Skip to content

Back-end Testing

This guide outlines IxDF's testing practices for Laravel, focusing on creating maintainable and efficient tests.

This document assumes you:

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

Naming

  • 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(): void

AAA 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 setUp couples 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:

  • assertViewContainsValidOutput
  • assertOutputHasValidTypography
  • assertHasSeoInfo
  • assertHasValidHeading

Performance Tips

  1. Use/extend \Tests\ApplicationTestCase selectively (it uses RefreshDatabase trait). Sometimes it's enough to extend PHPUnit\Framework\TestCase (what is a way faster and doesn't create Laravel app).
  2. Do not use DB, network, heavy 3rd party tools (like GD) when it's possible (use test doubles).

Further Reading