Skip to content
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,35 @@ 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. 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 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

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).
Expand Down
60 changes: 60 additions & 0 deletions src/Laravel/ValidatesOpenApiSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,58 @@

namespace Studio\OpenApiContractTesting\Laravel;

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;
use Studio\OpenApiContractTesting\HttpMethod;
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;
use WeakMap;

use function filter_var;
use function fwrite;
use function get_debug_type;
use function is_numeric;
use function is_string;
use function sprintf;
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<TestResponse, array<string, true>> */
private static ?WeakMap $validatedResponses = null;

/**
* 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
*/
private static $skipWarningHandler;

public static function resetValidatorCache(): void
{
self::$cachedValidator = null;
Expand Down Expand Up @@ -76,6 +96,13 @@ protected function maybeAutoAssertOpenApiSchema(
return;
}

// #[SkipOpenApi] opts the test out of auto-assert entirely — no
// validation, no coverage recording. Explicit calls to
// assertResponseMatchesOpenApiSchema() still run but emit a warning.
if ($this->findSkipOpenApiAttribute() !== null) {
return;
}

$this->assertResponseMatchesOpenApiSchema($response, $method, $path);
}

Expand All @@ -100,6 +127,11 @@ protected function assertResponseMatchesOpenApiSchema(
?HttpMethod $method = null,
?string $path = null,
): void {
$skipAttribute = $this->findSkipOpenApiAttribute();
if ($skipAttribute !== null) {
$this->emitSkipOpenApiWarning($skipAttribute);
}

$resolvedMethod = $method !== null ? $method->value : app('request')->getMethod();
$resolvedPath = $path ?? app('request')->getPathInfo();

Expand Down Expand Up @@ -188,6 +220,34 @@ private static function getOrCreateValidator(): OpenApiResponseValidator
return self::$cachedValidator;
}

private function emitSkipOpenApiWarning(SkipOpenApi $attribute): void
{
$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.',
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;
}

// 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);
}

private function isAutoAssertEnabled(): bool
{
$raw = config('openapi-contract-testing.auto_assert', false);
Expand Down
15 changes: 15 additions & 0 deletions src/SkipOpenApi.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Studio\OpenApiContractTesting;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
final class SkipOpenApi
{
public function __construct(
public readonly string $reason = '',
) {}
}
34 changes: 34 additions & 0 deletions src/SkipOpenApiResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Studio\OpenApiContractTesting;

use ReflectionClass;
use ReflectionMethod;

trait SkipOpenApiResolver
{
private function shouldSkipOpenApi(): bool
{
return $this->findSkipOpenApiAttribute() !== null;
}

private function findSkipOpenApiAttribute(): ?SkipOpenApi
{
$methodName = $this->name(); // @phpstan-ignore method.notFound
$refMethod = new ReflectionMethod($this, $methodName);
$methodAttrs = $refMethod->getAttributes(SkipOpenApi::class);
if ($methodAttrs !== []) {
return $methodAttrs[0]->newInstance();
}

$refClass = new ReflectionClass($this);
$classAttrs = $refClass->getAttributes(SkipOpenApi::class);
if ($classAttrs !== []) {
return $classAttrs[0]->newInstance();
}

return null;
}
}
31 changes: 31 additions & 0 deletions tests/Integration/Laravel/AutoAssertIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Studio\OpenApiContractTesting\OpenApiCoverageTracker;
use Studio\OpenApiContractTesting\OpenApiSpec;
use Studio\OpenApiContractTesting\OpenApiSpecLoader;
use Studio\OpenApiContractTesting\SkipOpenApi;

use function dirname;

Expand Down Expand Up @@ -163,6 +164,36 @@ 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());
}

#[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<int, class-string> */
protected function getPackageProviders($app): array
{
Expand Down
31 changes: 31 additions & 0 deletions tests/Unit/SkipOpenApiResolverClassLevelTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Studio\OpenApiContractTesting\Tests\Unit;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Studio\OpenApiContractTesting\SkipOpenApi;
use Studio\OpenApiContractTesting\SkipOpenApiResolver;

#[SkipOpenApi(reason: 'class-level')]
class SkipOpenApiResolverClassLevelTest extends TestCase
{
use SkipOpenApiResolver;

#[Test]
public function class_level_attribute_skips(): void
{
$this->assertTrue($this->shouldSkipOpenApi());
$this->assertSame('class-level', $this->findSkipOpenApiAttribute()->reason);
}

#[Test]
#[SkipOpenApi(reason: 'method-level')]
public function method_level_reason_overrides_class_level(): void
{
$this->assertTrue($this->shouldSkipOpenApi());
$this->assertSame('method-level', $this->findSkipOpenApiAttribute()->reason);
}
}
38 changes: 38 additions & 0 deletions tests/Unit/SkipOpenApiResolverTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Studio\OpenApiContractTesting\Tests\Unit;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Studio\OpenApiContractTesting\SkipOpenApi;
use Studio\OpenApiContractTesting\SkipOpenApiResolver;

class SkipOpenApiResolverTest extends TestCase
{
use SkipOpenApiResolver;

#[Test]
public function no_attribute_returns_false(): void
{
$this->assertFalse($this->shouldSkipOpenApi());
$this->assertNull($this->findSkipOpenApiAttribute());
}

#[Test]
#[SkipOpenApi]
public function method_level_attribute_skips(): void
{
$this->assertTrue($this->shouldSkipOpenApi());
$this->assertSame('', $this->findSkipOpenApiAttribute()->reason);
}

#[Test]
#[SkipOpenApi(reason: 'experimental endpoint')]
public function method_level_reason_is_resolved(): void
{
$this->assertTrue($this->shouldSkipOpenApi());
$this->assertSame('experimental endpoint', $this->findSkipOpenApiAttribute()->reason);
}
}
Loading