diff --git a/README.md b/README.md index e95d2b8..660620e 100644 --- a/README.md +++ b/README.md @@ -106,24 +106,85 @@ class GetPetsTest extends TestCase } ``` -To use a different spec for a specific test class, override `openApiSpec()`: +To use a different spec for a specific test class, add the `#[OpenApiSpec]` attribute: ```php +use Studio\OpenApiContractTesting\OpenApiSpec; +use Studio\OpenApiContractTesting\Laravel\ValidatesOpenApiSchema; + +#[OpenApiSpec('admin')] class AdminGetUsersTest extends TestCase { use ValidatesOpenApiSchema; - protected function openApiSpec(): string + // All tests in this class use the 'admin' spec +} +``` + +You can also specify the spec per test method. Method-level attributes take priority over class-level: + +```php +#[OpenApiSpec('front')] +class MixedApiTest extends TestCase +{ + use ValidatesOpenApiSchema; + + public function test_front_endpoint(): void { - return 'admin'; + // Uses 'front' from class-level attribute } - // ... + #[OpenApiSpec('admin')] + public function test_admin_endpoint(): void + { + // Uses 'admin' from method-level attribute (overrides class) + } } ``` +Resolution priority (highest to lowest): + +1. Method-level `#[OpenApiSpec]` attribute +2. Class-level `#[OpenApiSpec]` attribute +3. `openApiSpec()` method override +4. `config('openapi-contract-testing.default_spec')` + +> **Note:** You can still override `openApiSpec()` as before — it remains fully backward-compatible. + #### Framework-agnostic +You can use the `#[OpenApiSpec]` attribute with the `OpenApiSpecResolver` trait in any PHPUnit test: + +```php +use Studio\OpenApiContractTesting\OpenApiSpec; +use Studio\OpenApiContractTesting\OpenApiSpecResolver; +use Studio\OpenApiContractTesting\OpenApiResponseValidator; + +#[OpenApiSpec('front')] +class GetPetsTest extends TestCase +{ + use OpenApiSpecResolver; + + public function test_list_pets(): void + { + $specName = $this->resolveOpenApiSpec(); // 'front' + $validator = new OpenApiResponseValidator(); + $result = $validator->validate( + specName: $specName, + method: 'GET', + requestPath: '/api/v1/pets', + statusCode: 200, + responseBody: $decodedJsonBody, + responseContentType: 'application/json', + ); + + $this->assertTrue($result->isValid(), $result->errorMessage()); + } +} +``` + +Or without the attribute, pass the spec name directly: + ```php use Studio\OpenApiContractTesting\OpenApiResponseValidator; use Studio\OpenApiContractTesting\OpenApiSpecLoader; diff --git a/composer.json b/composer.json index 3a8395a..77ccd1a 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,8 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.94", "illuminate/testing": "^11.0 || ^12.0", - "phpstan/phpstan": "^2.0" + "phpstan/phpstan": "^2.0", + "symfony/http-foundation": "^6.4 || ^7.0 || ^8.0" }, "suggest": { "illuminate/testing": "Required for the Laravel adapter (ValidatesOpenApiSchema trait)" diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 419275f..a851626 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -19,17 +19,4 @@ parameters: path: src/Laravel/OpenApiContractTestingServiceProvider.php - message: '#does not specify its types: TResponse#' - paths: - - src/Laravel/ValidatesOpenApiSchema.php - - tests/Unit/ValidatesOpenApiSchemaTest.php - - tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php - - - message: '#expects TResponse of Symfony\\Component\\HttpFoundation\\Response#' - paths: - - tests/Unit/ValidatesOpenApiSchemaTest.php - - tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php - - - message: '#class@anonymous/tests/Helpers/CreatesTestResponse\.php.*no value type specified in iterable type array#' - paths: - - tests/Unit/ValidatesOpenApiSchemaTest.php - - tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php + path: src/Laravel/ValidatesOpenApiSchema.php diff --git a/src/Laravel/ValidatesOpenApiSchema.php b/src/Laravel/ValidatesOpenApiSchema.php index c32cdc8..9e7dc91 100644 --- a/src/Laravel/ValidatesOpenApiSchema.php +++ b/src/Laravel/ValidatesOpenApiSchema.php @@ -9,6 +9,7 @@ use Studio\OpenApiContractTesting\HttpMethod; use Studio\OpenApiContractTesting\OpenApiCoverageTracker; use Studio\OpenApiContractTesting\OpenApiResponseValidator; +use Studio\OpenApiContractTesting\OpenApiSpecResolver; use function is_numeric; use function is_string; @@ -17,6 +18,8 @@ trait ValidatesOpenApiSchema { + use OpenApiSpecResolver; + protected function openApiSpec(): string { $spec = config('openapi-contract-testing.default_spec'); @@ -28,16 +31,22 @@ protected function openApiSpec(): string return $spec; } + protected function openApiSpecFallback(): string + { + return $this->openApiSpec(); + } + protected function assertResponseMatchesOpenApiSchema( TestResponse $response, ?HttpMethod $method = null, ?string $path = null, ): void { - $specName = $this->openApiSpec(); + $specName = $this->resolveOpenApiSpec(); if ($specName === '') { $this->fail( 'openApiSpec() must return a non-empty spec name, but an empty string was returned. ' - . 'Either override openApiSpec() in your test class, or set the "default_spec" key ' + . 'Either add #[OpenApiSpec(\'your-spec\')] to your test class or method, ' + . 'override openApiSpec() in your test class, or set the "default_spec" key ' . 'in config/openapi-contract-testing.php.', ); } diff --git a/src/OpenApiSpec.php b/src/OpenApiSpec.php new file mode 100644 index 0000000..fda63c1 --- /dev/null +++ b/src/OpenApiSpec.php @@ -0,0 +1,15 @@ +name(); // @phpstan-ignore method.notFound + $refMethod = new ReflectionMethod($this, $methodName); + $methodAttrs = $refMethod->getAttributes(OpenApiSpec::class); + if ($methodAttrs !== []) { + return $methodAttrs[0]->newInstance()->name; + } + + // 2. Class-level #[OpenApiSpec] attribute + $refClass = new ReflectionClass($this); + $classAttrs = $refClass->getAttributes(OpenApiSpec::class); + if ($classAttrs !== []) { + return $classAttrs[0]->newInstance()->name; + } + + // 3. Subclass hook (e.g. openApiSpec() in Laravel trait) + return $this->openApiSpecFallback(); + } +} diff --git a/tests/Helpers/CreatesTestResponse.php b/tests/Helpers/CreatesTestResponse.php index 15f6bbd..66cb0dc 100644 --- a/tests/Helpers/CreatesTestResponse.php +++ b/tests/Helpers/CreatesTestResponse.php @@ -4,58 +4,20 @@ namespace Studio\OpenApiContractTesting\Tests\Helpers; -use const CASE_LOWER; - use Illuminate\Testing\TestResponse; - -use function array_change_key_case; -use function strtolower; +use Symfony\Component\HttpFoundation\Response; trait CreatesTestResponse { /** * @param array $headers + * + * @return TestResponse */ private function makeTestResponse(string $content, int $statusCode, array $headers = []): TestResponse { - $headerBag = new class ($headers) { - /** @var array */ - private readonly array $headers; - - /** @param array $headers */ - public function __construct(array $headers) - { - $this->headers = array_change_key_case($headers, CASE_LOWER); - } - - public function get(string $key, ?string $default = null): ?string - { - return $this->headers[strtolower($key)] ?? $default; - } - }; - - $baseResponse = new class ($content, $statusCode, $headerBag) { - public readonly object $headers; - - public function __construct( - private readonly string $content, - private readonly int $statusCode, - object $headers, - ) { - $this->headers = $headers; - } - - public function getContent(): string - { - return $this->content; - } - - public function getStatusCode(): int - { - return $this->statusCode; - } - }; + $response = new Response($content, $statusCode, $headers); - return new TestResponse($baseResponse); + return new TestResponse($response); } } diff --git a/tests/Unit/OpenApiSpecResolverFallbackTest.php b/tests/Unit/OpenApiSpecResolverFallbackTest.php new file mode 100644 index 0000000..e5dd36e --- /dev/null +++ b/tests/Unit/OpenApiSpecResolverFallbackTest.php @@ -0,0 +1,25 @@ +assertSame('from-fallback', $this->resolveOpenApiSpec()); + } + + protected function openApiSpecFallback(): string + { + return 'from-fallback'; + } +} diff --git a/tests/Unit/OpenApiSpecResolverTest.php b/tests/Unit/OpenApiSpecResolverTest.php new file mode 100644 index 0000000..10836e9 --- /dev/null +++ b/tests/Unit/OpenApiSpecResolverTest.php @@ -0,0 +1,37 @@ +assertSame('petstore-3.0', $this->resolveOpenApiSpec()); + } + + #[Test] + #[OpenApiSpec('petstore-3.1')] + public function method_level_attribute_overrides_class_level(): void + { + $this->assertSame('petstore-3.1', $this->resolveOpenApiSpec()); + } + + #[Test] + public function fallback_returns_empty_string_when_no_attribute(): void + { + // This test class has a class-level attribute, so we verify + // the fallback method itself returns empty by default. + $this->assertSame('', $this->openApiSpecFallback()); + } +} diff --git a/tests/Unit/ValidatesOpenApiSchemaAttributeTest.php b/tests/Unit/ValidatesOpenApiSchemaAttributeTest.php new file mode 100644 index 0000000..dc08d2c --- /dev/null +++ b/tests/Unit/ValidatesOpenApiSchemaAttributeTest.php @@ -0,0 +1,77 @@ + [['id' => 1, 'name' => 'Fido', 'tag' => null]]], + JSON_THROW_ON_ERROR, + ); + $response = $this->makeTestResponse($body, 200); + + $this->assertResponseMatchesOpenApiSchema( + $response, + HttpMethod::GET, + '/v1/pets', + ); + } + + #[Test] + #[OpenApiSpec('petstore-3.1')] + public function method_level_attribute_overrides_class_level(): void + { + $body = (string) json_encode( + ['data' => [['id' => 1, 'name' => 'Fido', 'tag' => null]]], + JSON_THROW_ON_ERROR, + ); + $response = $this->makeTestResponse($body, 200); + + $this->assertResponseMatchesOpenApiSchema( + $response, + HttpMethod::GET, + '/v1/pets', + ); + + // Verify coverage was recorded under the method-level spec name + $covered = OpenApiCoverageTracker::getCovered(); + $this->assertArrayHasKey('petstore-3.1', $covered); + } +} diff --git a/tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php b/tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php index 63ec93c..d12d6b7 100644 --- a/tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php @@ -77,7 +77,8 @@ public function empty_config_default_spec_fails_with_clear_message(): void $this->expectException(AssertionFailedError::class); $this->expectExceptionMessage( 'openApiSpec() must return a non-empty spec name, but an empty string was returned. ' - . 'Either override openApiSpec() in your test class, or set the "default_spec" key ' + . 'Either add #[OpenApiSpec(\'your-spec\')] to your test class or method, ' + . 'override openApiSpec() in your test class, or set the "default_spec" key ' . 'in config/openapi-contract-testing.php.', );