From e1572f81f474a30c486102e4d8bb87832be644cd Mon Sep 17 00:00:00 2001 From: wadakatu Date: Thu, 23 Apr 2026 02:50:54 +0900 Subject: [PATCH 1/7] feat: add #[SkipOpenApi] attribute and resolver trait Add a reflection-based resolver that detects method-level or class-level #[SkipOpenApi(reason: '...')] attributes, with method-level taking precedence over class-level for fine-grained opt-out. Refs #40 --- src/SkipOpenApi.php | 15 +++++++ src/SkipOpenApiResolver.php | 43 +++++++++++++++++++ .../SkipOpenApiResolverClassLevelTest.php | 31 +++++++++++++ tests/Unit/SkipOpenApiResolverTest.php | 37 ++++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 src/SkipOpenApi.php create mode 100644 src/SkipOpenApiResolver.php create mode 100644 tests/Unit/SkipOpenApiResolverClassLevelTest.php create mode 100644 tests/Unit/SkipOpenApiResolverTest.php diff --git a/src/SkipOpenApi.php b/src/SkipOpenApi.php new file mode 100644 index 0000000..1c23fb7 --- /dev/null +++ b/src/SkipOpenApi.php @@ -0,0 +1,15 @@ +findSkipOpenApiAttribute() !== null; + } + + private function resolveSkipOpenApiReason(): string + { + $attr = $this->findSkipOpenApiAttribute(); + + return $attr === null ? '' : $attr->reason; + } + + private function findSkipOpenApiAttribute(): ?SkipOpenApi + { + // 1. Method-level #[SkipOpenApi] attribute + $methodName = $this->name(); // @phpstan-ignore method.notFound + $refMethod = new ReflectionMethod($this, $methodName); + $methodAttrs = $refMethod->getAttributes(SkipOpenApi::class); + if ($methodAttrs !== []) { + return $methodAttrs[0]->newInstance(); + } + + // 2. Class-level #[SkipOpenApi] attribute + $refClass = new ReflectionClass($this); + $classAttrs = $refClass->getAttributes(SkipOpenApi::class); + if ($classAttrs !== []) { + return $classAttrs[0]->newInstance(); + } + + return null; + } +} diff --git a/tests/Unit/SkipOpenApiResolverClassLevelTest.php b/tests/Unit/SkipOpenApiResolverClassLevelTest.php new file mode 100644 index 0000000..037a765 --- /dev/null +++ b/tests/Unit/SkipOpenApiResolverClassLevelTest.php @@ -0,0 +1,31 @@ +assertTrue($this->shouldSkipOpenApi()); + $this->assertSame('class-level', $this->resolveSkipOpenApiReason()); + } + + #[Test] + #[SkipOpenApi(reason: 'method-level')] + public function method_level_reason_overrides_class_level(): void + { + $this->assertTrue($this->shouldSkipOpenApi()); + $this->assertSame('method-level', $this->resolveSkipOpenApiReason()); + } +} diff --git a/tests/Unit/SkipOpenApiResolverTest.php b/tests/Unit/SkipOpenApiResolverTest.php new file mode 100644 index 0000000..6ba7cc5 --- /dev/null +++ b/tests/Unit/SkipOpenApiResolverTest.php @@ -0,0 +1,37 @@ +assertFalse($this->shouldSkipOpenApi()); + $this->assertSame('', $this->resolveSkipOpenApiReason()); + } + + #[Test] + #[SkipOpenApi] + public function method_level_attribute_skips(): void + { + $this->assertTrue($this->shouldSkipOpenApi()); + } + + #[Test] + #[SkipOpenApi(reason: 'experimental endpoint')] + public function method_level_reason_is_resolved(): void + { + $this->assertTrue($this->shouldSkipOpenApi()); + $this->assertSame('experimental endpoint', $this->resolveSkipOpenApiReason()); + } +} From e853809001bf8755dc8765f03a9437d3bc9285a8 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Thu, 23 Apr 2026 02:51:02 +0900 Subject: [PATCH 2/7] feat(laravel): honor #[SkipOpenApi] in auto-assert with explicit-call warning maybeAutoAssertOpenApiSchema() now early-returns when the current test method or its class carries #[SkipOpenApi], so no validation or coverage recording happens on intentionally skipped tests. Calling assertResponseMatchesOpenApiSchema() explicitly on a skipped test still runs the assertion but emits E_USER_DEPRECATED via a swappable $skipWarningHandler to flag the contradictory intent. Refs #40 --- src/Laravel/ValidatesOpenApiSchema.php | 48 +++++++ .../Laravel/AutoAssertIntegrationTest.php | 16 +++ ...lidatesOpenApiSchemaClassLevelSkipTest.php | 59 +++++++++ tests/Unit/ValidatesOpenApiSchemaSkipTest.php | 121 ++++++++++++++++++ 4 files changed, 244 insertions(+) create mode 100644 tests/Unit/ValidatesOpenApiSchemaClassLevelSkipTest.php create mode 100644 tests/Unit/ValidatesOpenApiSchemaSkipTest.php diff --git a/src/Laravel/ValidatesOpenApiSchema.php b/src/Laravel/ValidatesOpenApiSchema.php index 809eaed..2755c48 100644 --- a/src/Laravel/ValidatesOpenApiSchema.php +++ b/src/Laravel/ValidatesOpenApiSchema.php @@ -4,6 +4,7 @@ namespace Studio\OpenApiContractTesting\Laravel; +use const E_USER_DEPRECATED; use const FILTER_NULL_ON_FAILURE; use const FILTER_VALIDATE_BOOLEAN; @@ -13,6 +14,7 @@ use Studio\OpenApiContractTesting\OpenApiCoverageTracker; use Studio\OpenApiContractTesting\OpenApiResponseValidator; use Studio\OpenApiContractTesting\OpenApiSpecResolver; +use Studio\OpenApiContractTesting\SkipOpenApiResolver; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use WeakMap; @@ -25,17 +27,30 @@ use function str_contains; use function strtolower; use function strtoupper; +use function trigger_error; use function var_export; trait ValidatesOpenApiSchema { use OpenApiSpecResolver; + use SkipOpenApiResolver; private static ?OpenApiResponseValidator $cachedValidator = null; private static ?int $cachedMaxErrors = null; /** @var null|WeakMap> */ private static ?WeakMap $validatedResponses = null; + /** + * Swap the handler that receives "#[SkipOpenApi] + explicit assert" warnings. + * Defaults to emitting an E_USER_DEPRECATED via trigger_error() so PHPUnit + * surfaces it in the run summary without failing the test (option A + * semantics: explicit calls always run; the warning is only a nudge). + * Tests can swap it to capture warnings in-memory. + * + * @var null|callable(string): void + */ + private static $skipWarningHandler; + public static function resetValidatorCache(): void { self::$cachedValidator = null; @@ -76,6 +91,14 @@ protected function maybeAutoAssertOpenApiSchema( return; } + // #[SkipOpenApi] opts the test out of auto-assert entirely — no + // validation, no coverage recording. Explicit calls to + // assertResponseMatchesOpenApiSchema() are not affected here; they + // still run but emit a warning so contradictory intent is visible. + if ($this->shouldSkipOpenApi()) { + return; + } + $this->assertResponseMatchesOpenApiSchema($response, $method, $path); } @@ -100,6 +123,10 @@ protected function assertResponseMatchesOpenApiSchema( ?HttpMethod $method = null, ?string $path = null, ): void { + if ($this->shouldSkipOpenApi()) { + $this->emitSkipOpenApiWarning(); + } + $resolvedMethod = $method !== null ? $method->value : app('request')->getMethod(); $resolvedPath = $path ?? app('request')->getPathInfo(); @@ -188,6 +215,27 @@ private static function getOrCreateValidator(): OpenApiResponseValidator return self::$cachedValidator; } + private function emitSkipOpenApiWarning(): void + { + $reason = $this->resolveSkipOpenApiReason(); + $message = sprintf( + '%s::%s is marked #[SkipOpenApi%s] but called assertResponseMatchesOpenApiSchema() explicitly. ' + . 'The assertion will run. Remove the attribute or the explicit call to clarify intent.', + static::class, + $this->name(), // @phpstan-ignore method.notFound + $reason !== '' ? sprintf('(reason: %s)', var_export($reason, true)) : '', + ); + + $handler = self::$skipWarningHandler; + if ($handler !== null) { + $handler($message); + + return; + } + + trigger_error($message, E_USER_DEPRECATED); + } + private function isAutoAssertEnabled(): bool { $raw = config('openapi-contract-testing.auto_assert', false); diff --git a/tests/Integration/Laravel/AutoAssertIntegrationTest.php b/tests/Integration/Laravel/AutoAssertIntegrationTest.php index db32949..38a1653 100644 --- a/tests/Integration/Laravel/AutoAssertIntegrationTest.php +++ b/tests/Integration/Laravel/AutoAssertIntegrationTest.php @@ -13,6 +13,7 @@ use Studio\OpenApiContractTesting\OpenApiCoverageTracker; use Studio\OpenApiContractTesting\OpenApiSpec; use Studio\OpenApiContractTesting\OpenApiSpecLoader; +use Studio\OpenApiContractTesting\SkipOpenApi; use function dirname; @@ -163,6 +164,21 @@ public function method_level_attribute_resolves_spec_for_auto_assert(): void $this->assertArrayNotHasKey('petstore-3.0', $covered); } + #[Test] + #[SkipOpenApi(reason: 'intentional spec violation for test')] + public function skip_open_api_attribute_opts_method_out_of_auto_assert(): void + { + // Would normally fail auto-assert because ?bad=1 returns {wrong_key:...} + // which violates the spec. #[SkipOpenApi] must prevent that failure + // AND stop coverage recording. + config()->set('openapi-contract-testing.auto_assert', true); + + $response = $this->get('/v1/pets?bad=1'); + $response->assertOk(); + + $this->assertArrayNotHasKey('petstore-3.0', OpenApiCoverageTracker::getCovered()); + } + /** @return array */ protected function getPackageProviders($app): array { diff --git a/tests/Unit/ValidatesOpenApiSchemaClassLevelSkipTest.php b/tests/Unit/ValidatesOpenApiSchemaClassLevelSkipTest.php new file mode 100644 index 0000000..434835d --- /dev/null +++ b/tests/Unit/ValidatesOpenApiSchemaClassLevelSkipTest.php @@ -0,0 +1,59 @@ + 'petstore-3.0', + 'openapi-contract-testing.auto_assert' => true, + ]; + } + + protected function tearDown(): void + { + self::resetValidatorCache(); + unset($GLOBALS['__openapi_testing_config']); + OpenApiSpecLoader::reset(); + OpenApiCoverageTracker::reset(); + parent::tearDown(); + } + + #[Test] + public function class_level_skip_opts_out_of_auto_assert(): void + { + $body = (string) json_encode(['wrong_key' => 'value'], JSON_THROW_ON_ERROR); + $response = $this->makeTestResponse($body, 200); + + $this->maybeAutoAssertOpenApiSchema($response, HttpMethod::GET, '/v1/pets'); + + $this->assertArrayNotHasKey('petstore-3.0', OpenApiCoverageTracker::getCovered()); + } +} diff --git a/tests/Unit/ValidatesOpenApiSchemaSkipTest.php b/tests/Unit/ValidatesOpenApiSchemaSkipTest.php new file mode 100644 index 0000000..39a8bf3 --- /dev/null +++ b/tests/Unit/ValidatesOpenApiSchemaSkipTest.php @@ -0,0 +1,121 @@ + */ + private array $capturedWarnings = []; + + protected function setUp(): void + { + parent::setUp(); + OpenApiSpecLoader::reset(); + OpenApiSpecLoader::configure(__DIR__ . '/../fixtures/specs'); + OpenApiCoverageTracker::reset(); + $GLOBALS['__openapi_testing_config'] = [ + 'openapi-contract-testing.default_spec' => 'petstore-3.0', + 'openapi-contract-testing.auto_assert' => true, + ]; + + $this->capturedWarnings = []; + self::$skipWarningHandler = function (string $message): void { + $this->capturedWarnings[] = $message; + }; + } + + protected function tearDown(): void + { + self::resetValidatorCache(); + self::$skipWarningHandler = null; + unset($GLOBALS['__openapi_testing_config']); + OpenApiSpecLoader::reset(); + OpenApiCoverageTracker::reset(); + parent::tearDown(); + } + + #[Test] + #[SkipOpenApi] + public function method_level_skip_opts_out_of_auto_assert(): void + { + // Body intentionally violates the spec — if auto-assert ran, this + // would throw AssertionFailedError. + $body = (string) json_encode(['wrong_key' => 'value'], JSON_THROW_ON_ERROR); + $response = $this->makeTestResponse($body, 200); + + $this->maybeAutoAssertOpenApiSchema($response, HttpMethod::GET, '/v1/pets'); + + // Coverage must not be recorded for skipped tests. + $this->assertArrayNotHasKey('petstore-3.0', OpenApiCoverageTracker::getCovered()); + } + + #[Test] + #[SkipOpenApi] + public function skipped_test_does_not_emit_warning_when_no_explicit_assert(): void + { + $body = (string) json_encode(['wrong_key' => 'value'], JSON_THROW_ON_ERROR); + $response = $this->makeTestResponse($body, 200); + + $this->maybeAutoAssertOpenApiSchema($response, HttpMethod::GET, '/v1/pets'); + + $this->assertSame([], $this->capturedWarnings); + } + + #[Test] + #[SkipOpenApi] + public function explicit_assert_on_skipped_test_emits_warning_and_still_validates(): 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'); + + // Warning was emitted once. + $this->assertCount(1, $this->capturedWarnings); + $this->assertStringContainsString('#[SkipOpenApi]', $this->capturedWarnings[0]); + $this->assertStringContainsString('assertResponseMatchesOpenApiSchema', $this->capturedWarnings[0]); + + // And validation actually ran — coverage was recorded despite the skip. + $covered = OpenApiCoverageTracker::getCovered(); + $this->assertArrayHasKey('petstore-3.0', $covered); + $this->assertArrayHasKey('GET /v1/pets', $covered['petstore-3.0']); + } + + #[Test] + public function non_skipped_explicit_assert_does_not_emit_warning(): 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'); + + $this->assertSame([], $this->capturedWarnings); + } +} From 868501bdefd339075cc8fcaf45e723b725b251d2 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Thu, 23 Apr 2026 02:51:08 +0900 Subject: [PATCH 3/7] docs: document #[SkipOpenApi] attribute Add an Auto-assert subsection covering method-level and class-level usage of #[SkipOpenApi], the optional reason parameter, and the deprecation-warning behavior when assertResponseMatchesOpenApiSchema() is called explicitly on a skipped test. Refs #40 --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index da9347d..2d7b7c9 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,34 @@ Notes: - `auto_assert` accepts boolean-compatible values (`true`/`false`/`"1"`/`"0"`/`"true"`/`"false"`) so `'auto_assert' => env('OPENAPI_AUTO_ASSERT')` works. Unrecognized values fail the test loudly with a clear message, not silently. - Streamed responses (`StreamedResponse`, binary downloads) cause `getContent()` to return `false`, which fails auto-assert with a clear message. If you use `auto_assert=true` on tests that exercise streams, scope the config change per-test or fall back to explicit manual asserts. +#### Opting out with `#[SkipOpenApi]` + +Some tests intentionally return responses that violate the spec (error-injection tests, experimental endpoints with a not-yet-finalized contract, etc.). For these, use the `#[SkipOpenApi]` attribute to opt out of auto-assert without turning the feature off globally: + +```php +use Studio\OpenApiContractTesting\SkipOpenApi; + +class ExperimentalApiTest extends TestCase +{ + use ValidatesOpenApiSchema; + + #[Test] + #[SkipOpenApi(reason: 'endpoint is behind an experimental flag')] + public function test_experimental_endpoint(): void + { + $this->get('/v1/experimental'); // auto-assert is skipped + } +} +``` + +The attribute can also be applied at the class level to skip every method in that class. Method-level attributes take precedence over class-level ones (the `reason` of the method-level attribute wins). + +Notes: + +- `#[SkipOpenApi]` suppresses auto-assert **only**. Explicit calls to `assertResponseMatchesOpenApiSchema()` still run — the assertion is the user's direct intent. +- When auto-assert is skipped, no coverage is recorded for that request (the endpoint is treated as uncovered in the report). +- If a test is marked `#[SkipOpenApi]` and still calls `assertResponseMatchesOpenApiSchema()` explicitly, a `E_USER_DEPRECATED` warning is emitted to flag the contradictory intent. The assertion is not suppressed — fix the cause by removing either the attribute or the explicit call. + ## Coverage Report After running tests, the PHPUnit extension prints a coverage report. The output format is controlled by the `console_output` parameter (or `OPENAPI_CONSOLE_OUTPUT` environment variable). From 277086feebac9e40ff7570d49270773cba447d7a Mon Sep 17 00:00:00 2001 From: wadakatu Date: Thu, 23 Apr 2026 03:00:47 +0900 Subject: [PATCH 4/7] refactor: drop resolveSkipOpenApiReason helper and cache resolved attribute The resolveSkipOpenApiReason() helper returned an empty string both when no #[SkipOpenApi] attribute existed and when one existed with an empty reason, conflating two distinct states. Drop the helper and have callers work directly with the nullable attribute. assertResponseMatchesOpenApiSchema() now resolves the attribute once and passes it to emitSkipOpenApiWarning(), avoiding a duplicate reflection pass on the skip-plus-explicit-call path. maybeAutoAssertOpenApiSchema() uses the same null check to align the two entry points. --- src/Laravel/ValidatesOpenApiSchema.php | 15 ++++++++------- src/SkipOpenApiResolver.php | 9 --------- tests/Unit/SkipOpenApiResolverClassLevelTest.php | 4 ++-- tests/Unit/SkipOpenApiResolverTest.php | 5 +++-- 4 files changed, 13 insertions(+), 20 deletions(-) diff --git a/src/Laravel/ValidatesOpenApiSchema.php b/src/Laravel/ValidatesOpenApiSchema.php index 2755c48..cdfde8e 100644 --- a/src/Laravel/ValidatesOpenApiSchema.php +++ b/src/Laravel/ValidatesOpenApiSchema.php @@ -14,6 +14,7 @@ use Studio\OpenApiContractTesting\OpenApiCoverageTracker; use Studio\OpenApiContractTesting\OpenApiResponseValidator; use Studio\OpenApiContractTesting\OpenApiSpecResolver; +use Studio\OpenApiContractTesting\SkipOpenApi; use Studio\OpenApiContractTesting\SkipOpenApiResolver; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -93,9 +94,8 @@ protected function maybeAutoAssertOpenApiSchema( // #[SkipOpenApi] opts the test out of auto-assert entirely — no // validation, no coverage recording. Explicit calls to - // assertResponseMatchesOpenApiSchema() are not affected here; they - // still run but emit a warning so contradictory intent is visible. - if ($this->shouldSkipOpenApi()) { + // assertResponseMatchesOpenApiSchema() still run but emit a warning. + if ($this->findSkipOpenApiAttribute() !== null) { return; } @@ -123,8 +123,9 @@ protected function assertResponseMatchesOpenApiSchema( ?HttpMethod $method = null, ?string $path = null, ): void { - if ($this->shouldSkipOpenApi()) { - $this->emitSkipOpenApiWarning(); + $skipAttribute = $this->findSkipOpenApiAttribute(); + if ($skipAttribute !== null) { + $this->emitSkipOpenApiWarning($skipAttribute); } $resolvedMethod = $method !== null ? $method->value : app('request')->getMethod(); @@ -215,9 +216,9 @@ private static function getOrCreateValidator(): OpenApiResponseValidator return self::$cachedValidator; } - private function emitSkipOpenApiWarning(): void + private function emitSkipOpenApiWarning(SkipOpenApi $attribute): void { - $reason = $this->resolveSkipOpenApiReason(); + $reason = $attribute->reason; $message = sprintf( '%s::%s is marked #[SkipOpenApi%s] but called assertResponseMatchesOpenApiSchema() explicitly. ' . 'The assertion will run. Remove the attribute or the explicit call to clarify intent.', diff --git a/src/SkipOpenApiResolver.php b/src/SkipOpenApiResolver.php index 9d1ee5f..fabffb8 100644 --- a/src/SkipOpenApiResolver.php +++ b/src/SkipOpenApiResolver.php @@ -14,16 +14,8 @@ private function shouldSkipOpenApi(): bool return $this->findSkipOpenApiAttribute() !== null; } - private function resolveSkipOpenApiReason(): string - { - $attr = $this->findSkipOpenApiAttribute(); - - return $attr === null ? '' : $attr->reason; - } - private function findSkipOpenApiAttribute(): ?SkipOpenApi { - // 1. Method-level #[SkipOpenApi] attribute $methodName = $this->name(); // @phpstan-ignore method.notFound $refMethod = new ReflectionMethod($this, $methodName); $methodAttrs = $refMethod->getAttributes(SkipOpenApi::class); @@ -31,7 +23,6 @@ private function findSkipOpenApiAttribute(): ?SkipOpenApi return $methodAttrs[0]->newInstance(); } - // 2. Class-level #[SkipOpenApi] attribute $refClass = new ReflectionClass($this); $classAttrs = $refClass->getAttributes(SkipOpenApi::class); if ($classAttrs !== []) { diff --git a/tests/Unit/SkipOpenApiResolverClassLevelTest.php b/tests/Unit/SkipOpenApiResolverClassLevelTest.php index 037a765..9b791db 100644 --- a/tests/Unit/SkipOpenApiResolverClassLevelTest.php +++ b/tests/Unit/SkipOpenApiResolverClassLevelTest.php @@ -18,7 +18,7 @@ class SkipOpenApiResolverClassLevelTest extends TestCase public function class_level_attribute_skips(): void { $this->assertTrue($this->shouldSkipOpenApi()); - $this->assertSame('class-level', $this->resolveSkipOpenApiReason()); + $this->assertSame('class-level', $this->findSkipOpenApiAttribute()->reason); } #[Test] @@ -26,6 +26,6 @@ public function class_level_attribute_skips(): void public function method_level_reason_overrides_class_level(): void { $this->assertTrue($this->shouldSkipOpenApi()); - $this->assertSame('method-level', $this->resolveSkipOpenApiReason()); + $this->assertSame('method-level', $this->findSkipOpenApiAttribute()->reason); } } diff --git a/tests/Unit/SkipOpenApiResolverTest.php b/tests/Unit/SkipOpenApiResolverTest.php index 6ba7cc5..c3df8db 100644 --- a/tests/Unit/SkipOpenApiResolverTest.php +++ b/tests/Unit/SkipOpenApiResolverTest.php @@ -17,7 +17,7 @@ class SkipOpenApiResolverTest extends TestCase public function no_attribute_returns_false(): void { $this->assertFalse($this->shouldSkipOpenApi()); - $this->assertSame('', $this->resolveSkipOpenApiReason()); + $this->assertNull($this->findSkipOpenApiAttribute()); } #[Test] @@ -25,6 +25,7 @@ public function no_attribute_returns_false(): void public function method_level_attribute_skips(): void { $this->assertTrue($this->shouldSkipOpenApi()); + $this->assertSame('', $this->findSkipOpenApiAttribute()->reason); } #[Test] @@ -32,6 +33,6 @@ public function method_level_attribute_skips(): void public function method_level_reason_is_resolved(): void { $this->assertTrue($this->shouldSkipOpenApi()); - $this->assertSame('experimental endpoint', $this->resolveSkipOpenApiReason()); + $this->assertSame('experimental endpoint', $this->findSkipOpenApiAttribute()->reason); } } From ce14a7939e40421cb96c8885735cf21b7387fcc3 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Thu, 23 Apr 2026 03:01:23 +0900 Subject: [PATCH 5/7] fix(laravel): write skip warning to STDERR so the message survives phpunit default config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trigger_error(E_USER_DEPRECATED) alone is effectively silent under PHPUnit's default display configuration — only a "1 deprecation" tally appears in the summary, not the actual message body. Operators relying on the run summary never saw why the contradictory-intent warning was raised. Also emit the formatted message to STDERR so CI logs always include the class/method/reason, regardless of whether displayDetailsOnTestsThatTriggerDeprecations is enabled downstream. The trigger_error call is retained so tooling that counts deprecations keeps working. --- src/Laravel/ValidatesOpenApiSchema.php | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/Laravel/ValidatesOpenApiSchema.php b/src/Laravel/ValidatesOpenApiSchema.php index cdfde8e..47f0525 100644 --- a/src/Laravel/ValidatesOpenApiSchema.php +++ b/src/Laravel/ValidatesOpenApiSchema.php @@ -7,6 +7,7 @@ use const E_USER_DEPRECATED; use const FILTER_NULL_ON_FAILURE; use const FILTER_VALIDATE_BOOLEAN; +use const STDERR; use Illuminate\Testing\TestResponse; use JsonException; @@ -21,6 +22,7 @@ use WeakMap; use function filter_var; +use function fwrite; use function get_debug_type; use function is_numeric; use function is_string; @@ -42,11 +44,13 @@ trait ValidatesOpenApiSchema private static ?WeakMap $validatedResponses = null; /** - * Swap the handler that receives "#[SkipOpenApi] + explicit assert" warnings. - * Defaults to emitting an E_USER_DEPRECATED via trigger_error() so PHPUnit - * surfaces it in the run summary without failing the test (option A - * semantics: explicit calls always run; the warning is only a nudge). - * Tests can swap it to capture warnings in-memory. + * Receives the warning emitted when a test marked #[SkipOpenApi] still + * calls assertResponseMatchesOpenApiSchema() explicitly. The explicit + * assertion always runs regardless — this is an advisory nudge that the + * two signals contradict each other. + * + * Defaults to writing to STDERR and emitting an E_USER_DEPRECATED via + * trigger_error(). Tests can swap it to capture warnings in-memory. * * @var null|callable(string): void */ @@ -234,6 +238,13 @@ private function emitSkipOpenApiWarning(SkipOpenApi $attribute): void return; } + // STDERR guarantees the message body is visible in CI regardless of + // PHPUnit's `displayDetailsOnTestsThatTriggerDeprecations` setting — + // without it, the default config would only show a "1 deprecation" + // tally and hide the actual contradictory-intent message. + fwrite(STDERR, sprintf("\n[openapi-contract-testing] %s\n", $message)); + // trigger_error still fires so PHPUnit counts the deprecation and + // surfaces it in the run summary for downstream tools to detect. trigger_error($message, E_USER_DEPRECATED); } From a36734c200c53e0ad4c3a2925efcb1fec2e06aba Mon Sep 17 00:00:00 2001 From: wadakatu Date: Thu, 23 Apr 2026 03:01:33 +0900 Subject: [PATCH 6/7] test(laravel): cover default warning handler, reason format, class-level warning, and POST skip Fills coverage gaps identified during PR review: - New ValidatesOpenApiSchemaDefaultWarningHandlerTest exercises the default trigger_error(E_USER_DEPRECATED) path via set_error_handler so a future refactor cannot silently drop the deprecation emission. - ValidatesOpenApiSchemaSkipTest asserts the warning message contains the var_export-formatted "reason: '...'" segment, locking in the user-facing format. - ValidatesOpenApiSchemaClassLevelSkipTest adds an explicit-assert case that verifies the class-level reason surfaces in the warning and that the handler is reset in tearDown. - AutoAssertIntegrationTest adds a POST skip case through the Testbench integration path so a regression that only consulted skip on GET would be caught. --- .../Laravel/AutoAssertIntegrationTest.php | 15 +++ ...lidatesOpenApiSchemaClassLevelSkipTest.php | 30 ++++++ ...OpenApiSchemaDefaultWarningHandlerTest.php | 93 +++++++++++++++++++ tests/Unit/ValidatesOpenApiSchemaSkipTest.php | 17 ++++ 4 files changed, 155 insertions(+) create mode 100644 tests/Unit/ValidatesOpenApiSchemaDefaultWarningHandlerTest.php diff --git a/tests/Integration/Laravel/AutoAssertIntegrationTest.php b/tests/Integration/Laravel/AutoAssertIntegrationTest.php index 38a1653..7eef78d 100644 --- a/tests/Integration/Laravel/AutoAssertIntegrationTest.php +++ b/tests/Integration/Laravel/AutoAssertIntegrationTest.php @@ -179,6 +179,21 @@ public function skip_open_api_attribute_opts_method_out_of_auto_assert(): void $this->assertArrayNotHasKey('petstore-3.0', OpenApiCoverageTracker::getCovered()); } + #[Test] + #[SkipOpenApi] + public function skip_open_api_attribute_opts_post_out_of_auto_assert(): void + { + // Guard against a regression that only checks skip on GET. The hook + // is verb-agnostic in theory, but a bug that validated POST bodies + // before consulting skip would only be caught here. + config()->set('openapi-contract-testing.auto_assert', true); + + $response = $this->postJson('/v1/pets', ['name' => 'Buddy']); + $response->assertCreated(); + + $this->assertArrayNotHasKey('petstore-3.0', OpenApiCoverageTracker::getCovered()); + } + /** @return array */ protected function getPackageProviders($app): array { diff --git a/tests/Unit/ValidatesOpenApiSchemaClassLevelSkipTest.php b/tests/Unit/ValidatesOpenApiSchemaClassLevelSkipTest.php index 434835d..e3995b8 100644 --- a/tests/Unit/ValidatesOpenApiSchemaClassLevelSkipTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaClassLevelSkipTest.php @@ -25,6 +25,9 @@ class ValidatesOpenApiSchemaClassLevelSkipTest extends TestCase use CreatesTestResponse; use ValidatesOpenApiSchema; + /** @var list */ + private array $capturedWarnings = []; + protected function setUp(): void { parent::setUp(); @@ -35,11 +38,17 @@ protected function setUp(): void 'openapi-contract-testing.default_spec' => 'petstore-3.0', 'openapi-contract-testing.auto_assert' => true, ]; + + $this->capturedWarnings = []; + self::$skipWarningHandler = function (string $message): void { + $this->capturedWarnings[] = $message; + }; } protected function tearDown(): void { self::resetValidatorCache(); + self::$skipWarningHandler = null; unset($GLOBALS['__openapi_testing_config']); OpenApiSpecLoader::reset(); OpenApiCoverageTracker::reset(); @@ -55,5 +64,26 @@ public function class_level_skip_opts_out_of_auto_assert(): void $this->maybeAutoAssertOpenApiSchema($response, HttpMethod::GET, '/v1/pets'); $this->assertArrayNotHasKey('petstore-3.0', OpenApiCoverageTracker::getCovered()); + // Auto-assert skip does not emit a warning (only explicit calls do). + $this->assertSame([], $this->capturedWarnings); + } + + #[Test] + public function class_level_skip_emits_warning_with_class_reason_on_explicit_assert(): 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'); + + // Warning fires with the class-level reason (no method-level attribute overrides it). + $this->assertCount(1, $this->capturedWarnings); + $this->assertStringContainsString("reason: 'entire class is experimental'", $this->capturedWarnings[0]); + + // Explicit assert still ran — validation + coverage recorded. + $this->assertArrayHasKey('petstore-3.0', OpenApiCoverageTracker::getCovered()); } } diff --git a/tests/Unit/ValidatesOpenApiSchemaDefaultWarningHandlerTest.php b/tests/Unit/ValidatesOpenApiSchemaDefaultWarningHandlerTest.php new file mode 100644 index 0000000..0d4fe34 --- /dev/null +++ b/tests/Unit/ValidatesOpenApiSchemaDefaultWarningHandlerTest.php @@ -0,0 +1,93 @@ + 'petstore-3.0', + ]; + // Intentionally do NOT set $skipWarningHandler — exercise the default. + } + + protected function tearDown(): void + { + self::resetValidatorCache(); + unset($GLOBALS['__openapi_testing_config']); + OpenApiSpecLoader::reset(); + OpenApiCoverageTracker::reset(); + parent::tearDown(); + } + + #[Test] + #[SkipOpenApi(reason: 'testing default path')] + public function default_handler_emits_e_user_deprecated(): void + { + $captured = null; + set_error_handler( + static function (int $errno, string $msg) use (&$captured): bool { + $captured = ['errno' => $errno, 'msg' => $msg]; + + // Returning true suppresses the default PHP error handler so + // PHPUnit's own error converter doesn't turn the deprecation + // into a test failure. STDERR output still fires before this + // handler is invoked, which is fine — we only assert on the + // captured values. + return true; + }, + E_USER_DEPRECATED, + ); + + try { + $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'); + } finally { + restore_error_handler(); + } + + $this->assertNotNull($captured, 'Expected trigger_error to fire E_USER_DEPRECATED'); + $this->assertSame(E_USER_DEPRECATED, $captured['errno']); + $this->assertStringContainsString('#[SkipOpenApi', $captured['msg']); + $this->assertStringContainsString("reason: 'testing default path'", $captured['msg']); + } +} diff --git a/tests/Unit/ValidatesOpenApiSchemaSkipTest.php b/tests/Unit/ValidatesOpenApiSchemaSkipTest.php index 39a8bf3..a601e9c 100644 --- a/tests/Unit/ValidatesOpenApiSchemaSkipTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaSkipTest.php @@ -118,4 +118,21 @@ public function non_skipped_explicit_assert_does_not_emit_warning(): void $this->assertSame([], $this->capturedWarnings); } + + #[Test] + #[SkipOpenApi(reason: 'intentional violation test')] + public function warning_message_includes_reason_when_provided(): 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'); + + $this->assertCount(1, $this->capturedWarnings); + // Formatted via var_export so the reason value is quoted verbatim. + $this->assertStringContainsString("(reason: 'intentional violation test')", $this->capturedWarnings[0]); + } } From dbffbf5c25ed4340e7f1075409270cb473c97a92 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Thu, 23 Apr 2026 03:01:40 +0900 Subject: [PATCH 7/7] docs: correct SkipOpenApi precedence wording and coverage claim - Clarify that a method-level #[SkipOpenApi] fully shadows the class- level attribute. The prior wording ("the reason of the method-level attribute wins") implied only the reason field was overridden, but in reality the method-level attribute replaces the class-level one entirely during resolution. - Document that coverage is recorded when a #[SkipOpenApi] test still calls assertResponseMatchesOpenApiSchema() explicitly. The previous wording implied the skip attribute always suppressed coverage. - Note that class-level attributes on abstract parents are not inherited by subclasses, since resolution uses ReflectionClass on the direct class only. --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2d7b7c9..bec7ac1 100644 --- a/README.md +++ b/README.md @@ -284,13 +284,14 @@ class ExperimentalApiTest extends TestCase } ``` -The attribute can also be applied at the class level to skip every method in that class. Method-level attributes take precedence over class-level ones (the `reason` of the method-level attribute wins). +The attribute can also be applied at the class level to skip every method in that class. A method-level `#[SkipOpenApi]` fully shadows the class-level one — only the method-level attribute (and its `reason`) is inspected. Notes: -- `#[SkipOpenApi]` suppresses auto-assert **only**. Explicit calls to `assertResponseMatchesOpenApiSchema()` still run — the assertion is the user's direct intent. -- When auto-assert is skipped, no coverage is recorded for that request (the endpoint is treated as uncovered in the report). -- If a test is marked `#[SkipOpenApi]` and still calls `assertResponseMatchesOpenApiSchema()` explicitly, a `E_USER_DEPRECATED` warning is emitted to flag the contradictory intent. The assertion is not suppressed — fix the cause by removing either the attribute or the explicit call. +- `#[SkipOpenApi]` suppresses **auto-assert only**. Explicit calls to `assertResponseMatchesOpenApiSchema()` still run — the assertion is the user's direct intent. +- When auto-assert is skipped and no explicit assertion is made, no coverage is recorded for that request (the endpoint is treated as uncovered in the report). If you call `assertResponseMatchesOpenApiSchema()` explicitly on a skipped test, validation runs and coverage is recorded as usual. +- If a test is marked `#[SkipOpenApi]` and still calls `assertResponseMatchesOpenApiSchema()` explicitly, an advisory warning is written to `STDERR` and a user deprecation is raised to flag the contradictory intent. The assertion is not suppressed — fix the cause by removing either the attribute or the explicit call. +- The attribute is resolved via reflection on the direct class only; a class-level `#[SkipOpenApi]` on an abstract parent is **not** inherited by subclasses. Apply the attribute on each concrete test class (or per method) instead. ## Coverage Report