Testing โ
Conventions and helpers for the test suite.
composer test # the whole suite
composer test -- --filter CompilerTest # a single class
composer performance # perf tests, excluded from the default runcomposer test runs tests only. Code style is composer lint and static analysis is composer analyse โ each command does one thing, matching how CI runs them.
Prefer data providers โ
Most behavior in this library varies along a few axes โ OpenAPI version, processing mode, annotation vs attribute vs spec input. Repeating a test body per combination gets long and hides which case actually failed.
Use #[DataProvider] and let the provider name the case, so a failure reports which combination broke rather than just the assertion. Most of the suite already works this way; follow the closest existing example rather than inventing a new shape.
Prefer adding a case to an existing provider over copying a whole test method. Prefer adding new test cases over modifying existing ones โ an existing case usually encodes a regression somebody cared about.
Build objects in the test, not in the provider โ
PHPUnit evaluates providers while collecting tests, before coverage recording starts, so anything constructed in a provider is asserted but never counted as covered. Yield class names and arguments; construct in the test body.
The failure is silent โ tests pass either way โ and only shows up when someone runs coverage and wonders why a tested class reads as untouched.
Prefer a fixture to an assertion โ
A test that asserts what the implementation currently produces captures the status quo. That is worth having, but it is not evidence the output is correct โ it only pins today's behaviour so tomorrow's change is visible.
Where the output is a specification document, a scratch fixture is stronger. It runs the whole pipeline rather than one stage, it produces a file per OpenAPI version, and those files are linted by composer redocly against the real schema. The expectation then comes from the specification rather than from whoever wrote the test.
Concretely: the attributes now covered by Fixtures/Scratch/Auth-spec.php were first covered by a unit test asserting compiler output. Every case passed. The fixture that replaced it immediately failed Redocly, because the pipeline was emitting type: mutualTLS into OpenAPI 3.0 documents, where that type does not exist. The assertions had faithfully encoded the bug.
Reach for a unit test when there is no document to validate โ ComponentIndexTest and SlotMapConsistencyTest are the right shape, because neither has a YAML counterpart.
Shared helpers โ
tests/Concerns/ holds the reusable pieces. Look here before writing setup code:
| Trait | Use for |
|---|---|
AssemblesSpecification | assemble(...$classes) โ build a Specification from classes without touching the filesystem |
AssertsSchemaStructure | comparing compiled schema structure (allOf refs + property names) against a YAML fixture, independent of property order |
AssertsSpecEquals | deep-equality on a whole document โ YAML, array or stdClass โ order-independent for maps |
CollectsSpecClasses | enumerating every OpenApi\Spec attribute class, for suite-wide invariants |
ExpectsLogEntries | declaring which diagnostics a build is allowed to emit |
GeneratesTestMatrix | building version ร mode combinations, with exclusions, and discovering fixtures by glob |
UsesExamples | registering a classloader for a docs/examples implementation |
UsesFixtures | resolving paths under tests/Fixtures/ |
GeneratesTestMatrix is the one to reach for when a test needs to run across versions and modes โ it handles the cartesian product, the exclusions, and stable test-case naming, so providers stay short.
ExpectsLogEntries is strict: an entry matching neither an expectation nor an allowance fails the test, so a new diagnostic cannot appear unnoticed. Prefer expectLogEntry() โ allowLogEntry() asserts nothing, and is for diagnostics incidental to what the test is about.
SlotMapConsistencyTest is a good example of the suite-wide-invariant style: rather than testing one attribute, it checks a property that must hold across all of them.
The docs are part of the test suite โ
Two tests verify documentation against real output. Both will fail if you add documentation without its counterpart:
DocSnippetsTest runs every docs/snippets/*_an.php and compares against the matching -3.1.0.yaml, across the applicable modes and implementations. Adding a snippet means adding all of:
foo_an.php(annotations),foo_at.php(attributes),foo_spec.php(spec attributes)foo-3.1.0.yamlโ the expected output
Missing implementations are skipped rather than failing, but a missing or stale -3.1.0.yaml is a failure. The mode/implementation pairing is deliberate: spec mode runs only spec snippets and classic mode never runs them, but hybrid runs all three.
ExamplesTest does the same for docs/examples/specs/*, against the per-version *-3.0.0.yaml / *-3.1.0.yaml / *-3.2.0.yaml fixtures.
This is why changing pipeline output shows up as a wall of documentation failures. That is the suite working: the expected YAML files are the specification of what the pipeline produces, and they are what the published docs display.
Fixtures โ
tests/Fixtures/โ general test fixturestests/Fixtures/Scratch/โ spec-pipeline scratch fixtures with expected YAML, exercised byScratchTestdocs/examples/specs/โ full worked examples, doubling as published documentation
A Scratch fixture that provokes a diagnostic declares it in ScratchTest::scratchTestCases(), keyed {fixture}-{version} when every mode raises it, or {fixture}-{version}-{mode} when only one does. Both keys apply when both are present, so a mode-specific entry adds to the shared one rather than replacing it.
composer redocly validates the generated example specs against the OpenAPI schema. It passes, with warnings; known problems are suppressed via .redocly.lint-ignore.yaml, so a new failure means something genuinely regressed.