Loïc Faugeron Technical Blog

PHPUnit Best Practices (Ultimate Guide) 31/07/2025

Edit: brought up to date on 2026-02-27.

Forge battle-tested code, under the hammer of PHPUnit.

Unit Tests

Here's a unit test for a Username value object:

<?php

declare(strict_types=1);

namespace App\Tests\Unit;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Small;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;

#[CoversClass(Username::class)]
#[Small]
final class UsernameTest extends TestCase
{
    private function username(string $value = 'Merlin'): Username
    {
        return Username::fromString($value);
    }

    #[TestDox('It can be converted from/to string')]
    public function test_it_can_be_converted_from_and_to_string(): void
    {
        $this->assertSame('Merlin', $this->username()->toString());
    }

    #[DataProvider('invalidUsernameProvider')]
    #[TestDox('It fails when raw username $scenario')]
    public function test_it_fails_when_raw_username_is_invalid(
        string $scenario,
        string $invalidUsername,
    ): void {
        $this->expectException(ValidationFailedException::class);
        $this->username($invalidUsername);
    }

    /**
     * @return \Iterator<array{
     *     scenario: string,
     *     invalidUsername: string,
     * }>
     */
    public static function invalidUsernameProvider(): \Iterator
    {
        yield ['scenario' => 'is empty', 'invalidUsername' => ''];
        yield ['scenario' => 'is too short (< 4 characters)', 'invalidUsername' => 'abc'];
        yield ['scenario' => 'is too long (> 15 characters)', 'invalidUsername' => 'abcdefghijklmnop'];
    }
}

Factory Methods

Prefer private factory methods over setUp() to create the System Under Test (SUT):

Warning: PHPUnit creates one instance of each test class per test method and per data provider row, and keeps them all in memory until the testsuite completes.

Attributes

Attributes (#[<Name>]) were introduced in PHP 8 and PHPUnit 10, they replace Annotations (PHPdoc @<Name>) which have been deprecated in PHPUnit 10 and removed in PHPUnit 12.

Their goal is to make PHP tooling more robust and IDE integration more reliable, use them!

Specify targeted class:

Categorize tests based on their scope, complexity and resource usage:

Data Providers

Use Data Providers to test different sets of inputs / outputs:

Testdox

Run PHPUnit with --testdox option to get executable specifications:

Use \Iterator with yield and named parameters for readable data providers, combined with #[TestDox] and a $scenario variable (as shown in the UsernameTest example above).

Output with --testdox:

Username
 ✔ It can be converted from/to string
 ✔ It fails when raw username is empty
 ✔ It fails when raw username is too short (< 4 characters)
 ✔ It fails when raw username is too long (> 15 characters)

Coding Standards

Follow Coding Standards to ensure consistency across the PHP ecosystem, and internal projects:

Here are examples of topics you can debate:

Principles

FIRST properties of Unit Tests, they should be:

Follow AAA, each test method should group these functional sections, separated by blank lines:

  1. Arrange: all necessary preconditions and input
  2. Act: on the System Under Test (SUT)
  3. Assert: that the expected results have occurred

Not necessarily in that order (eg when testing exceptions: Arrange, Expect, Act).

DRY vs DAMP (aka WET), it's all about finding the right balance: pick whichever is more readable, on a case-by-case basis.

"DRY (Don't Repeat Yourself) increases maintainability by isolating change (risk) to only those parts of the system that must change.

DAMP (Descriptive And Meaningful Phrases, aka WET: We Edit Twice) increases maintainability by reducing the time necessary to read and understand the code."

— Chris Edwards

Mocking

Note: this is "In My Humble Opinion".

There are two Test Driven Development (TDD) schools of thought:

The mocking library prophecy's expressive syntax allows for an approach that's more aligned with spec BDD. It can be used in PHPUnit with the phpspec/prophecy-phpunit package.

When the SUT creates values internally, use Argument matchers:

<?php

declare(strict_types=1);

namespace App\Tests\Unit;

use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Small;
use PHPUnit\Framework\TestCase;

#[CoversClass(SignInPlayerHandler::class)]
#[Small]
final class SignInPlayerHandlerTest extends TestCase
{
    use ProphecyTrait;

    public function test_it_signs_in_player(): void
    {
        $username = UsernameFixture::makeString();
        $player = PlayerFixture::make();

        // Stub: configure return value
        $findPlayer = $this->prophesize(FindPlayer::class);
        $findPlayer->find(
            Argument::that(static fn (Username $u): bool => $u->toString() === $username),
        )->willReturn($player);

        // Mock: assert it gets called
        $saveAuthToken = $this->prophesize(SaveAuthToken::class);
        $saveAuthToken->save(Argument::type(AuthToken::class))
            ->shouldBeCalled();

        $signInPlayerHandler = new SignInPlayerHandler(
            $findPlayer->reveal(),
            $saveAuthToken->reveal(),
        );
        $signedInPlayer = $signInPlayerHandler->run(new SignInPlayer(
            $username,
        ));

        $this->assertInstanceOf(SignedInPlayer::class, $signedInPlayer);
    }
}

Integration Tests

🤫 Super Secret Tip:

PHPUnit instantiates the test class once per test method and once per data provider row. This is a fundamental design decision that prioritizes test isolation over performance.

So if you have:

  • 5 regular test methods: that's 5 instances
  • 1 test method with 10 data provider rows: that's 10 instances
  • Total: 15 instances created

Why This Matters:

  • Performance : expensive setUp() and constructors will have a measurable impact
  • Memory Usage: Each instance holds its own state in memory until the end of the testsuite run
  • Test Isolation: Ensures no state leakage between tests (the main benefit)

Since each test method creates a new instance, expensive operations compound quickly. Watch out for:

  • repeated kernel booting
  • database connections
  • fixture loading (especially when Doctrine ORM Entity hydration is involved)
  • external API calls

You can use singletons for stateless services, transactions for database cleanup, and mocks for external dependencies. The example below uses AppSingleton::get() to share a stateless application instance across the entire testsuite.

Smoke Tests

Note: this is the pragmatic approach.

For controllers and commands, no need to mock internal dependencies or asserting on complex business logic.

Just craft the input, pass it to application, and verify the status code.

This tests the entire request-response cycle: routing, middleware, validation, business logic, serialization... Everything.

Here's an integration test for a POST /api/v1/actions/sign-up-new-player endpoint controller:

<?php

declare(strict_types=1);

namespace App\Tests\Integration\Controller;

use PHPUnit\Framework\Attributes\CoversNothing;
use PHPUnit\Framework\Attributes\Medium;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

#[CoversNothing]
#[Medium]
final class SignUpNewPlayerControllerTest extends TestCase
{
    public function test_it_signs_up_a_new_player(): void
    {
        $appKernel = TestKernelSingleton::get()->appKernel();

        $request = Request::create(
            uri: '/api/v1/actions/sign-up-new-player',
            method: 'POST',
            server: ['CONTENT_TYPE' => 'application/json'],
            content: json_encode([
                'username' => UsernameFixture::makeString(),
                'password' => PasswordPlainFixture::makeString(),
            ], \JSON_THROW_ON_ERROR),
        );

        $response = $appKernel->handle($request);

        $this->assertSame(Response::HTTP_CREATED, $response->getStatusCode(), (string) $response->getContent());
    }
}

And here's an integration test for a ./bin/console action:sign-up-new-player CLI command:

<?php

declare(strict_types=1);

namespace App\Tests\Integration\Cli;

use PHPUnit\Framework\Attributes\CoversNothing;
use PHPUnit\Framework\Attributes\Medium;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Command\Command;

#[CoversNothing]
#[Medium]
final class SignUpNewPlayerCommandTest extends TestCase
{
    public function test_it_signs_up_a_new_player(): void
    {
        $application = TestKernelSingleton::get()->application();

        $application->run([
            'command' => 'action:sign-up-new-player',
            'username' => UsernameFixture::makeString(),
            'password' => PasswordPlainFixture::makeString(),
        ]);

        $this->assertSame(Command::SUCCESS, $application->getStatusCode());
    }
}

Useful CLI options

phpunit

  # Configuration:
  --generate-configuration             Generate configuration file with suggested settings
  --migrate-configuration              Migrate configuration file to current format

  # Selection:
  --list-groups                        List available test groups
  --group small                        Only run tests from the specified group(s)
  --exclude-group small                Exclude tests from the specified group(s)

  --list-tests                         List available tests
  --covers 'Username'                  Only run tests that intend to cover <name>
  --filter 'UsernameTest'              Filter which tests to run (test class, or test method)
  --filter 'test_it_can_be_converted_from_and_to_string'

  ## Useful for running testsuites individually, in the CI
  --list-testsuites                    List available testsuites
  --testsuite unit                     Only run tests from the specified testsuite(s)
  --exclude-testsuite unit             Exclude tests from the specified testsuite(s)

  # Execution
  --stop-on-failure                    Stop after first failure
  --order-by <order>                   Run tests in order: default|defects|depends|duration|no-depends|random|reverse|size

  # Reporting
  --no-progress                        Disable output of test execution progress (the dots)
  --testdox                            Replace default result output with TestDox format

Order By options:

  • default: tests run in the order they're discovered (filesystem order, typically alphabetical)
  • defects: previously failed/errored tests run first (requires --cache-result to remember past failures)
  • depends: tests with dependencies run after their dependencies, non-dependent tests run first
  • duration: fastest tests run first, slowest tests run last (requires --cache-result to remember execution times)
  • no-depends: ignores test dependencies and runs tests in discovery order
  • random: tests run in random order (use --random-order-seed <N> for reproducible randomness)
  • reverse: tests run in reverse discovery order
  • size: tests run by size: #[Small], then #[Medium], after #[Large], and finally unsized tests

Worth noting:

  • Combining options: --order-by=depends,defects

Configuration

<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="tests/bootstrap.php"

         cacheDirectory=".phpunit.cache"
         executionOrder="depends,defects"
         requireCoverageMetadata="true"
         beStrictAboutCoverageMetadata="true"
         beStrictAboutOutputDuringTests="true"
         displayDetailsOnPhpunitDeprecations="true"
         failOnPhpunitDeprecation="true"
         failOnRisky="true"
         failOnWarning="true"

         shortenArraysForExportThreshold="10"
         colors="true"
>
    <php>
        <!-- Useful for CI environments -->
        <ini name="display_errors" value="1" />
        <ini name="error_reporting" value="-1" />

        <!-- Useful for Symfony -->
        <env name="KERNEL_CLASS" value="App\Kernel" />
        <env name="APP_ENV" value="test" force="true" />
        <env name="APP_DEBUG" value="0" force="true" />
        <env name="SHELL_VERBOSITY" value="-1" />
    </php>

    <testsuites>
        <testsuite name="unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="integration">
            <directory>tests/Integration</directory>
        </testsuite>
    </testsuites>

    <source
        ignoreIndirectDeprecations="true"
        restrictNotices="true"
        restrictWarnings="true"
    >
        <include>
            <directory>src</directory>
        </include>
    </source>
</phpunit>

Notes:

  • bootstrap defaults to vendor/autoload.php
  • shortenArraysForExportThreshold defaults to 0 from v11.3 and 10 from v12
  • colors defaults to false, for automated/scripted environment compatibility

Resources