Loïc Faugeron Technical Blog

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

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

Unit Tests

Inspired by Sebastian Bergmann, _Sources:

Here's a unit test for a CheckArray->check(string $field, mixed $value): array class:

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Domain\Check;

use App\Domain\Check\CheckArray;
use App\Domain\Exception\ValidationFailedException;
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(CheckArray::class)]
#[Small]
class CheckArrayTest extends TestCase
{
    private const string FIELD = 'path.field_name';

    private function checkArray(): CheckArray
    {
        return new CheckArray();
    }

    public function test_it_accepts_valid_array(): void
    {
        $validArray = [23, 42, 1337];

        $this->assertSame($validArray, $this->checkArray()->check(
            self::FIELD,
            $validArray,
        ));
    }

    public function test_it_accepts_null(): void
    {
        // When checking that no exception is thrown
        $this->expectNotToPerformAssertions();

        $this->checkArray()->check(
            self::FIELD,
            null,
        );
    }

    /**
     * @return array<string, array{mixed}>
     */
    public static function nonArrayTypesProvider(): array
    {
        return [
            'string' => ['hello world'],
            'integer' => [42],
            'boolean' => [false],
        ];
    }

    #[DataProvider('nonArrayTypesProvider')]
    public function test_it_rejects_non_array_types(mixed $notAnArray): void
    {
        $this->expectException(ValidationFailedException::class);

        $this->checkArray()->check(
            self::FIELD,
            $notAnArray,
        );
    }
}

Factory Methods

When the System Under Test (SUT) has a simple constructor, or its instantiation is clear and consistent, then instantiate it directly in each test method.

But when the SUT's constructor has multiple parameters, or a couple of test method require the same specific configuration, consider moving that creation logic to a private factory method:

Finally, when your SUT legitimately needs to be tested under different configurations, create explicit factory methods that document these variations.

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:

Note 1

Running phpunit --testdox with the following data provider:

    public static function nonArrayTypesProvider(): array
    {
        return [
            ['hello world'],
            [42],
            [false],
        ];
    }

    #[DataProvider('nonArrayTypesProvider')]
    public function test_it_rejects_non_array_types(mixed $notAnArray): void

Will output:

Check Array (App\Tests\Unit\Domain\Check\CheckArray)
 ✔ It rejects non array types with data set 0
 ✔ It rejects non array types with data set 1
 ✔ It rejects non array types with data set 2

Note 2

This is equivalent to:

    #[TestWith(['hello world'])]
    #[TestWith([42])]
    #[TestWith([false])]
    public function test_it_rejects_non_array_types(mixed $notAnArray): void

Note 3

However, we can use string keys to describe each set:

    public static function nonArrayTypesProvider(): array
    {
        return [
            'string' => ['hello world'],
            'integer' => [42],
            'boolean' => [false],
        ];
    }

    #[DataProvider('nonArrayTypesProvider')]
    public function test_it_rejects_non_array_types(mixed $notAnArray): void

Which will output:

Check Array (App\Tests\Unit\Domain\Check\CheckArray)
 ✔ It rejects non array types with string
 ✔ It rejects non array types with integer
 ✔ It rejects non array types with boolean

Note 4

Finally, we can also change the text with #[TestDox]:

    public static function nonArrayTypesProvider(): array
    {
        return [
            'string' => ['hello world'],
            'integer' => [42],
            'boolean' => [false],
        ];
    }

    #[DataProvider('nonArrayTypesProvider')]
    #[TestDox('it rejects `$notAnArray` because it does not have type `array`')]
    public function test_it_rejects_non_array_types(mixed $notAnArray): void

Which will output:

Check Array (App\Tests\Unit\Domain\Check\CheckArray)
 ✔ it rejects `hello·world` because it does not have type `array`
 ✔ it rejects `42` because it does not have type `array`
 ✔ it rejects `false` because it does not have type `array`

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:

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Domain\Event\Check;

use App\Domain\Check\CheckDateTimeIso8601;
use App\Domain\Event\Check\CheckStart;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Small;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;

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

    private const string FIELD = 'start';
    private const string START = '2025-06-17T13:00:00';

    public function test_it_checks_start(): void
    {
        $checkDateTimeIso8601 = $this->prophesize(CheckDateTimeIso8601::class);

        $checkDateTimeIso8601->check(
            self::FIELD,
            self::START,
        )->shouldBeCalled()->willReturn(self::START);

        $checkStart = new CheckStart(
            $checkDateTimeIso8601->reveal(),
        );
        $this->assertSame(self::START, $checkStart->check(
            self::FIELD,
            self::START,
        ));
    }
}

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 /v1/events endpoint controller:

<?php

declare(strict_types=1);

namespace App\Tests\Integration\Controller\Event;

use App\Controller\Event\CreateController;
use App\Tests\AppSingleton;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Medium;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;

#[CoversClass(CreateController::class)]
#[Medium]
final class CreateControllerTest extends TestCase
{
    public function test_it_creates_new_one(): void
    {
        $appKernel = AppSingleton::get()->appKernel();

        $headers = [
            'CONTENT_TYPE' => 'application/json',
        ];
        $request = Request::create('/v1/events', 'POST', [], [], [], $headers, (string) json_encode([
            'title' => 'Daily stand up',
            'start' => (new \DateTimeImmutable('now'))->format('Y-m-d\TH:i:s'),
            'end' => (new \DateTimeImmutable('now + 1 second'))->format('Y-m-d\TH:i:s'),
        ]));

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

        $this->assertSame(201, $response->getStatusCode(), (string) $response->getContent());
    }

    public function test_it_cannot_create_new_one_without_required_fields(): void
    {
        $appKernel = AppSingleton::get()->appKernel();

        $headers = [
            'CONTENT_TYPE' => 'application/json',
        ];
        $request = Request::create('/v1/events', 'POST', [], [], [], $headers, (string) json_encode([
            'start' => '2025-07-09T09:00:00+00:00',
            'end' => '2025-07-09T09:15:00+00:00',
        ]));

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

        $this->assertSame(422, $response->getStatusCode(), (string) $response->getContent());
    }
}

And here's an integration test for a ./bin/console events:list CLI command:

<?php

declare(strict_types=1);

namespace App\Tests\Integration\Command\Event;

use App\Command\Event\ListCommand;
use App\Tests\AppSingleton;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Medium;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Command\Command;

#[CoversClass(ListCommand::class)]
#[Medium]
final class ListCommandTest extends TestCase
{
    public function test_it_lists_existing_ones(): void
    {
        $applicationTester = AppSingleton::get()->applicationTester();

        $input = [
            ListCommand::NAME,
        ];

        $statusCode = $applicationTester->run($input);

        $this->assertSame(Command::SUCCESS, $statusCode, $applicationTester->getDisplay());
    }
}

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 'CheckArray'                Only run tests that intend to cover <name>
  --filter 'CheckArrayTest'            Filter which tests to run (test class, or test method)
  --filter 'test_it_accepts_valid_array'

  ## 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