Skip to content

Commit b19816b

Browse files
authored
Merge pull request #54 from studio-design/feature/auto-assert-test-response
New Feature: ValidatesOpenApiSchema trait に auto_assert オプションを追加し、HTTP レスポンスを自動検証
2 parents e0088f0 + a0b867f commit b19816b

7 files changed

Lines changed: 600 additions & 5 deletions

File tree

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ return [
8585
// Maximum number of validation errors to report per response.
8686
// 0 = unlimited (reports all errors).
8787
'max_errors' => 20,
88+
89+
// Automatically validate every TestResponse produced by Laravel HTTP
90+
// helpers (get(), post(), etc.) against the OpenAPI spec. Defaults to
91+
// false for backward compatibility.
92+
'auto_assert' => false,
8893
];
8994
```
9095

@@ -223,6 +228,42 @@ $validator = new OpenApiResponseValidator(maxErrors: 1);
223228

224229
For Laravel, set the `max_errors` key in `config/openapi-contract-testing.php`.
225230

231+
#### Auto-assert every response
232+
233+
Forgetting `$this->assertResponseMatchesOpenApiSchema($response)` in a test means the contract is silently unchecked. Enable `auto_assert` to validate every response produced by Laravel's HTTP helpers automatically — just include the trait:
234+
235+
```php
236+
// config/openapi-contract-testing.php
237+
return [
238+
'default_spec' => 'front',
239+
'auto_assert' => true,
240+
];
241+
```
242+
243+
```php
244+
use Studio\OpenApiContractTesting\Laravel\ValidatesOpenApiSchema;
245+
246+
class GetPetsTest extends TestCase
247+
{
248+
use ValidatesOpenApiSchema;
249+
250+
public function test_list_pets(): void
251+
{
252+
// Contract is checked automatically — no explicit assert call needed.
253+
$this->get('/api/v1/pets')->assertOk();
254+
}
255+
}
256+
```
257+
258+
Notes:
259+
260+
- Defaults to `false` so existing test suites keep their explicit-assert behavior.
261+
- Auto-assert hooks into `MakesHttpRequests::createTestResponse()`. Responses you construct manually (outside `$this->get()`, `$this->post()`, etc.) are not touched.
262+
- Idempotency is keyed on the `(spec, method, path)` tuple. Calling `assertResponseMatchesOpenApiSchema($response)` after auto-assert with the matching signature is a no-op. Calling it with a different `method`/`path` — or a different `#[OpenApiSpec]` — runs validation again.
263+
- When auto-assert fails, the exception is thrown from inside `$this->get(...)`, so any chained assertion on the same line (`$this->get(...)->assertOk()`) will not run. This is usually what you want — the schema failure takes precedence over status-code checks.
264+
- `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.
265+
- 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.
266+
226267
## Coverage Report
227268

228269
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).

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
},
1212
"require-dev": {
1313
"friendsofphp/php-cs-fixer": "^3.94",
14-
"illuminate/testing": "^11.0 || ^12.0",
14+
"orchestra/testbench": "^9.0 || ^10.0 || ^11.0",
1515
"phpstan/phpstan": "^2.0",
1616
"symfony/http-foundation": "^6.4 || ^7.0 || ^8.0"
1717
},

src/Laravel/ValidatesOpenApiSchema.php

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,79 @@
44

55
namespace Studio\OpenApiContractTesting\Laravel;
66

7+
use const FILTER_NULL_ON_FAILURE;
8+
use const FILTER_VALIDATE_BOOLEAN;
9+
710
use Illuminate\Testing\TestResponse;
811
use JsonException;
912
use Studio\OpenApiContractTesting\HttpMethod;
1013
use Studio\OpenApiContractTesting\OpenApiCoverageTracker;
1114
use Studio\OpenApiContractTesting\OpenApiResponseValidator;
1215
use Studio\OpenApiContractTesting\OpenApiSpecResolver;
16+
use Symfony\Component\HttpFoundation\Request;
17+
use Symfony\Component\HttpFoundation\Response;
18+
use WeakMap;
1319

20+
use function filter_var;
21+
use function get_debug_type;
1422
use function is_numeric;
1523
use function is_string;
24+
use function sprintf;
1625
use function str_contains;
1726
use function strtolower;
27+
use function strtoupper;
28+
use function var_export;
1829

1930
trait ValidatesOpenApiSchema
2031
{
2132
use OpenApiSpecResolver;
2233
private static ?OpenApiResponseValidator $cachedValidator = null;
2334
private static ?int $cachedMaxErrors = null;
2435

36+
/** @var null|WeakMap<TestResponse, array<string, true>> */
37+
private static ?WeakMap $validatedResponses = null;
38+
2539
public static function resetValidatorCache(): void
2640
{
2741
self::$cachedValidator = null;
2842
self::$cachedMaxErrors = null;
43+
self::$validatedResponses = null;
44+
}
45+
46+
/**
47+
* Overrides Laravel's MakesHttpRequests::createTestResponse hook so every
48+
* HTTP test call runs schema validation when auto_assert is enabled.
49+
* When the library is used outside Laravel, this method is never called.
50+
*
51+
* Method and path are resolved from the Request passed in by Laravel
52+
* rather than from app('request'), so auto-assert stays independent of
53+
* container state and sees the exact values the framework dispatched.
54+
*
55+
* @param Response $response
56+
* @param null|Request $request
57+
*/
58+
protected function createTestResponse($response, $request = null): TestResponse
59+
{
60+
$testResponse = parent::createTestResponse($response, $request);
61+
62+
$method = $request !== null ? HttpMethod::tryFrom(strtoupper($request->getMethod())) : null;
63+
$path = $request?->getPathInfo();
64+
65+
$this->maybeAutoAssertOpenApiSchema($testResponse, $method, $path);
66+
67+
return $testResponse;
68+
}
69+
70+
protected function maybeAutoAssertOpenApiSchema(
71+
TestResponse $response,
72+
?HttpMethod $method = null,
73+
?string $path = null,
74+
): void {
75+
if (!$this->isAutoAssertEnabled()) {
76+
return;
77+
}
78+
79+
$this->assertResponseMatchesOpenApiSchema($response, $method, $path);
2980
}
3081

3182
protected function openApiSpec(): string
@@ -49,6 +100,9 @@ protected function assertResponseMatchesOpenApiSchema(
49100
?HttpMethod $method = null,
50101
?string $path = null,
51102
): void {
103+
$resolvedMethod = $method !== null ? $method->value : app('request')->getMethod();
104+
$resolvedPath = $path ?? app('request')->getPathInfo();
105+
52106
$specName = $this->resolveOpenApiSpec();
53107
if ($specName === '') {
54108
$this->fail(
@@ -59,8 +113,16 @@ protected function assertResponseMatchesOpenApiSchema(
59113
);
60114
}
61115

62-
$resolvedMethod = $method !== null ? $method->value : app('request')->getMethod();
63-
$resolvedPath = $path ?? app('request')->getPathInfo();
116+
// Idempotency key includes the spec so that validating the same
117+
// response against a different spec (or a different method/path on
118+
// the same spec) still runs — auto-assert's no-op only applies to
119+
// exact repeats.
120+
$signature = $specName . ':' . $resolvedMethod . ' ' . $resolvedPath;
121+
122+
if (self::isAlreadyValidated($response, $signature)) {
123+
return;
124+
}
125+
self::markValidated($response, $signature);
64126

65127
$content = $response->getContent();
66128
if ($content === false) {
@@ -82,6 +144,8 @@ protected function assertResponseMatchesOpenApiSchema(
82144
// Record coverage for any matched endpoint, including those where body
83145
// validation was skipped (e.g. non-JSON content types). "Covered" means
84146
// the endpoint was exercised in a test, not that its body was validated.
147+
// Note: under auto_assert, this records coverage for every Laravel HTTP
148+
// call — including responses with no explicit contract-test intent.
85149
if ($result->matchedPath() !== null) {
86150
OpenApiCoverageTracker::record(
87151
$specName,
@@ -97,6 +161,20 @@ protected function assertResponseMatchesOpenApiSchema(
97161
);
98162
}
99163

164+
private static function isAlreadyValidated(TestResponse $response, string $signature): bool
165+
{
166+
return self::$validatedResponses !== null &&
167+
isset(self::$validatedResponses[$response][$signature]);
168+
}
169+
170+
private static function markValidated(TestResponse $response, string $signature): void
171+
{
172+
self::$validatedResponses ??= new WeakMap();
173+
$signatures = self::$validatedResponses[$response] ?? [];
174+
$signatures[$signature] = true;
175+
self::$validatedResponses[$response] = $signatures;
176+
}
177+
100178
private static function getOrCreateValidator(): OpenApiResponseValidator
101179
{
102180
$maxErrors = config('openapi-contract-testing.max_errors', 20);
@@ -110,6 +188,30 @@ private static function getOrCreateValidator(): OpenApiResponseValidator
110188
return self::$cachedValidator;
111189
}
112190

191+
private function isAutoAssertEnabled(): bool
192+
{
193+
$raw = config('openapi-contract-testing.auto_assert', false);
194+
195+
if ($raw === true) {
196+
return true;
197+
}
198+
if ($raw === false || $raw === null) {
199+
return false;
200+
}
201+
202+
$parsed = filter_var($raw, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
203+
if ($parsed === null) {
204+
$this->fail(sprintf(
205+
'openapi-contract-testing.auto_assert must be a boolean (or a boolean-compatible value '
206+
. 'like "true"/"false"/"1"/"0"), got %s: %s.',
207+
get_debug_type($raw),
208+
var_export($raw, true),
209+
));
210+
}
211+
212+
return $parsed;
213+
}
214+
113215
/** @return null|array<string, mixed> */
114216
private function extractJsonBody(TestResponse $response, string $content, string $contentType): ?array
115217
{

src/Laravel/config.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,10 @@
88
// Maximum number of validation errors to report per response.
99
// 0 = unlimited (reports all errors).
1010
'max_errors' => 20,
11+
12+
// When true, every TestResponse produced by Laravel HTTP test helpers
13+
// (get(), post(), etc.) is validated against the OpenAPI spec at creation
14+
// time, without requiring an explicit assertResponseMatchesOpenApiSchema()
15+
// call in each test. Defaults to false for backward compatibility.
16+
'auto_assert' => false,
1117
];

tests/Helpers/LaravelConfigMock.php

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
namespace Studio\OpenApiContractTesting\Laravel;
66

7+
use Illuminate\Container\Container;
8+
9+
use function array_key_exists;
10+
use function class_exists;
11+
712
/**
813
* Namespace-level config() mock for unit testing.
914
*
@@ -21,9 +26,34 @@
2126
* in ValidatesOpenApiSchema.php (i.e., no "use function config" import).
2227
* Adding such an import would bypass namespace resolution and break this mock.
2328
*
24-
* Test values are read from $GLOBALS['__openapi_testing_config'].
29+
* Resolution order:
30+
* 1. A unit-test override in $GLOBALS['__openapi_testing_config'] wins — unit
31+
* tests set this explicitly to control what the trait sees.
32+
* 2. When a Laravel container is bootstrapped and has a "config" binding (i.e.
33+
* integration tests using orchestra/testbench), delegate to the framework's
34+
* config() helper via a variable function. The variable call avoids both
35+
* PHP namespace resolution (which would recurse into this mock) and any
36+
* future auto-import of the global \config() by cs-fixer's
37+
* global_namespace_import rule (which would add a "use function config;"
38+
* and re-break this mock for the same reason as a manual import).
39+
* 3. Otherwise (pure unit tests with no booted app), return the provided
40+
* default. The container-bound check avoids swallowing real binding errors:
41+
* only an unbootstrapped container falls through here.
2542
*/
2643
function config(string $key, mixed $default = null): mixed
2744
{
28-
return $GLOBALS['__openapi_testing_config'][$key] ?? $default;
45+
if (
46+
isset($GLOBALS['__openapi_testing_config']) &&
47+
array_key_exists($key, $GLOBALS['__openapi_testing_config'])
48+
) {
49+
return $GLOBALS['__openapi_testing_config'][$key];
50+
}
51+
52+
if (class_exists(Container::class) && Container::getInstance()->bound('config')) {
53+
$globalConfig = 'config';
54+
55+
return $globalConfig($key, $default);
56+
}
57+
58+
return $default;
2959
}

0 commit comments

Comments
 (0)