Laravel test learnings for 2022
I had a large untested side project at the beginning of 2022 which I wanted to start getting under automated tests. My target for this year wasn’t to get it to complete test coverage which I thought may be too ambitious, but just to write 1,000 PHPUnit tests before 2023. The main goal was to try to prepare as much as possible for a PHP 8 + Laravel 9 upgrade.
Got to 2,227 as of today! 🥳 It’s not close to complete coverage still, but I’m planning on spending the rest of the year getting this dockerised and up to PHP 8.1 and Laravel 9 so thought it might be a good time to put some thoughts about testing down.
Approaching a legacy codebase
The code in this codebase was not written with testing in mind and was mostly untestable, so having to get started with writing tests for it was intimidating. I took the following approach:
- Write simple HTTP tests for all existing endpoints (or as many as I could). This gives me a safety net for refactoring.
- As I rewrite or refactor code, maintain any existing HTTP tests while also writing corresponding “unit tests” for new classes or functions.
This was very comfortable and low-stress. The HTTP tests gave me some confidence to make changes to old code, while being quick to write without needing me to dig into the implementation. All I had to do was to go to each endpoint that needed testing, record whatever payloads were currently being sent, and assert the existing behaviours and responses. This process also surfaced a few bugs which I was able to fix.
Structuring the tests directory
Picked up these tips from Testing Laravel (Spatie) and Battle-Ready Laravel (Ash Allen) ☺️
Structure HTTP test directory according to the controller folder structure.
I originally opted to structure tests by “domain”, EG —
/Tests
/Feature
/Post
/Post_Create_Test.php
/Post_Edit_Test.php
/Post_Index_Test.php
/Post_Store_Test.php
/Post_Update_Test.php
/Post_View_Test.php
/User
/UserProfile_Edit_Test.php
/UserProfile_Update_Test.php
/UserProfile_View_Test.php
This didn’t work very well. There were at least two times where I accidentally rewrote tests for a group of endpoints before realising later I had written them twice.
The problem is endpoints where two domains overlap — if you have a page on a user’s profile that shows all of their posts, does that go into the User folder, or into the Posts folder? Once you have a few hundred tests, how do you quickly check whether the tests for a specific page have already been written and where they got put?
Structuring the tests according to controllers feels theoretically unsound for HTTP tests, considering these aren’t controller tests (they’re HTTP tests!), plus controllers don’t necessarily map 1:1 to routes. I tried this out and it’s been treating me well, though. It’s very easy to find tests corresponding to any given endpoint by just following the route to the Controller, then the Controller’s file path to the test.
/Tests
/Feature
/Http
/Controllers
/Post
/PostController
/GetCreate_Test.php
/GetEdit_Test.php
/GetView_Test.php
/PostStore_Test.php
/PostUpdate_Test.php
/User
/ProfileController
/GetEdit_Test.php
/GetView_Test.php
/PostUpdate_Test.php
/AccountController
...
Give each method a new test class
A lot of testing examples I’ve seen before create one test per class. EG: A UserController
may have a UserControllerTest
.
I didn’t like this approach due to how large the test classes got; this made them hard for me to navigate and discouraged me from adding “too many” test cases for each class.
I started breaking every test up into one folder per class (eg. /UserController/
), and inside each folder a test class for every method that needed testing (eg. /UserController/GetView_Test.php
). Each test class feels more fresh and tidy.
The obvious benefit for a HTTP test-centric test suite is that due to how broad the testing endpoints are, methods unavoidably end up having many behaviours that need verification. I would sometimes break one method up into multiple test classes to make it easier to organise tests: /UserController/GetView_blocksUnauthorizedUsers_test.php
, /UserController/GetView_displaysCustomLayoutBlocks_Test.php
, /UserController/GetView_displaysLatestPosts_Test.php
, etc. This gave me lots of space for testing lots of test cases.
Custom factories
This is a tip I picked up from Laravel Beyond Crud (Spatie) — I ran into a lot of problems with Laravel’s in-built factories and didn’t like the way state()
and afterCreated()
was used. Building my own factory classes with custom logic felt nicer and more flexible to work with.
EG —
class PostFactory extends Factory
{
private ?User $user = null;
private ?PostPrivacy $privacy = null;
private array $subscribers = [];
public function user(User $user): PostFactory
{
$factory = clone $this;
$factory->user = $user;
return $factory;
}
public function privacy(PostPrivacy $privacy): PostFactory { /* ... */ }
public function withSubscriber(User $user): PostFactory { /* ... */ }
public function create(): Post
{
$user = $this->user ?? UserFactory::new()->create();
$post = Post::create([
'user_id' => $user->id,
'privacy' => $this->privacy ?? PostPrivacy::PUBLIC,
'subscribers_count' => count($this->subscribers),
]);
/* @var User $subscriber */
if ($this->subscribers) {
$post->subscribers()->attach($this->subscribers);
}
}
}
public function test_post()
{
$user = UserFactory::new()->create();
$post = PostFactory::new()->create();
$subscribedPost = PostFactory::new()->withSubscriber($user)->create();
$ownedPost = PostFactory::new()->user($user)->create();
}
Data providers
I like data providers a lot for quickly covering a lot of cases without having to copy paste a test a bunch of times. I found myself using these often for authorization tests which I thought were highly sensitive (in my use case a broken authorization check is the worst possible scenario — the entire site going down is preferable to accidentally leaking private user content).
HTTP tests for authorization? Why not just test the policy?
Testing the policy means that the policy works but doesn’t necessarily mean that the policy has been correctly attached to the endpoint. On account of having a single celled brain, I can only internalize that the codebase is working after seeing the HTTP endpoint getting hit and a correct response being returned.
I wrote policy tests, but didn’t see a significant downside to writing redundant HTTP tests in addition to policy tests. The biggest cost is development time, but when using data providers together with factories I’ve found this is very fast for the amount of extra confidence it gives me.
EG —
/**
* @dataProvider dataProvider_visitor_sees_content
*/
function test_visitor_sees_content(Closure $userGenerator, bool $canViewContent)
{
$user = UserFactory::new()->create();
$this->actingAs($userGenerator($user));
$res = $this->get(action([UserController::class, 'view'], ['user' => $user]));
if ($canViewContent) {
$res->assertSuccessful()->assertSee($user->username);
} else {
$res->assertForbidden()->assertDontSee($user->username);
}
}
function dataProvider_visitor_sees_content(): array
{
$f = fn (User $user) => UserRelationshipFactory::new($user);
return [
'random user = hide' => [fn (User $user) => UserFactory::new()->create(), true],
'authorized user = show' => [fn (User $user) => $f($user)->authorizedUser(), true],
'blocked user = hide' => [fn (User $user) => $f($user)->blockedUser(), false],
'blocking user = hide' => [fn (User $user) => $f($user)->blockingUser(), false],
'owner = show' => [fn (User $user) => $user, true],
];
}
Data providers like this can be re-used between different tests with a trait. If you have a Post index on the main page, and a Post index on the user profile, and a Post index on the search page, and all three have the same “visibility” rules, you can just use the same dataProvider for all three endpoints. The same data provider can even be re-used for the HTTP test and the policy test.
trait TestsPostVisibility
{
function dataProvider_post_index_visibility(): array
{
$publicPost = fn () => PostFactory::new();
$authorizedOnlyPost = fn () => PostFactory::new()->privacy(PostPrivacy::AUTHORIZED_ONLY);
$privatePost = fn () => PostFactory::new()->privacy(PostPrivacy::PRIVATE);
$randomUser = fn (Post $post) => UserFactory::new()->create();
$authorizedUser = fn (Post $post) => UserRelationshipFactory::new($post->user)->authorizedUser()->create();
$ownerUser = fn (Post $post) => $post->user;
return [
'public post + random user = show' => [$publicPost, $randomUser, true],
'public post + authorized user = show' => [$publicPost, $authorizedUser, true],
'public post + owner user = show' => [$publicPost, $ownerUser, true],
'authorized post + random user = hide' => [$authorizedOnlyPost, $randomUser, false],
'authorized post + authorized user = show' => [$authorizedOnlyPost, $authorizedUser, true],
'authorized post + owner user = show' => [$authorizedOnlyPost, $ownerUser, true],
'private post + random user = hide' => [$privatePost, $randomUser, false],
'private post + authorized user = hide' => [$privatePost, $authorizedUser, false],
'private post + owner user = show' => [$privatePost, $ownerUser, true],
];
}
}
// User\PostIndex_Test.php
class PostIndex_Test
{
use TestsPostVisibility;
/**
* @dataProvider dataProvider_post_index_visibility
*/
public function test_expected_posts_are_visible(Closure $postFactoryGenerator, Closure $visitorGenerator, bool $isVisible)
{
$user = UserFactory::new()->create();
$post = $postFactoryGenerator()->user($user)->create();
$this->actingAs($visitorGenerator($post));
if ($isVisible) {
// ...
} else {
// ...
}
}
}
// Feed\PostIndex_Test.php
class PostIndex_Test
{
use TestsPostVisibility;
/**
* @dataProvider dataProvider_post_index_visibility
*/
public function test_expected_posts_are_visible(Closure $postFactoryGenerator, Closure $visitorFactoryGenerator, bool $isVisible)
{
$post = $postFactoryGenerator()->public()->create();
$this->actingAs($visitorGenerator($post));
// ...
}
}
// Search\PostSearch_Test.php
class PostSearch_Test
{
use TestsPostVisibility;
/**
* @dataProvider dataProvider_post_index_visibility
*/
public function test_expected_posts_are_visible(Closure $postFactoryGenerator, Closure $visitorFactoryGenerator, bool $isVisible)
{
$post = $postFactoryGenerator()->searchable()->create();
$this->actingAs($visitorGenerator($post));
// ...
}
}
Slow tests
Parallel testing
./vendor/bin/sail phpunit --parallel
Not working? I had RefreshDatabase
on my test cases — this was causing the database to constantly refresh itself over other tests and giving me a variety of “this table doesn’t exist” or “this table already exists” errors.
Replacing this with DatabaseTransactions
fixed the issue.