Tidying Up Your PHPUnit Tests with Class-Based Model Factories

Feature image: Tidying Up Your PHPUnit Tests with Class-Based Model Factories

As you begin writing automated tests, one of the first questions you will find yourself asking is, "How do I make it clear what this test is accomplishing?" It's important for tests to have a natural and logical flow that any future developer can follow, but that's easier said than done.

In order to solve this issue, many developers format their tests in the structure of “arrange, act, assert.” First, you create the world that your test will run in; then you trigger an action; and finally, you verify that any changes caused by that action match your expectations.

After using this structure for a while, I learned that my tests were prone to becoming top-heavy. As your application increases in complexity, you will find yourself spending more and more time on the "arrange" step, and before you know it, this code has bloated to become three times as long as the other steps. While Laravel offers tools such as model factories to help address this issue, it does not take long for them to begin to feel underpowered. Let’s examine this problem and my preferred solution to it: class-based model factories.

A Real-World Example

To begin, let's take a look at a real test from a personal project I'm working on. The app is fantasy football-esque in nature, but instead of being centered around football, it is for a TV show similar to Survivor. Within the app, admin users create "seasons" (i.e. a season of a TV show), and each season has a certain amount of "contestants" (people who are on the show). Users can then create one or multiple "leagues" for a season, and invite their friends ("league invitations") who become "league members" once they accept the invitation. Each league member builds their own roster comprised of a certain number of the contestants. They then earn or lose points throughout the season based on the actions of the contestants on their roster. At the end of the season, whichever league member has earned the most points from their roster wins the game.

If you didn't completely follow that, don't worry! The domain knowledge is helpful but not critical to understand for our goals here. Now, let's take a look at that example test:

/** @test */
public function the_combined_total_of_members_and_invitations_for_a_league_cannot_exceed_the_number_of_contestants()
{
// Arrange
$user = factory(User::class)->create();
$season = factory(Season::class)->create();
$contestants = factory(Contestant::class, 20)->create([
'season_id' => $season->id,
]);
$league = factory(League::class)->create([
'user_id' => $user->id,
'season_id' => $season->id,
]);
$leagueInvitations = factory(LeagueInvitation::class, 10)->create([
'league_id' => $league->id,
]);
$leagueMembers = factory(LeagueMember::class, 10)->create([
'league_id' => $league->id,
]);
 
// Act
$response = $this->actingAs($user)
->post("/leagues/{$league->getRouteKey()}/invitations");
 
// Assert
$response->assertStatus(403);
$this->assertEquals('If you invite another user, you will have more invitations and members than there are contestants!', $response->message);
}

Note: I will avoid going into the nitty-gritty details of this test, but the basic idea is that leagues cannot have a larger combined total of members and invitations than the season has contestants. So if a user tries to invite 21 people to a league where there are only 20 contestants available, we should see an error.

I don't know about you, but the "arrange" step in the example above feels like a lot to digest to me. It has over three times as many lines of code as the other two steps combined, and I'm not convinced that I could come back to it in a few months and understand why I set things up the way I did. Each step is crucial in creating the world we need, but together they feel like a mental burden killing the readability of the test from the start.

Previous Solutions

Over the years, I've approached this problem in a few different ways. I have tried moving everything to the setUp method of the test class, a solution that feels right at first but reveals its brittleness when different tests require slightly different worlds. I've tried creating service classes for each scenario with names such as CreateLeagueWithMaxMembers, but they quickly began to feel like one-offs that couldn't justify their existence. Each "solution" just moved the problem somewhere else. That is, until I discovered class-based model factories.

Class-based Model Factories

Class-based model factories are classes that create, build relationships for, and return Laravel model factories. Not every model factory necessitates a class-based counterpart, though, so how do you determine which do? If you take a hard look at the example test, you'll notice that the "arrange" step uses six different model factories, but in the "act" and "assert" steps we only reference two of them: $user and $league. Typically, if one model factory appears to be the true focus of our interest while a large number of others are merely there to support it, it's a good indication that it deserves a class-based equivalent. In this case, it's all about the league.

Let's take another look at the portion of code in our example test that is relevant to creating the league:

/* ... */
 
$user = factory(User::class)->create();
$season = factory(Season::class)->create();
$league = factory(League::class)->create([
'user_id' => $user->id,
'season_id' => $season->id,
]);
 
/* ... */

This league belongs to a specific season and is owned by a specific user. Let's build a class-based model factory for the league that allows you to create the same thing:

<?php
 
use App\League;
 
class LeagueFactory
{
public function create($overrides = [])
{
return factory(League::class)->create($overrides);
}
}

Note: I will typically add class-based model factories to the root of the /database directory in Laravel. You can store them wherever you'd like.

We can now update our test to use the LeagueFactory instead of a traditional model factory:

/* ... */
 
$user = factory(User::class)->create();
$season = factory(Season::class)->create();
$league = app(LeagueFactory::class)->create([
'user_id' => $user->id,
'season_id' => $season->id,
]);
 
/* ... */

It may not appear like this change has accomplished much, but what we have done is unlocked the full power of object-oriented programming. For instance, what are we really doing when we save the season and user id to the league above? We're trying to say "this league is owned by this user and belongs to this season."

Let's update the LeagueFactory with methods that allow us to more clearly relate seasons and users to a league. We can also make them fluent by returning $this so that we can easily chain them together:

<?php
 
use App\League;
use App\Season;
use App\User;
 
class LeagueFactory
{
public $user = null;
public $season = null;
 
public function ownedBy(User $user)
{
$this->user = $user;
 
return $this;
}
 
public function forSeason(Season $season)
{
$this->season = $season;
 
return $this;
}
 
public function create()
{
return factory(League::class)->create([
'user_id' => $this->user ?? factory(User::class)->create(),
'season_id' => $this->season ?? factory(Season::class)->create(),
]);
}
}

We can now use our new fluent methods for relating seasons and users to a league in the "arrange" step:

/* ... */
 
// Arrange
$user = factory(User::class)->create();
$season = factory(Season::class)->create();
$contestants = factory(Contestant::class, 20)->create([
'season_id' => $season->id,
]);
$league = app(LeagueFactory::class)->ownedBy($user)->forSeason($season);
$leagueInvitations = factory(LeagueInvitation::class, 10)->create([
'league_id' => $league->id,
]);
$leagueMembers = factory(LeagueMember::class, 10)->create([
'league_id' => $league->id,
]);
 
/* ... */

This looks a little bit better, but there is still a lot going on here. In particular, creating the league invitations and league members is taking up a large amount of space and creating mental debt. It's important to remember that the "act" and "assert" portions of the test don't even reference the invitations or members directly: they just depend on a certain amount of them existing. So let's add a few more fluent methods to the LeagueFactory that delegate creating a specific amount of those relationships instead:

<?php
 
use App\League;
use App\LeagueInvitation;
use App\LeagueMember;
use App\Season;
use App\User;
 
class LeagueFactory
{
public $invitesCount = 0;
public $membersCount = 0;
public $user = null;
public $season = null;
 
public function withInvitations($count)
{
$this->invitesCount = $count;
 
return $this;
}
 
public function withMembers($count)
{
$this->membersCount = $count;
 
return $this;
}
 
public function ownedBy(User $user)
{
$this->user = $user;
 
return $this;
}
 
public function forSeason(Season $season)
{
$this->season = $season;
 
return $this;
}
 
public function create()
{
$league = factory(League::class)->create([
'user_id' => $this->user ?? factory(User::class)->create(),
'season_id' => $this->season ?? factory(Season::class)->create(),
]);
 
factory(LeagueInvitation::class, $this->invitesCount)->create([
'league_id' => $league->id,
]);
 
factory(LeagueMember::class, $this->membersCount)->create([
'league_id' => $league->id,
]);
 
return $league;
}
}

Note: Here, we are making use of the second parameter to Laravel's factory helper, which is a number that denotes how many of the models we want to create. In this case (because we default them to zero) if we don't apply these fluent methods, no invitations or members will be created.

Now we can update our "arrange" step with our new methods for creating invitations and members:

/* ... */
 
// Arrange
$season = factory(Season::class)->create();
$contestants = factory(Contestant::class, 20)->create([
'season_id' => $season->id,
]);
$user = factory(User::class)->create();
$league = app(LeagueFactory::class)
->ownedBy($user)
->forSeason($season)
->withInvitations(10)
->withMembers(10);
 
/* ... */

This already looks a lot better, but we can still do more! In my real-world situation, almost every test creates a season with a specific number of contestants. Seeing repeated logic like that throughout different test setups is another alarm bell that makes me think a class-based model factory might be appropriate here. So let's whip up a SeasonFactory that allows us to create a season with any number of contestants:

<?php
 
use App\Contestant;
use App\Season;
 
class SeasonFactory
{
public $contestantsCount = 0;
 
public function withContestants($count)
{
$this->contestantsCount = $count;
 
return $this;
}
 
public function create()
{
$season = factory(Season::class)->create();
 
factory(Contestant::class, $this->contestantsCount)->create([
'season_id' => $season->id,
]);
 
return $season;
}
}

With our new SeasonFactory, we can make our "arrange" step even more clear:

/* ... */
 
// Arrange
$season = app(SeasonFactory::class)->withContestants(20)->create();
$user = factory(User::class)->create();
$league = app(LeagueFactory::class)
->ownedBy($user)
->forSeason($season)
->withInvitations(10)
->withMembers(10)
->create();
 
/* ... */

Here's how it looks within the context of the entire test:

/** @test */
public function the_combined_total_of_members_and_invitations_for_a_league_cannot_exceed_the_number_of_contestants()
{
// Arrange
$season = app(SeasonFactory::class)->withContestants(20)->create();
$user = factory(User::class)->create();
$league = app(LeagueFactory::class)
->ownedBy($user)
->forSeason($season)
->withInvitations(10)
->withMembers(10)
->create();
 
// Act
$response = $this->actingAs($user)
->post("/leagues/{$league->getRouteKey()}/invitations");
 
// Assert
$response->assertStatus(403);
$this->assertEquals('If you invite another user, you will have more invitations and members than there are contestants!', $response->message);
}

This is looking really good, but I wish our class-based model factories functioned more like Laravel's facades so that we didn't have to pass them through to the app container every time we use them. Well, thanks to Laravel's real-time facades feature, this is an easy change.

First, add the following line to the top of the factory classes create method:

\Facades\FactoryName::clearResolvedInstance('FactoryName');

For example, inside the SeasonFactory:

/* ... */
 
public function create()
{
\Facades\SeasonFactory::clearResolvedInstance('SeasonFactory');
 
$season = factory(Season::class)->create();
 
factory(Contestant::class, $this->contestantsCount)->create([
'season_id' => $season->id,
]);
 
return $season;
}
 
/* ... */

Note: The clearResolvedInstance method prevents your class-based model factories from behaving statically. Without it, if you created 2 seasons with your SeasonFactory and set the first one to have 10 contestants, the second one would also inherit that setting.

Next, simply prefix your class-based model factories with Facades\ when you import them, and they will magically begin to function like standard facades in Laravel. Here's an example:

use Facades\LeagueFactory;
use Facades\SeasonFactory;
 
/* ... */
 
// Arrange
$user = factory(User::class)->create();
$season = SeasonFactory::withContestants(20)->create();
$league = LeagueFactory::ownedBy($user)
->forSeason($season)
->withInvitations(10)
->withMembers(10)
->create();
 
/* ... */

Quick and simple! (Thank you to Caleb Porzio for the idea of combining class-based model factories with real-time facades).

Factory States

If you've gotten this far, you may be wondering where traditional model factory "states" fit into this equation, and whether or not class-based model factories remove the need for them entirely. In my experience, using one certainly does not mean losing out on the other. In fact, they complement each other quite nicely! While class-based model factories are well suited to creating model relationships on the fly, factory states are still the best way to update data within the model itself. We can make factory states available to us within our class-based model factories by adding a simple fluent method for them. Let's update our SeasonFactory as an example:

<?php
 
use App\Contestant;
use App\Season;
 
class SeasonFactory
{
public $states = null;
public $contestantsCount = 0;
 
public function states($states)
{
$this->states = $states;
 
return $this;
}
 
public function withContestants($count)
{
$this->contestantsCount = $count;
 
return $this;
}
 
public function create()
{
\Facades\SeasonFactory::clearResolvedInstance('SeasonFactory');
 
$season = factory(Season::class)->states($this->states)->create();
 
factory(Contestant::class, $this->contestantsCount)->create([
'season_id' => $season->id,
]);
 
return $season;
}
}

With this addition, we can utilize the full power of factory states and class-based model factories together:

SeasonFactory::states('some_example_state')->withContestants(20)->create();

Conclusion

Thanks to class-based model factories, our test setup went from being a bloated mess to simple and succinct. The optional fluent methods give us flexibility and make it obvious when we are intentionally changing our world. Should you read this blog post and immediately go and update all of your model factories to be class-based instead? Of course not! But if you begin to notice your tests feeling top heavy, class-based model factories may be the tool to reach for.


I'd like to give a shout-out to Adam Wathan, whose Test-Driven Laravel course first introduced me to the basic concept of wrapping classes around model factories. Additional shout-outs to Caleb Porzio, Daniel Coulbourne, Jose Soto, and Marje Holmstrom-Sabo, who were all a part of a multi-hour conversation regarding improving Laravel model factories at the 2017 Tighten on-site. This idea was built on top of many ideas that came up during that conversation.

Get our latest insights in your inbox:

By submitting this form, you acknowledge our Privacy Notice.

Hey, let’s talk.
©2024 Tighten Co.
· Privacy Policy