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:

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.php
trait 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.php
trait 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.

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.