Appearance
Back-end Testing
This guide outlines IxDF's testing practices for Laravel, focusing on creating maintainable and efficient tests.
This document assumes you:
- Know basic PHPUnit conceptions
- Read Laravel docs
- Testing
- HTTP Tests (a.k.a. Feature tests)
- DB Seeding
Table of Contents
Tools
For PHP testing, we use PHPUnit 11 together with testing functionality provided by Laravel Facades.
Why not Pest?
Pest is written in a way that uses a lot of magic, and it doesn't allow static analysis tools (incl. your IDE) to work on full power. We prefer to stick with PHPUnit. After all, Pest is a wrapper around PHPUnit.
Types of Tests
We maintain two primary types of tests:
- Unit tests (
/tests/Unit/): to test isolated components and business logic - Feature tests (
/tests/Feature/): to test HTTP endpoints and service integration
The base test case class \Tests\ApplicationTestCase does few things:
- creates a Laravel application instance
- sets up the database for each test
- (re)binds services in the container (use test doubles instead real implementations)
- adds some helper methods to simplify tests (
asAdmin,asSuperAdmin, etc.)
While extending and using ApplicationTestCase helps a lot, it also makes tests slower. For these reasons, extending/usage of PHPUnit\Framework\TestCase is preferable when possible.
Test Organization
Single Responsibility
Each test class should focus on a single component / endpoint.
For complex SUTs ("System Under Control"), split tests by functionality (Classical aka Detroit school):
text
📁 Tests/Feature/Modules/Payment/Http/Controllers
├─ PaymentControllerCreateTest.php
└─ PaymentControllerStoreTest.phpNaming
- Using underscore (snake_case) improves readability
- The name should describe the behavior, not the implementation
- Use names without technical keywords. It should be readable for a non-programmer person. Exception: testing utility classes.
Good examples:
php
#[Test]
public function it_prevents_enrollment_for_full_courses(): void
#[Test]
public function it_sends_welcome_email_only_to_new_members(): voidAAA pattern
Visually separate three sections of the test:
- Arrange: Bring the system under test in the desired state. Prepare dependencies, arguments and finally construct the SUT.
- Act: Invoke a tested element.
- Assert: Verify the result, the final state, or the communication with collaborators.
php
#[Test]
public function it_does_not_allow_usage_paragraph_with_css_class(): void
{
$purifier = new InputPurifier(); // Arrange
$purifiedOutput = $purifier->clean('<p class="js-doSomethingBad">Hi!</p>'); // Act
$this->assertSame('<p>Hi!</p>', $purifiedOutput); // Asserts block
}Data Providers
Use Data Providers to test sets of data:
php
#[Test]
#[DataProvider('validIp4AddressesDataProvider')]
public function it_accepts_valid_ip4_address(string $ip4Address): void
{
$ipAddressValueObject = new IpAddress($ip4Address);
$this->assertSame($ip4Address, $ipAddressValueObject->value);
}
/** @return \Generator<string, list{non-empty-string}> */
public static function validIp4AddressesDataProvider(): \Generator
{
yield 'regular' => ['1.2.3.4'];
yield 'localhost' => ['127.0.0.1'];
yield 'max value' => ['255.255.255.255'];
}When create Data Providers, prefer yield over return for better readability (performance is the same in case of PHPUnit).
To improve the developer experience, use descriptive string keys for each dataset.
Testing Best Practices
Test doubles
Use Laravel's built-in Facade-based fakes to fake implementations and simplify assertions:
php
#[Test]
public function it_queues_welcome_email(): void
{
Queue::fake();
$member = MemberFactory::new()->createOne();
Event::assertDispatched(MemberCreated::class);
Queue::assertQueued(WelcomeEmail::class);
}For custom services, use test doubles. When needed, rebind them in the container:
php
$this->app->bind('stripe_usa', FakeStripeGateway::class);
// or
$this->app->instance('stripe_usa', new FakeStripeGateway());There are various kinds of double that Martin Fowler lists:
- Dummy: objects are passed around but never actually used. Usually they are just used to fill parameter lists.
- Stub: simplest implementation that provides canned answers to calls.
- Fake: simplified implementation to simulate the original behavior, not suitable for production (e.g.
InMemoryDatabase). - Spy: are stubs that also record some information based on how they were called. One form of this might be an email service that records how many messages it was sent.
- Mock: record method calls and verify them.
Is database dependency?
Since it's a Laravel application that uses Active Record pattern, it's Models are tightly coupled with the database. For this reason, DB isn't considered an external service/dependency.
Avoid Mocking
Do not use Mocks (incl. Mockery package), when possible.
Use Model Factories Effectively
Explicitly leverage Laravel's Model Factories for test data:
php
// Instead of manual creation
$course = CourseFactory::new()
->published()
->advanced()
->createOne();Note, all Model Factories are located in Tests/Factories/{module-name} namespaces.
To help static analysis tools, please use createOne() method instead of create() and makeOne() instead of make().
Keep Test Data Simple
Use minimal, focused test data:
php
// Good
$email = 'any@example.com';
$amount = 10.00;
// Avoid
$email = 'john.smith.1234@gmail.com';
$amount = 99.62;Strict assertions
Use assertSame() instead of assertEqual() (=== vs. ==) (Rector migrates assertions automatically).
Common Pitfalls to Avoid
Time-dependent Tests
php
// Bad
$this->assertTrue($course->isAvailable());
// Good
Carbon::setTestNow(now()->next('Sunday'));
$this->assertTrue($course->isAvailable());Brittle Assertions
php
// Bad: will fail after copy changes
$response->assertSee('Welcome to your dashboard!');
// Good
$response->assertViewIs('dashboard.index');
$response->assertSuccessful();Making private method public to make it testable
Instead of changing the visibility, test the public method that uses the private method and then, the state or side effects of the public method.
Often, you need to separate a code with side effects and code that contains only logic.
setUp() misuse
The best case for using the setUp method is testing stateless objects.
- Any configuration made inside
setUpcouples tests together, and has impact on all tests. - It's better to avoid a shared state between tests and configure the initial state accordingly to test method.
- Readability is worse compared to configuration made in the proper test method.
Coverage
Use class-level #[CoversClass[YourClass::class] attribute to specify what the TestCase class covers. You can use multiple #[CoversClass] attributes in a single test class.
In rare case, when test class doesn't cover any specific functionality, you can utilize \PHPUnit\Metadata\CoversNothing attribute.
Measure test coverage
We use an external Codecov service to measure and track the test coverage. It's configured by the codecov.yml file and integrated on CI/CD and test coverage is updated after each release and for every PR.
100% is not the goal
100% Coverage is not the goal or even is undesirable because if there is 100% coverage, tests probably will be very fragile, which means refactoring will be very hard. Mutation testing gives better feedback about the quality of tests.
Do not test
You do not need to cover all the code with tests. Focus on the most critical parts, while ignore something that is:
- Trivial (e.g. do not test a method that always returns the same hardcoded literal [string, number, etc.]).
- Already covered by PHP's type system.
Custom Assertions
TestResponse
When you call $this->get(), $this->post() and similar methods to produce HTTP requests, Laravel returns a TestResponse class instance. In our case it's setup to return a custom version of TestResponse class: it has some custom assertions:
assertViewContainsValidOutputassertOutputHasValidTypographyassertHasSeoInfoassertHasValidHeading
Performance Tips
- Use/extend
\Tests\ApplicationTestCaseselectively (it usesRefreshDatabasetrait). Sometimes it's enough to extendPHPUnit\Framework\TestCase(what is a way faster and doesn't create Laravel app). - Do not use DB, network, heavy 3rd party tools (like GD) when it's possible (use test doubles).
Further Reading
- Testing tips by Kamil Ruczyński
- PHPUnit 11 Documentation