lchrusciel/api-test-case
ApiTestCase is a PHPUnit TestCase for Symfony API development, extending WebTestCase with JSON/XML response matching via PHP-Matcher and optional Alice/Doctrine fixture loading. It supports a clear TDD workflow, readable assertion errors, and simple expected-response files.
Reality check first: despite the "JSON API testing" framing, this is a Symfony package — JsonApiTestCase extends Symfony's WebTestCase and boots a Symfony Kernel. It will not drop into a Laravel app. Treat this assessment as: "what can I learn/steal from it, and what would it look like if I were on Symfony." If you're staying on Laravel, the equivalents to reach for are assertJson / assertJsonStructure / assertJsonFragment from Illuminate\Testing\TestResponse, plus spatie/phpunit-snapshot-assertions if you want the golden-file workflow described below.
For a Symfony codebase:
composer require --dev lchrusciel/api-test-case
Zero configuration is required to get the first test running. Pick a base class (JsonApiTestCase or XmlApiTestCase) and write:
use ApiTestCase\JsonApiTestCase;
class HelloWorldTest extends JsonApiTestCase
{
public function testGetHelloWorldResponse(): void
{
$this->client->request('GET', '/');
$this->assertResponse($this->client->getResponse(), 'hello_world');
}
}
Then create the expected body at tests/Responses/Expected/hello_world.json. Run vendor/bin/phpunit, watch it fail, build the controller, watch it pass.
Where to look first, in order:
coduo/php-matcher) — assertResponse is essentially a thin wrapper around its pattern language, and that's where 90% of your day-to-day power and pain live.test/ directory in the repo — a minimal working Symfony app you can composer install and run against to see real expected-response files and fixtures.phpunit.xml server vars — the config surface is small and entirely environment-driven.Your first real use case: a GET /products endpoint where id and created_at are non-deterministic. Write the expected file once with matchers ("id": "@integer@", "created_at": "@[email protected]()"), and you have a stable contract test for the whole payload shape.
Golden-file / snapshot assertions as the primary workflow. The core pattern is: one expected-response file per assertion, committed to the repo, compared structurally. This is snapshot testing with a human-authored expectation rather than a machine-generated one — which is the point. You get a readable diff instead of a regenerated blob, and reviewers can see the API contract change in the PR. Name files after behaviour (product_list.json), not after the test method, so they survive renames.
Matchers for everything non-deterministic. The pattern language is the actual product:
[
{
"id": "@integer@",
"uuid": "@uuid@",
"name": "Star-Wars T-shirt",
"sizes": "@array@",
"created_at": "@[email protected]()"
}
]
Rule of thumb: pin exact values for anything your application decides, use matchers only for anything the environment decides (ids, timestamps, uuids, generated tokens). Overusing @string@ makes tests that pass against a broken serializer.
Fixture loading (Doctrine + Alice). Enable NelmioAliceBundle and FidryAliceDataFixturesBundle in config/bundles.php under the test env only, put YAML fixtures alongside your tests, then:
$this->loadFixturesFromFile('books.yml');
$this->loadFixturesFromDirectory('big_library');
This is the pattern worth stealing even outside Symfony: per-test fixture files scoped to the scenario, loaded fresh, with the database purged between tests. It beats a monolithic seeder because each test's data dependencies are visible in the test itself.
Compose a project-level base class. Don't extend JsonApiTestCase in every test. Extend it once (AppApiTestCase) and add auth helpers, default headers, JSON POST helpers, and a assertJsonCreated() convenience wrapper that passes the expected status code. All 40 downstream tests then change together when your auth scheme changes.
Configuration is per-suite, via phpunit.xml:
<php>
<server name="KERNEL_CLASS" value="Acme\Kernel" />
<server name="EXPECTED_RESPONSE_DIR" value="/path/to/expected/responses/" />
<server name="FIXTURES_DIR" value="/path/to/DataFixtures/ORM/" />
<server name="IS_DOCTRINE_ORM_SUPPORTED" value="true" />
<server name="TMP_DIR" value="/tmp/api-test-case/" />
</php>
Set EXPECTED_RESPONSE_DIR and FIXTURES_DIR explicitly. Relying on the implicit ../Responses and ../DataFixtures/ORM/ guessing works until someone reorganises the test tree, at which point you get confusing "file not found" failures with no hint about which path was probed.
TDD loop that actually works: write test → write expected JSON → red (diff shown) → implement → green → tighten matchers. The diff output is the killer feature; it shows you the exact structural mismatch rather than dumping two blobs.
assertResponse checks three things, not one. Status code (default 200), then Content-Type: application/json, then body. A 201 Created test that fails with a completely unrelated message is usually the status check — always pass the expected code for non-200 responses. The content-type check also bites when you return a JSON body with text/plain, or when you have application/problem+json on error paths.
The README's paths are Symfony 2/3-era. src/AppBundle/Tests/Responses/Expected/... and test/app/console will not exist in a Symfony 6/7 app. Map them to tests/Controller/ and bin/console. Also note expected files live in a Responses/Expected/ subfolder relative to EXPECTED_RESPONSE_DIR — the filename argument is hello_world, not hello_world.json.
Version matrix is narrow and moving. Recent releases support Symfony 6.4/7.0 and PHPUnit 9–11, having dropped 5.4 and older PHPUnits. Several 5.3.x patches are pure compatibility bumps (Support for PHPUnit 11.0, Doctrine ORM 3.0). Pin it and check the release notes before bumping PHPUnit — a PHPUnit upgrade will silently break the base class before it breaks your tests.
ESCAPE_JSON is a legacy trap. Default false. Turning it on escapes unicode and slashes before comparison, which "fixes" diffs caused by an old expectation file but masks real output differences. The README explicitly says it exists only for backwards compatibility and will be removed — do not build new expectations around it.
Fixture path failures throw RuntimeException. loadFixturesFromFile/FromDirectory will throw if the directory doesn't exist or contains no loadable files. In CI this usually means the fixtures weren't committed, or FIXTURES_DIR resolved relative to a different working directory than locally.
OPEN_ERROR_IN_BROWSER must be false in CI. It defaults to false, but if someone sets it locally to debug HTML responses and commits it, you get browser-launch attempts in your pipeline. Pair with OPEN_BROWSER_COMMAND.
Removed features you might find in old blog posts: SymfonyMockerContainer support and the container-mocking feature are gone (see changelog #173, #150). tearDown is now protected (#152), so subclasses overriding it as public will fatal. Don't copy two-year-old tutorials.
Matchers fail open. @string@ matches essentially any string; @array@ matches any array, including an empty one. There is no "strict mode." Review expected files in PRs as seriously as you review the assertions — a test asserting {"data": "@array@"} is nearly worthless. Prefer exact strings and explicit @integer@-with-constraints (@[email protected](0)), and use @...@ only where you truly cannot predict.
Debugging a mismatch: the PHP-Matcher diff in the PHPUnit output is usually enough. If not, TMP_DIR is where the package writes its temporary diff/log files — point it somewhere sane (not /tmp on ephemeral CI) and inspect there. Setting TMP_DIR explicitly is also useful when you want artifacts uploaded from failed CI runs.
Extension points are limited by design. The package is a thin shell over PHP-Matcher and Alice, so the real extension surface is those two libraries: PHP-Matcher lets you register custom patterns, and Alice lets you write custom fixture providers. Subclassing JsonApiTestCase with your own assert* helpers is the intended way to add domain-specific behaviour — there is no plugin system and no service container hook.
Given 0 dependents and a "documentation/maturity" profile built on a good README plus changelog, treat it as a small, focused utility — that's actually the right mental model. Read the ~5 classes it ships, and if a behaviour surprises you, you can find the answer faster in the source than in any documentation.
How can I help you explore Laravel packages today?