Framework-agnostic OpenAPI 3.0/3.1 contract testing for PHPUnit with endpoint coverage tracking.
Validate your API responses against your OpenAPI specification during testing, and get a coverage report showing which endpoints have been tested.
- OpenAPI 3.0 & 3.1 support — Automatic version detection from the
openapifield - Response validation — Validates response bodies against JSON Schema (Draft 07 via opis/json-schema). Supports
application/jsonand any+jsoncontent type (e.g.,application/problem+json) - Content negotiation — Accepts the actual response
Content-Typeto handle mixed-content specs. Non-JSON responses (e.g.,text/html,application/xml) are verified for spec presence without body validation; JSON-compatible responses are fully schema-validated - Endpoint coverage tracking — Unique PHPUnit extension that reports which spec endpoints are covered by tests
- Path matching — Handles parameterized paths (
/pets/{petId}) with configurable prefix stripping - Laravel adapter — Optional trait for seamless integration with Laravel's
TestResponse - Zero runtime overhead — Only used in test suites
- PHP 8.2+
- PHPUnit 11, 12, or 13
- Redocly CLI (recommended for
$refresolution / bundling)
composer require --dev studio-design/openapi-contract-testingThis package expects a bundled (all $refs resolved) JSON spec file. Use Redocly CLI to bundle:
npx @redocly/cli bundle openapi/root.yaml --dereferenced -o openapi/bundled/front.jsonImportant: The
--dereferencedflag is required. Without it,$refpointers (e.g.,#/components/schemas/...) are preserved in the output, causingUnresolvedReferenceExceptionat validation time. The underlying JSON Schema validator (opis/json-schema) does not resolve OpenAPI$refreferences.
Add the coverage extension to your phpunit.xml:
<extensions>
<bootstrap class="Studio\OpenApiContractTesting\PHPUnit\OpenApiCoverageExtension">
<parameter name="spec_base_path" value="openapi/bundled"/>
<parameter name="strip_prefixes" value="/api"/>
<parameter name="specs" value="front,admin"/>
</bootstrap>
</extensions>| Parameter | Required | Default | Description |
|---|---|---|---|
spec_base_path |
Yes* | — | Path to bundled spec directory (relative paths resolve from getcwd()) |
strip_prefixes |
No | [] |
Comma-separated prefixes to strip from request paths (e.g., /api) |
specs |
No | front |
Comma-separated spec names for coverage tracking |
output_file |
No | — | File path to write Markdown coverage report (relative paths resolve from getcwd()) |
*Not required if you call OpenApiSpecLoader::configure() manually.
Publish the config file:
php artisan vendor:publish --tag=openapi-contract-testingThis creates config/openapi-contract-testing.php:
return [
'default_spec' => '', // e.g., 'front'
];Set default_spec to your spec name, then use the trait — no per-class override needed:
use Studio\OpenApiContractTesting\Laravel\ValidatesOpenApiSchema;
class GetPetsTest extends TestCase
{
use ValidatesOpenApiSchema;
public function test_list_pets(): void
{
$response = $this->get('/api/v1/pets');
$response->assertOk();
$this->assertResponseMatchesOpenApiSchema($response);
}
}To use a different spec for a specific test class, override openApiSpec():
class AdminGetUsersTest extends TestCase
{
use ValidatesOpenApiSchema;
protected function openApiSpec(): string
{
return 'admin';
}
// ...
}use Studio\OpenApiContractTesting\OpenApiResponseValidator;
use Studio\OpenApiContractTesting\OpenApiSpecLoader;
// Configure once (e.g., in bootstrap)
OpenApiSpecLoader::configure(__DIR__ . '/openapi/bundled', ['/api']);
// In your test
$validator = new OpenApiResponseValidator();
$result = $validator->validate(
specName: 'front',
method: 'GET',
requestPath: '/api/v1/pets',
statusCode: 200,
responseBody: $decodedJsonBody,
responseContentType: 'application/json', // optional: enables content negotiation
);
$this->assertTrue($result->isValid(), $result->errorMessage());After running tests, the PHPUnit extension prints a coverage report:
OpenAPI Contract Test Coverage
==================================================
[front] 12/45 endpoints (26.7%)
--------------------------------------------------
Covered:
✓ GET /v1/pets
✓ POST /v1/pets
✓ GET /v1/pets/{petId}
✓ DELETE /v1/pets/{petId}
Uncovered: 41 endpoints
When running in GitHub Actions, the extension automatically detects the GITHUB_STEP_SUMMARY environment variable and appends a Markdown coverage report to the job summary. No configuration needed.
Note: Both features are independent — when running in GitHub Actions with
output_fileconfigured, the Markdown report is written to both the file and the Step Summary.
Use the output_file parameter to write a Markdown report to a file. This is useful for posting coverage as a PR comment:
<extensions>
<bootstrap class="Studio\OpenApiContractTesting\PHPUnit\OpenApiCoverageExtension">
<parameter name="spec_base_path" value="openapi/bundled"/>
<parameter name="specs" value="front,admin"/>
<parameter name="output_file" value="coverage-report.md"/>
</bootstrap>
</extensions>Example GitHub Actions workflow step to post the report as a PR comment:
- name: Run tests
run: vendor/bin/phpunit
- name: Post coverage comment
if: github.event_name == 'pull_request' && hashFiles('coverage-report.md') != ''
uses: marocchino/sticky-pull-request-comment@v2
with:
path: coverage-report.mdThe package auto-detects the OAS version from the openapi field and handles schema conversion accordingly:
| Feature | 3.0 handling | 3.1 handling |
|---|---|---|
nullable: true |
Converted to type array ["string", "null"] |
Not applicable (uses type arrays natively) |
prefixItems |
N/A | Converted to items array (Draft 07 tuple) |
$dynamicRef / $dynamicAnchor |
N/A | Removed (not in Draft 07) |
examples (array) |
N/A | Removed (OAS extension) |
readOnly / writeOnly |
Removed (OAS-only in 3.0) | Preserved (valid in Draft 07) |
Main validator class. Validates a response body against the spec.
The optional responseContentType parameter enables content negotiation: when provided, non-JSON content types (e.g., text/html) are checked for spec presence only, while JSON-compatible types proceed to full schema validation.
$result = $validator->validate(
specName: 'front',
method: 'GET',
requestPath: '/api/v1/pets/123',
statusCode: 200,
responseBody: ['id' => 123, 'name' => 'Fido'],
responseContentType: 'application/json',
);
$result->isValid(); // bool
$result->errors(); // string[]
$result->errorMessage(); // string (joined errors)
$result->matchedPath(); // ?string (e.g., '/v1/pets/{petId}')Manages spec loading and configuration.
OpenApiSpecLoader::configure('/path/to/bundled/specs', ['/api']);
$spec = OpenApiSpecLoader::load('front');
OpenApiSpecLoader::reset(); // For testingTracks which endpoints have been validated.
OpenApiCoverageTracker::record('front', 'GET', '/v1/pets');
$coverage = OpenApiCoverageTracker::computeCoverage('front');
// ['covered' => [...], 'uncovered' => [...], 'total' => 45, 'coveredCount' => 12]composer install
# Run tests
vendor/bin/phpunit
# Static analysis
vendor/bin/phpstan analyse
# Code style
vendor/bin/php-cs-fixer fix
vendor/bin/php-cs-fixer fix --dry-run --diff # Check onlyMIT License. See LICENSE for details.