Writing tests for a production Laravel application using Inertia in PestPHP
Taking the right testing approach
As I began my testing journey, I wanted to remain nimble. I feel very productive with the current offerings from the Laravel
framework. The last thing I wanted was to spend a large effort to write beautiful tests, but then be held back by failing tests when I changed something in the future. I wanted a broad coverage approach, that, in the end, would help me ship faster with confidence, but also not be a burden to update.
There are plenty of debates about approaches to developing applications1: TDD (Test Driven Development), DDD (Domain Driven Development), BDD (Behavior Driven Development). I will not prescribe one solution as it truly depends on your needs, your product, your team size, tech stack, and so much more...
Here are my guiding principles:
- Write tests for happy paths, starting with core logic and most used product flows
- Keep tests at the right layer/level: unit first, and only go deep (end-to-end) when required
- Run your tests on CI before deploying, so you get the maximum value out of your efforts
Available tools for Laravel
We have various testing frameworks and tools, but the ones I would recommend that are currently state of the art:
- PestPHP: built on top of PHPUnit
- Laravel Dusk: end-to-end browser testing using Selenium, a chrome driver
Ok so where do I start?
First off, let's tackle writing tests for our core business logic. I started off stepping back and looking at my application:
- Importance: What product features do my users use most? Or expect to use most?
-
- Think: For the average user, what is the most used product flows and core features?
- Complexity: Where does high complexity live in my application?
-
- Think: Places I have lower confidence? Things that I test manually often? Places with lots of comments?
- Stability: If things go really wrong in a feature/flow, how much trouble would I be in?
-
- Think: Is there any feature that would take my site down completely?
Taking the intersection of those focus areas, you can land on a list resembling product features or core logic that is: important, complex, and keeps stability high.
Structuring Laravel tests in PestPHP
I like to follow the format of creating test files and folders that map to my actual controller logic. This makes it easy to find things now and in the future.
The convention is as follows:
-
App/Http/Controllers/UserController.php
-->tests/Features/Http/Controllers/UserControllerTest.php
Note: If you started your application with a starter kit like Laravel Jetstream, you will likely already have tests scaffolded for you that exist in the: tests/Features/Jetstream
folder.
Writing Laravel tests for controllers
Usually I structure my controllers to be CRUD
and try to follow Laravel conventions as much as I can, which is a great strategy to have a maintainable app throughout the years. My controllers usually have these methods:
-
index()
: show a list ofMyModel
-
show(MyModel $model)
: show a specific instance ofMyModel
-
store(MyModel $model)
: persist a new instance ofMyModel
-
update(MyModel $model)
: update an existing instance ofMyModel
-
delete(MyModel $model)
: delete an existing instance ofMyModel
All methods are important, but I like to start out with index()
to get momentum, in which case I create something like this:
UserContactControllerTest.php
Context: This test is using PestPHP, Inertia, and laravel-data
(from the amazing folks at Spatie).
- Create test user via Laravel eloquent factories and login via
actingAs
- Create controller specific test data, with more factories
- Get response via
get
andaction
methods, via Laravel pest plugin - Assert response is an
Inertia
response with a specific pageComponent
and specificprops
1use App\Http\Controllers\UserContactController;
2use App\Models\User; 3use App\Models\UserContact; 4use Inertia\Testing\AssertableInertia; 5use function Pest\Laravel\get; 6use function Pest\Laravel\actingAs; 7 8beforeEach(function () { 9 $this->user = User::factory()->createOne();10 actingAs($this->user);11});12 13test('can show list of contacts', function () {14 $contacts = UserContact::factory()15 ->for($this->user)16 ->count(10)17 ->create();18 19 $response = get(action([UserContactController::class, 'index']));20 21 $response->assertInertia(function (AssertableInertia $page) {22 $page23 ->component('Contacts/ContactsPage')24 ->has('viewData.paginatedUserContacts', function (AssertableInertia $page) {25 ...26 });27 });28})
Benefits of this feature test
You are testing the happy path, given scaffolded test data, covering:
- Model logic: your controller should fetch the models created from factories and pass into Inertia page
-
Authorization logic: assuming your controller has a
Form request
with attached authorization - Inertia logic: ensuring this page is the right page component, and this component has the right props view data
There is a whole lot more to test here, if you'd like, but this is a good starting point.
Next steps
Overall there is a lot to go into on how to test things the right way. This test is an introduction to testing Laravel applications, but will go a long way. There are more things to cover to make sure you are shipping quality with guardrails in place:
- Testing that queues, mail, events are working appropriately using facade fakes
- Testing that your SPA frontend (Inertia for example) has no Javascript errors
-
- Think: The test we have above is only testing the server side, but there can be an issue on client side that will block rendering the content to the end user
- Testing model CRUD operations: insert, update, delete. These tests will focus on passing in input to API/route, then asserting database has or doesn't have certain elements
- Running your tests in CI! Github Actions is great for this, but it's not always straightforward to get set up and a bit of a pain to get right
Final thoughts
As you become more familiar with writing and running tests, it becomes an important feedback loop and a great source of pleasure when you make large scale changes, run your test suite (which you know covers your changes) and all things are green!
Although 100% test coverage shouldn't be your goal, my advice would be test the things that you think are important, and don't think too hard about it after that. Stay tuned for more real-world testing advice!