speicher210 / functional-test-bundle
Symfony bundle for functional testing
Package info
github.com/protung/functional-test-bundle
Type:symfony-bundle
pkg:composer/speicher210/functional-test-bundle
Requires
- php: ~8.4.0 || ~8.5.0
- ext-dom: *
- ext-json: *
- coduo/php-matcher: ^6.0.18
- dama/doctrine-test-bundle: ^8.4
- doctrine/data-fixtures: ^2.1.0
- doctrine/dbal: ^3.10.0 || ^4.2.0
- doctrine/doctrine-fixtures-bundle: ^3.7.3 || ^4.3.1
- doctrine/orm: ^2.20.9 || ^3.5.0
- php-standard-library/php-standard-library: ^3.3.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
- phpunit/phpunit: ^12.5.8 || ^13.0
- symfony/browser-kit: ^6.4 || ^7.4 || ^8.0
- symfony/css-selector: ^6.4 || ^7.4 || ^8.0
- symfony/dependency-injection: ^6.4 || ^7.4 || ^8.0
- symfony/framework-bundle: ^6.4 || ^7.4 || ^8.0
Requires (Dev)
- ext-imagick: *
- ext-intl: *
- doctrine/coding-standard: ^14.0.0
- ergebnis/composer-normalize: ^2.54.0
- mikey179/vfsstream: ^1.6.12
- moneyphp/money: ^4.9.0
- nesbot/carbon: ^3.11.4
- php-standard-library/phpstan-extension: ^2.1.0
- phpstan/phpstan: ^2.2.16
- phpstan/phpstan-phpunit: ^2.0.19
- phpstan/phpstan-strict-rules: ^2.0.12
- phpstan/phpstan-symfony: ^2.0.20
- spatie/pdf-to-image: ^3.2.0
- spatie/pdf-to-text: ^1.54.0
- symfony/clock: ^6.4 || ^7.4 || ^8.0
- symfony/console: ^6.4 || ^7.4 || ^8.0
- symfony/form: ^6.4 || ^7.4 || ^8.0
- symfony/security-core: ^6.4 || ^7.4 || ^8.0
- symfony/validator: ^6.4 || ^7.4 || ^8.0
- twig/twig: ^3.23.0
Suggests
- ext-imagick: To assert PDF files, images or create fixture images
- mikey179/vfsstream: To mock uploading large files
- spatie/pdf-to-image: To test PDF files
- spatie/pdf-to-text: To test PDF files
- symfony/console: To use command line tool to generate tests stubs
Provides
None
Conflicts
- spatie/pdf-to-image: <3.0.0
- twig/twig: <3.0.0
Replaces
None
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
SnapshotUpdaterExtensionwrites 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. ThematcherPatternsparameter replaces the list of patterns to keep, as a JSON list (default:Json::DEFAULT_MATCHER_PATTERNS). - Fixed values from the
fieldsparameter, 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, orapplication/problem+jsonfor errors. Both can be changed by overriding a method. loginAs()andloginAsAdmin()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:
KernelTestCasegives 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/datadirectory next to the test class, named like expected files. - Uploaded files:
Test\DummyFileis an enum of bundled sample files (images, audio, video, PDF, text, XLSX), andWebTestCasecreates uploaded files from them. Large files needmikey179/vfsstreamandVfsStreamSetup::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.jsonand-2.json, with{}as content.Fixtures/data/testCreatesUser-1.php, a payload file.Fixtures/Loaders/TestCreatesUser.php, a fixture loader extending the configuredfixture_loader_extend_class. Skip it with--no-loader.
It then prints the #[WithFixture] attribute to add to the test.