Writing Clear and Maintainable Tests in Laravel
Laravel developers often put great care into structuring their application code: organizing logic into controllers, middleware, policies, jobs, mailables, resources, services etc.. But when it comes to tests, I wonder how many developers apply the same level of discipline.
Without intentional structure, a test suite can quickly become hard to grok: confusing for new developers, messy to work with, and difficult to navigate, despite being one of the most important parts of the codebase.
A Better Testing Structure
A simple way to make your test suite more navigable is to mirror the structure of your application. Instead of scattering tests across the default Unit and Feature folders, organize them according to the app/ directory inside tests/.
Here’s an example of how this could look:
| Type | Location |
|---|---|
| Controller Test | tests/Unit/Http/Controllers/ListingControllerTest.php |
| Middleware Test | tests/Unit/Http/Middleware/AuthenticateTest.php |
| Policy Test | tests/Unit/Policies/ListingPolicyTest.php |
| Job Test | tests/Unit/Jobs/SendWelcomeEmailTest.php |
| Macro Test | tests/Unit/Macros/StringMacrosTest.php |
| Mail Test | tests/Unit/Mail/PasswordResetTest.php |
| Model Test | tests/Unit/Models/UserTest.php |
| Rule Test | tests/Unit/Rules/ValidPlatformRuleTest.php |
| Service Test | tests/Unit/Services/MessageSendingServiceTest.php |
Organizing tests this way makes it obvious where new tests belong and where to look for existing ones. For example, anyone unfamiliar with the project looking for validation tests on a listing will immediately know to look inside the listing controller tests.
Better structure leads to more focussed tests
Suppose you need to create a test for a job that syncs email bounces from Postmark. You want to ensure that these bounces are correctly recorded in your database. With a structured approach (one that mirrors your application’s folder organization and keeps each test focused on a single responsibility) writing this test becomes straightforward.
You would start by creating a dedicated test file for the job tests\Unit\Jobs\SyncBouncesWithPostmarkTest and inside the file add a test to fake the Postmark API and verify that the bounce information is correctly applied to the relevant user in the database.
public function test_suppressions_are_fetched_from_postmark(): void
{
Http::fake([
'https://api.postmarkapp.com/*' => Http::response(
json_encode(['Suppressions' => [
[
'EmailAddress' => 'john.doe@example.com',
'CreatedAt' => '2025-12-04T00:00:00Z',
'SuppressionReason' => 'HardBounce',
],
]]),
200,
['X-Postmark-Server-Token' => 'fAkeTokEn'],
),
]);
$john = User::where('email', 'john.doe@example.com')->first();
$this->assertNull($john->email_bounced_at);
$this->assertNull($john->email_bounce_type);
Bus::dispatchNow(new SyncBouncesWithPostmark());
$this->assertSame('2025-12-04 00:00:00', $john->refresh()->email_bounced_at->toDateTimeString());
$this->assertSame('HardBounce', $john->email_bounce_type);
Http::assertSent(function ($request) {
return $request->url() === 'https://api.postmarkapp.com/message-streams/outbound/suppressions/dump';
});
}
By organizing tests this way, it’s immediately clear where to place new tests, and each test is focused on a single responsibility, making your test suite easier to read, maintain, and extend.
Great Test Names Improve Readability
Test names that explain exactly what the test is doing improve readability and understanding. Consider the following code:
public function test_a_valid_listing_must_be_provided()
It is not clear what this test is actually doing. Maybe it is testing a domain rule, a validation rule or something about how listings are defined. When writing a test name, imagine you were explaining what the test did to someone who is new to the programming world.
Something like this is more specific:
public function test_listings_return_a_not_found_response_when_the_uuid_cannot_be_found(): void
{
$uuid = Uuid::uuid4()->toString();
$response = $this->postJson("/api/listings/$uuid", [
'message' => "Hello friendly guest",
]);
$response->assertNotFound();
}
Use Semantic Assertions for Clarity
Another small improvement you can make to your tests is to make use functions that express the intent of the test more clearly. For example, instead of asserting an error code, use the semantic equivalent:
| Generic Assertion | Semantic Equivalent |
|---|---|
| $response->assertStatus(200) | $response->assertOk() |
| $response->assertStatus(403) | $response->assertForbidden() |
| $response->assertStatus(201) | $response->assertCreated() |
| $response->assertStatus(422) | $response->assertUnprocessable() |
Obviously both are fine, but I find the semantic helpers make tests a little easier to read.
Conclusion
Well-organized tests make writing, updating, and understanding tests easier, while providing clarity for anyone working on the application. By structuring your tests to mirror your application folders, keeping them focused, using descriptive names, and leveraging semantic assertions, your test suite becomes a reliable guide for your codebase.
