In writing tests, I tend to abstract things that aren't necessary for the specific feature that's under test. That way, the test case itself can read as documentation.
One of the biggest filler for test cases are setups of the test.
Let's start with a feature
To walk through this article, let's say we're building a feature to send a message to a Slack channel.
it('sends a message to a channel', function () { $workspace = Workspace::factory()->create(); $user = User::factory() ->hasAttached($workspace, ['owner' => true]) ->create(); $channel = Channel::factory() ->for($workspace) ->hasAttached($user) ->create(); actingAs($user); assertDatabaseCount('messages', 0); sendMessage($workspace, $channel, [ 'content' => 'Good morning! 👋', ])->assertRedirect( route('channels.show', [$workspace, $channel]) ); assertDatabaseCount('messages', 1); assertDatabaseHas('messages', [ 'content' => 'Good morning! 👋', ]);}) function sendMessage($workspace, $message, array $attributes){ return post(route('channels.store', [$workspace, $channel]), array_replace([ 'content' => 'Some valid content.', ], $attributes));}
While straightforward enough, it adds up quite a bit the more test cases we need.
it('sends a message to a client', ...); it('validates the data', ...); it("doesn't allow non-channel members to send a message", ...);
beforeEach
/setUp
To reduce the cognitive load of these tests, the common attributes are move up to the setUp
method. Or in Pest's case, the beforeEach
block.
Once we've moved the setup to the beforeEach
block, our test would look something like this.
beforeEach(function () { $this->workspace = Workspace::factory()->create(); $this->user = User::factory() ->hasAttached($workspace, ['owner' => true]) ->create(); $this->channel = Channel::factory() ->for($workspace) ->hasAttached($this->user) ->create(); actingAs($this->user);}); it('sends a message to a channel', function () { assertDatabaseCount('messages', 0); sendMessage($this->workspace, $this->channel, [ 'content' => 'Good morning! 👋', ])->assertRedirect( route('channels.show', [$this->workspace, $this->channel]) ); assertDatabaseCount('messages', 1); assertDatabaseHas('messages', [ 'content' => 'Good morning! 👋', ]);})
Using a seeder
Looking at the set up, this won't be the only time we're going to write test data for:
- Some random workspace.
- Some random user in the workspace.
- Some random channel.
To work with this, some developers use a seeder and use that to set up their tests.
beforeEach(function () { $this->seed(TestAccountSeeder::class); actingAs(User::first());});
While this is totally valid, it personally doesn't sit right with me.
The seeders are a bit way out of reach, and the models need to be re-queried to be able to be usable in the test set up anyway.
Granted, I haven't spent a lot of time using this approach so I might be completely wrong on how developers are setting up their tests with seeders.
My preference: Traits
In Laravel, you can create traits that run during the setUp
process by adding a method with setup
prefixed to the trait name. Similiar to how bootable traits work with models.
I use these traits to set up the world depending on the context of a specific test case.
Here's how our test case would look.
# Fixtures/ActingAsRandomAccountOwner.phptrait ActingAsRandomAccountOwner{ protected $workspace; protected $user; public function setupActingAsRandomAccountOwner() { $this->workspace = Workspace::factory()->create(); $this->user = User::factory() ->hasAttached($workspace, ['owner' => true]) ->create(); actingAs($this->user); }} # Fixtures/OnPublicChannel.phptrait OnPublicChannel{ protected $channel; public function setupOnPublicChannel() { $this->channel = Channel::factory() ->hasAttached($this->user) ->create(); }} # Feature/SendMessageTest.php uses(ActingAsRandomAccountOwner::class, OnPublicChannel::class); it('sends a message to a channel', function () { assertDatabaseCount('messages', 0); sendMessage($this->workspace, $this->channel, [ 'content' => 'Good morning! 👋', ])->assertRedirect( route('channels.show', [$this->workspace, $this->channel]) ); assertDatabaseCount('messages', 1); assertDatabaseHas('messages', [ 'content' => 'Good morning! 👋', ]);}) # if using PHPUnit class SendMessageTest extends TestCase{ use ActingAsRandomAccountOwner, OnPublicChannel; /** @test **/ function it_sends_a_message_to_a_channel() { // ... }}
I prefer this over seeders since I immediately have access to these class properties instead of having to requery them in the setup method. This also allows me to compose different traits depending on the context of the test.
-
OnPrivateChannel
-
ActingAsInvitedClient
-
AcinngAsWorkspaceMember
-
ActingAsSuperUser
- etc...
This is just another tool in the toolbox
I'm pretty sure someone that's used seeders in tests knows an approach to do the same thing, this pretty much works well with how my mental model wants tests to look like.
At least for myself, seeders tend to be a way to set up the world for manual testing, either through deployments, local development, etc.