speicher210/functional-test-bundle
Symfony bundle for functional REST endpoint tests: sets up test databases, loads Doctrine fixtures, mocks even private DI services, and validates responses via JSON snapshots using php-matcher, with optional automatic expected-file updates.
Install as a dev dependency.
composer require --dev speicher210/functional-test-bundle
Register the bundle for dev and test only in config/bundles.php:
Speicher210\FunctionalTestBundle\Speicher210FunctionalTestBundle::class => ['dev' => true, 'test' => true],
Create a PHPUnit bootstrap that builds the kernel once. The bundle's bootstrap expects a $kernel variable in the global scope:
// tests/bootstrap.php
require dirname(__DIR__) . '/config/bootstrap.php'; // must assign $kernel
require_once dirname(__DIR__) . '/vendor/speicher210/functional-test-bundle/src/Test/bootstrap.php';
Point phpunit.xml at it (bootstrap="tests/bootstrap.php") and set APP_ENV=test / KERNEL_CLASS in <php>.
First use case: a smoke test on a REST endpoint. Extend RestControllerWebTestCase and let the first run generate the snapshot:
use Speicher210\FunctionalTestBundle\Test\RestControllerWebTestCase;
use Symfony\Component\HttpFoundation\Request;
final class UserEndpointTest extends RestControllerWebTestCase
{
public function testReturns404WhenUserIsMissing(): void
{
$this->assertRestRequestReturns404('/api/user/1', Request::METHOD_GET);
}
public function testReturnsUserData(): void
{
$this->assertRestGetPath('/api/user/1');
}
}
Where to look first in the source. src/Test/RestControllerWebTestCase.php (the assertion API you'll live in), src/Test/WebTestCase.php (kernel/container/fixture plumbing), and the snapshot assertion class under src/Assert/ (how files are named, stored, and compared). With 5 stars and 0 dependents, the source is the documentation — budget an hour to read those three files before writing tests.
Snapshot-driven body assertions, status/header assertions for the contract. Use the dedicated assertRest* helpers for status codes and let snapshots cover the JSON body. Don't snapshot the whole response for endpoints with volatile fields — that turns every test into a changelog.
Golden-file workflow. First run writes the snapshot; commit it; every subsequent run diffs against it. Treat snapshot diffs in code review exactly like fixture diffs: a changed snapshot is either a deliberate API change or a regression. Keep snapshots in a predictable directory next to the test (__snapshots__) so ownership is obvious.
CRUD chains. For write endpoints, drive the flow through the client and capture identifiers rather than hardcoding them:
$client = static::createClient();
$client->request(Request::METHOD_POST, '/api/users', [], [], [], json_encode(['email' => '[email protected]']));
$id = json_decode($client->getResponse()->getContent(), true)['id'];
$this->assertRestGetPath('/api/users/' . $id);
Fixtures + DB setup. Keep the base class responsible for booting the kernel and schema; load fixtures via DoctrineFixturesBundle in setUp() or a shared trait. Use a fresh database per run (SQLite in-memory or a truncated MySQL schema) and prefer deterministic fixture data — fixed IDs, frozen clock — because snapshots encode whatever the DB returns.
Mocking services, including private ones. This is the bundle's real differentiator: it lets you swap a service in the compiled container before the request is handled, which plain WebTestCase cannot do without a compiler pass. The pattern:
$mock = $this->createMock(BillingClient::class);
$mock->method('charge')->willReturn(new ChargeResult('ok'));
$this->mockServiceInContainer(BillingClient::class, $mock); // helper from the bundle's test case
$client->request(Request::METHOD_POST, '/api/orders', ...);
Verify the exact method name against src/Test/ before adopting — it's the one API worth pinning in a base class of your own so a future version bump is a one-line change.
One test case per scenario, snapshots named per case. Use data providers for variations and give each data set its own snapshot; a single test asserting five payloads produces unreadable diffs.
Integration tips. Wrap the bundle's base class in your own ApiTestCase that sets your default headers (auth token, Accept: application/json), your fixture loader, and your snapshot path. Every project ends up with this layer; write it on day one.
git status --porcelain is clean afterward.createdAt, ETags, and trace IDs change per run. Normalize them out (replace with "__ID__") or freeze time/IDs in fixtures. This is the single biggest source of churn in this style of testing.$client->disableReboot() when a mock must survive multiple requests — and remember it also leaks state between tests.$kernel scope trap. The bundle's bootstrap relies on your config/bootstrap.php putting the kernel in a global $kernel. If your project's bootstrap returns the kernel instead (common in newer skeletons), the bundle bootstrap won't see it. Fix by assigning explicitly.static::getContainer(). Raw Symfony test containers only expose public services. self::$container is deprecated/removed across versions — prefer static::getContainer() and let the bundle's helper handle private lookups. Expect friction if you're on Symfony 4.x/5.0-era code with an older bundle release.void-less setUp() signatures. Check compatibility with PHPUnit 10/11 before committing; if the base class blocks you, pin PHPUnit rather than forking the bundle."1" where MySQL returns 1 for integer-ish columns; JSON column ordering can differ. Run functional tests on the same engine as production if snapshots must be portable.$client->getResponse()->getContent() and the status/headers before the assertion fires; run with --filter on a single test; check var/log/test.log. Diff the generated file against the committed one directly — the failure message rarely shows the full picture.ApiTestCase you could re-point at spatie/phpunit-snapshot-assertions + plain WebTestCase if needed.How can I help you explore Laravel packages today?