Search by

speicher210 / functional-test-bundle

wingudragosprotung

Symfony bundle for functional testing

Package info

github.com/protung/functional-test-bundle

Type:symfony-bundle

pkg:composer/speicher210/functional-test-bundle

Statistics

Installs: 68 632

Dependents: 2

Suggesters: 2

Stars: 5

Open Issues: 3

2.0.0 2026-09-28 18:39 UTC

README

A Symfony bundle with base test cases and helpers for functional testing, focused on REST endpoints.

  • Snapshot assertions against expected files, with coduo/php-matcher patterns.
  • A PHPUnit extension that updates the expected files from the actual output.
  • Doctrine fixtures loaded per test, declared with attributes.
  • Access to and mocking of container services, including private ones.
  • Base test cases for console commands, forms, validators, Doctrine types, DQL functions and Twig templates.

Requirements

  • PHP 8.4 or 8.5
  • Symfony 6.4, 7.4 or 8.x
  • PHPUnit 12.5.8+ or 13
  • Doctrine ORM 2.20+ or 3, DBAL 3 or 4

Installation

composer require --dev speicher210/functional-test-bundle

Enable the bundle in config/bundles.php:

return [
    // ...
    Speicher210\FunctionalTestBundle\Speicher210FunctionalTestBundle::class => ['dev' => true, 'test' => true],
];

The only configuration option is the base class of the fixture loaders generated by the stub command:

# config/packages/speicher210_functional_test.yaml
speicher210_functional_test:
    fixture_loader_extend_class: App\Tests\Fixtures\Loader\AbstractLoader # default: Speicher210\FunctionalTestBundle\Test\Loader\AbstractLoader

PHPUnit setup

The bundle's bootstrap file boots the kernel once and recreates the database schema for the default entity manager. Include it from your own bootstrap file:

<?php
// tests/bootstrap.php

declare(strict_types=1);

use Symfony\Component\Dotenv\Dotenv;

require dirname(__DIR__) . '/vendor/autoload.php';

(new Dotenv())->bootEnv(dirname(__DIR__) . '/.env');

require dirname(__DIR__) . '/vendor/speicher210/functional-test-bundle/src/Test/bootstrap.php';

// Only needed when using WebTestCase::getRequestUploadLargeFile() (requires mikey179/vfsstream).
Speicher210\FunctionalTestBundle\VfsStreamSetup::initialize();

Then register the PHPUnit extensions:

<!-- phpunit.xml.dist -->
<phpunit bootstrap="tests/bootstrap.php">
    <php>
        <server name="KERNEL_CLASS" value="App\Kernel"/>
    </php>
    <extensions>
        <!-- Runs every test in a database transaction that is rolled back afterwards. -->
        <bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>

        <!-- Uncomment to update the expected files of failing snapshot assertions. -->
        <!--
        <bootstrap class="Speicher210\FunctionalTestBundle\Extension\SnapshotUpdaterExtension">
            <parameter name="fields" value='{"createdAt": "@string@.isDateTime()"}'/>
        </bootstrap>
        -->
    </extensions>
</phpunit>
  • Database: the schema is created only once per run, so each test has to clean up its data. dama/doctrine-test-bundle does this by running every test in a transaction.
  • Snapshots: the SnapshotUpdaterExtension writes the actual output into the expected files. It's only meant to be enabled while you update them, see Updating expected files.

Expected files (snapshots)

Most assertions of the bundle compare the actual output with an expected file in an Expected directory next to the test class. The file name is made of the test method name, the data set name when a data provider is used, and a counter that increases with every assertion in the same test:

tests/Controller/UserControllerTest.php
tests/Controller/Expected/testUpdatesUser-1.json
tests/Controller/Expected/testUpdatesUser-2.json
tests/Controller/Expected/testWithDataProvider-some data set-1.json

Expected files can use any coduo/php-matcher pattern for values that aren't known in advance:

{
  "id": 1,
  "firstName": "Jane",
  "email": "@string@",
  "createdAt": "@string@.isDateTime()"
}

REST responses and arrays are compared as JSON, console output and form errors as text, Twig templates as HTML, and PDFs by text and page images. REST responses and console output without an expected file must be empty, so tests of commands that print nothing don't need one.

Updating expected files

Expected files don't have to be written by hand. While the SnapshotUpdaterExtension is registered, every failing snapshot assertion rewrites its expected file with the actual output. The test still fails that one time; run it again to see it pass, and review the changes in the diff before committing them.

Enable it only when updating snapshots: uncomment it in phpunit.xml.dist (see PHPUnit setup), or register it for a single run:

vendor/bin/phpunit --extension "Speicher210\FunctionalTestBundle\Extension\SnapshotUpdaterExtension"

When updating JSON files, the extension keeps what makes snapshots stable:

  • Matcher patterns in the expected file, such as @string@, @integer@ or @uuid@, are kept as long as they still match the actual value. The matcherPatterns parameter replaces the list of patterns to keep, as a JSON list (default: Json::DEFAULT_MATCHER_PATTERNS).
  • Fixed values from the fields parameter, a JSON object, are always written for those keys, at any depth. Use it for values that change on every run, like timestamps.

To add a new REST snapshot, create the expected file with {} as content (the stub command can do this) and run the test with the extension.

The extension was previously called RestRequestFailTestExpectedOutputFileUpdater. The old name still works, but it is deprecated.

Updating expected files from your own assertions

Your own snapshot assertions can use the same mechanism: catch the ExpectationFailedException, update the file when the updater is enabled, and rethrow the exception.

use PHPUnit\Framework\ExpectationFailedException;
use Speicher210\FunctionalTestBundle\SnapshotUpdater;
use Speicher210\FunctionalTestBundle\SnapshotUpdater\DriverConfigurator;

protected function assertCsvExportMatchesExpected(string $actualCsv): void
{
    // Next expected file of the test: Expected/<test>-<n>.csv
    $expectedFile = $this->getExpectedContentFile('csv');

    try {
        self::assertStringEqualsFile($expectedFile, $actualCsv);
    } catch (ExpectationFailedException $e) {
        $comparisonFailure = $e->getComparisonFailure();
        if ($comparisonFailure !== null && DriverConfigurator::isOutputUpdaterEnabled()) {
            SnapshotUpdater::updateText($comparisonFailure, $expectedFile);
        }

        throw $e;
    }
}

SnapshotUpdater has a method for each kind of content the assertion compares: JSON, text, XML or binary. The expected file has to exist, even empty, because an assertion on a missing file fails without a comparison failure.

Test cases

Class Use it for
Test\KernelTestCase Anything that needs the kernel: services, Doctrine, fixtures, expected files. All other test cases below build on it.
Test\WebTestCase Requests through the KernelBrowser, response assertions, upload helpers.
Test\RestControllerWebTestCase REST endpoints with JSON snapshots and authentication.
Test\Command\CommandTestCase Console commands, with the output compared to an expected file.
Test\Symfony\Form\FormTypeTestCase Form types: submitted data, or errors compared to an expected file.
Test\Symfony\Validator\ValidatorTestCase Constraint validators, with common tests built in.
Test\Doctrine\DBAL\Types\TypeTestCase Custom Doctrine DBAL types, driven by data providers.
Test\Doctrine\ORM\Query\AST\FunctionTestCase Custom DQL functions, with an in-memory SQLite entity manager.
Test\Twig\TemplateTestCase Twig templates, with the rendered HTML compared to an expected file.
Test\Twig\IntegrationTestCase Twig's own integration tests, without the legacy tests.

All classes are in the Speicher210\FunctionalTestBundle namespace.

Testing REST endpoints

<?php

declare(strict_types=1);

namespace App\Tests\Controller;

use App\Tests\Fixtures\Loader\LoadOneUser;
use Speicher210\FunctionalTestBundle\Attribute\WithFixture;
use Speicher210\FunctionalTestBundle\Test\RestControllerWebTestCase;
use Symfony\Component\HttpFoundation\Request;

#[WithFixture(LoadOneUser::class)]
final class UserControllerTest extends RestControllerWebTestCase
{
    public function testReturns404IfUserIsNotFound(): void
    {
        $this->assertRestRequestReturns404('/api/users/999', Request::METHOD_GET);
    }

    public function testReturnsUser(): void
    {
        $this->assertRestGetPath('/api/users/1');
    }

    public function testUpdatesUser(): void
    {
        self::loginAsAdmin();

        $this->assertRestPatchPath('/api/users/1', ['firstName' => 'Jane']);
        $this->assertRestGetPath('/api/users/1');
    }
}
  • Each REST assertion sends a JSON request, checks the status code, and compares the response body with the next expected file. When there is no expected file, or for 204 No Content, the body must be empty.
  • Responses compared with an expected file must be application/json, or application/problem+json for errors. Both can be changed by overriding a method.
  • loginAs() and loginAsAdmin() authenticate the requests that follow in the test.
  • The 401, 403 and 404 assertions compare with bundled problem responses, which can be replaced as well.

Fixtures

Fixtures are Doctrine fixtures. Extend Test\Loader\AbstractLoader and yield the entities to persist:

<?php

declare(strict_types=1);

namespace App\Tests\Fixtures\Loader;

use App\Entity\User;
use Generator;
use Override;
use Speicher210\FunctionalTestBundle\Test\Loader\AbstractLoader;

final class LoadOneUser extends AbstractLoader
{
    #[Override]
    protected function doLoad(): Generator
    {
        yield new User(1, 'John', 'Doe');
    }
}

Declare the loaders to run before each test with attributes:

  • #[WithFixture(Loader::class)] on the test class loads the fixture for every test of the class. It is repeatable.
  • #[WithFixture(Loader::class)] on a test method loads it for that test only.
  • #[WithFixture(Loader::class)] on a trait loads it for every test of the classes that use the trait, including through parent classes.
  • #[WithFixtureForTest(Loader::class, 'testName')] on the test class loads it only for the given test. This is meant for tests inherited from a parent class.

Loaders that need services can implement Test\Loader\LoaderAsService. They are then fetched from the container instead of being instantiated. Register them as public services in the test environment, otherwise Symfony removes them from the container as unused.

Other helpers

  • Container services: KernelTestCase gives access to services, including private ones, and can replace them with mocks, also before the fixtures are loaded.
  • Doctrine: shortcuts for the entity manager, for reading entities, and for resetting database sequences.
  • Request payloads: large payloads can be kept in a Fixtures/data directory next to the test class, named like expected files.
  • Uploaded files: Test\DummyFile is an enum of bundled sample files (images, audio, video, PDF, text, XLSX), and WebTestCase creates uploaded files from them. Large files need mikey179/vfsstream and VfsStreamSetup::initialize() in the bootstrap file.
Trait or class Description
Test\Carbon\CarbonClockSensitiveTestCase Freezes Carbon's clock during each test.
Test\Symfony\Clock\SymfonyClockSensitiveTestCase Freezes the Symfony clock during each test.
Test\Assert\Pdf Compares a PDF's text or page images with expected files. Needs spatie/pdf-to-text, spatie/pdf-to-image and ext-imagick.
Test\Assert\Image Compares two images. Needs ext-imagick. Included in KernelTestCase.
Test\MockObject\AbstractClass Mocks the abstract methods of a class and keeps its concrete methods.
Test\PHPUnitHelper withConsecutive(), a replacement for PHPUnit's removed method. Included in KernelTestCase.
Comparator\MoneyPHP PHPUnit comparator for moneyphp/money. Register it in the bootstrap file.

"No expectations were configured" notices

PHPUnit 12.5 and later report a notice for mock objects without expectations. TypeTestCase and FormTypeTestCase create such mocks for every test. PHPUnit only reads the opt-out attribute from the test class itself or the test method, not from parent classes, so add it to your test classes. The built-in TypeTestCase tests already have it.

use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;

#[AllowMockObjectsWithoutExpectations]
final class MyFormTypeTest extends FormTypeTestCase
{
}

Creating test stubs

The bundle provides a command that creates the files of a new test:

bin/console sp210:test:stub:create tests/Controller testCreatesUser --expected=2 --payloads=1

The directory must already contain a *Test.php class, which is used to find the namespace. The command creates:

  • Expected/testCreatesUser-1.json and -2.json, with {} as content.
  • Fixtures/data/testCreatesUser-1.php, a payload file.
  • Fixtures/Loaders/TestCreatesUser.php, a fixture loader extending the configured fixture_loader_extend_class. Skip it with --no-loader.

It then prints the #[WithFixture] attribute to add to the test.