How To Use Laravel Factories Inside A Data Provider
May 14, 2020 (6 min read)
Forward
If you just want the answer without all the fluff, I’ve condensed this down into a StackOverflow answer here. Give it a 👍 if you find it useful!
I ran into an issue with using Laravel’s factories inside of a data provider. It was a pretty frustrating thing to work around, and I’m not even sure I’m happy with what I came up with. But I got it working so I figured it’s worth sharing.
The first error was along the lines of Unable to locate factory with name [default] [App\User].
I found that pretty odd. I checked all the obvious reasons why it could be happening and it didn’t make any sense. I figured I’d lift it up into the setUp
method of the test instead.
Then I get another error! This time I can’t access property id
on null
😡 what?
After revisiting the docs, and reading through some threads on GitHub I realized that I hadn’t fully understood how data providers were actually working. So let’s start there.
How phpunit data providers work
Apparently, when the parser reads your test class, and finds a method with the @dataprovider
annotation, it runs the provider it references!
I honestly didn’t know this until today. I guess I’ve just never ran into the situation but I’ve always assumed it wouldn’t run until after setUp
which just isn’t true.
Instead phpunit runs the provider first so it knows how many tests there are. Then when it runs your test it injects the cached results from running the provider beforehand.
This is why you can’t use factories and all sorts of framework goodies. They haven’t been loaded yet! And when I lifted them into setUp
I was getting null references because setUp
hadn’t even ran yet…
How do you get around this?
There are a few ways to get around this.
Use the constructor
First of all, if you’re not trying to use Laravel’s factories or interact with a database in anyway for your setup inside the data provider, then the solution is pretty simple.
Just add the setup inside the constructor of your test class. The constructor gets executed before anything else including your data providers.
Unfortunately if you need the state you set up there to be torn down before, after or between tests this won’t work.
Use createApplication
The other solution is to call $this->createApplication()
or $this->refreshApplication()
inside your data provider. I don’t like this one for two reasons:
- Number one… I don’t like the idea of rebooting the entire application for each item in my provider array. That just seems insane to me, and probably to you too.
- Maybe there’s something I had setup wrong, but database transactions don’t seem to work with this method. Which is an absolute deal breaker for me as I like to keep my tests idempotent baby ✅✅✅✅✅✅✅.
Return a closure as the argument for each item in your provider
Eventually I stumbled onto this StackOverflow answer which I found referenced by this answer.
And then a light bulb went off 💡
I can just return a closure as the argument for each item in my provider array! So here’s how I achieved that.
First I modified the way I consume the provider to look like this:
/**
* @test
* @dataProvider validationProvider
*/
public function it_validates_payload($getData)
{
// data provider now returns a function which we can invoke to
// destructure our arguments here.
[$ruleName, $payload] = $getData();
$this->post(route('participants.store'), $payload)
->assertSessionHasErrors($ruleName);
}
For reference normally that function signature would have looked like this:
public function it_validates_payload($ruleName, $payload)
Which is the normal way to use data providers.
Now the actual data provider itself looks like this:
public function validationProvider()
{
return [
'it fails if participant_type_id does not exist' => [
function () {
return [
'participant_type_id',
array_merge($this->getValidData(), ['participant_type_id' => null])
];
}
],
'it fails if first_name does not exist' => [
function () {
return [
'first_name',
array_merge($this->getValidData(), ['first_name' => null])
];
}
],
'it fails if last_name does not exist' => [
function () {
return [
'last_name',
array_merge($this->getValidData(), ['last_name' => null])
];
}
],
'it fails if email is not unique' => [
function () {
$existingEmail = factory(Participant::class)->create([
'email' => 'pbeasly@dundermifflin.com'
])->email;
return [
'email',
array_merge($this->getValidData(), ['email' => $existingEmail])
];
}
],
];
}
🤢🤮 ewww…
I’m probably exaggerating my disgust because I wrote it and I have self-deprecating tendencies. But as if data providers weren’t hard enough to make readable. This is a little insane. Certainly not what an artisan would enjoy writing 🍸🤵. However it does work, and I’ve seen and have inherited much, much worse looking code than this.
This allows you to use factories and still have database transactions work!
So real quick before I cap this off, I want to show you what getValidData()
is doing. It’s just returning an array of valid data so each test is only asserting one validation error at a time. But it too, uses a factory:
private function getValidData()
{
return [
'practice_id' => factory(Practice::class)->create()->id,
'participant_type_id' => 1,
'first_name' => 'John',
'last_name' => 'Doe',
];
}
Some things that still bug me about this
- It’s just sloppy looking. Data providers are already difficult to make readable and this just makes it a lot worse. Although you could abstract a utility to help clean this up.
- In my example, I have a database call that gets made for every single scenario, since it gets run with each execution of the provider’s returned closures… yuk! I’m not sure what the best approach would be to fix that but one way would be to set up some state in the constructor of the class itself. Once the id is created after the first execution of the provider, you could pull the id from state rather than making a new call to the database each time.
Otherwise, it is a working solution for when you need this, and I hope it helps others if they happen to find this!
Let me know in the comments below if you’ve been able to work around this oddity and if it’s cleaner. I’d love learn from you!
Cheers,
Dan