Test stub scaffolding
gesso stubs writes test skeletons for the responses your suite does not exercise yet. Point it at a spec and a coverage document and it generates one test class per operation, one test method per uncovered (status, content-type) tuple — the same granularity the coverage report measures.
vendor/bin/gesso stubs \
--spec=openapi.json \
--coverage=build/coverage.json \
--adapter=laravel[Gesso] Wrote 3 files covering 7 uncovered responses to tests/Feature/Contract:
+ tests/Feature/Contract/DeletePetsPetIdTest.php
+ tests/Feature/Contract/PostPetsTest.php
+ tests/Feature/Contract/PutPetsPetIdTest.php
1 file already exists and was left untouched:
= tests/Feature/Contract/GetPetsTest.phpWithout --coverage the whole spec is scaffolded. With it, a response the document reports as validated is skipped; uncovered and skipped responses both get a stub, because a skipped response is untested too.
Laravel users can run the same thing through Artisan, which takes the spec from gesso.default_spec / gesso.spec_base_path:
php artisan gesso:stubs --coverage=build/coverage.jsonOptions
| Option | Default | Meaning |
|---|---|---|
--spec=<path> | required | OpenAPI document (.json / .yaml / .yml). |
--coverage=<path> | — | Coverage JSON (schema_version 3). Omit to scaffold the whole spec. |
--spec-name=<name> | --spec filename | Key under specs in the coverage document, and the spec name written into the generated tests. |
--adapter=<name> | phpunit | phpunit, laravel, symfony, or pest. |
--output=<dir> | per adapter | Where to write. tests/Contract, or tests/Feature/Contract for laravel and pest. |
--namespace=<ns> | per adapter | Namespace for the generated classes. Ignored by pest. |
--base-class=<fqcn> | per adapter | Test class to extend — PHPUnit\Framework\TestCase, or Tests\TestCase for laravel. Ignored by pest. |
--dry-run | off | Report what would be written without writing it. |
Exit code 0 means stubs were written or there was nothing left to stub; 2 means a usage error or an unreadable input.
The Artisan command takes the same options. Its --spec accepts a spec name resolved under gesso.spec_base_path as well as a path.
What ends up in a stub
Each generated test is marked incomplete — markTestIncomplete(), or ->todo() for Pest — so a freshly scaffolded suite reports outstanding work instead of failures. Removing that line is how you say the stub is finished.
Everything the spec pins down is filled in:
Path and query. Path template variables are substituted, and required query parameters are appended. Values come from the parameter's
example, thenschema.default/schema.enum, then a type- and format-shaped placeholder (1for an integer, a zero UUID forformat: uuid, …).TODOis the last resort.Required headers.
in: headerparameters markedrequiredare passed on the request.Bodies. A request body or response
example(or the first entry ofexamples) becomes the literal in the stub. Without one you get[]under a// TODOcomment. The call shape follows the media type: JSON objects go through the JSON helpers,application/x-www-form-urlencodedandmultipart/form-dataare sent as request fields so the form decoders see a field map, and anything else goes out as a raw body. Whichever shape it takes, the declared media type is set explicitly —Request::create()would otherwise default a form request toapplication/x-www-form-urlencoded(and leavePATCHwith noContent-Typeat all), so a multipart operation would never match its ownrequestBody. A multipart stub points atUploadedFilefor the file parts rather than hand-building a boundary."JSON" here means what the validator means by it —
application/jsonor a+jsonsuffix.application/vnd.acme+jsonuses the JSON helpers;application/notjsondoes not. A spec key that is a range (application/*,*/*) is not something a client can put on the wire, so the stub sends a concrete type the range covers.A form body on an
additionalOperationsmethod goes out as raw urlencoded bytes rather than request fields:Request::create()only moves its parameters into the request bag for POST/PUT/PATCH/DELETE/QUERY, and routes anything else into the query bag.multipart/form-datahas no raw-byte form the decoder can parse back. A multipart body on a custom method is therefore only a dead end when it isrequiredand no other media type is declared: an optional body is simply omitted, and an operation that also offers urlencoded is stubbed through that. A body no Content-Type resolves back to at all — atext/*key, which no JSON type selects and no form type covers — is the other dead end. Either way the operation is reported as not stubbable for the Laravel, Pest, and Symfony adapters rather than emitted as a request that would silently validate as skipped. Thephpunitadapter is unaffected by both — it only validates responses and never builds a request, so norequestBodycan cost it an operation.A request media type is resolved the way
RequestBodyValidatordoes, which is not how responses resolve. Its JSON route has no exact-match preference, so only the first JSON key (orapplication/*) is ever selected by a JSON Content-Type. And forms are the one non-JSON family it still checks against a schema, which makesmultipart/form-datathe way to reach amultipart/*range — unreachable through every other route. Where several media types are declared, the one whose schema is actually enforced wins: an operation offering bothapplication/xmlandmultipart/form-datais stubbed through the form, because the XML would validate as Skipped whatever the body is.Status codes. A range key such as
4XX, ordefault, is exercised as a concrete code with a comment saying which one was picked. The code is chosen the way the runtime resolver reads the spec — exact keys win over ranges, and ranges overdefault— so an operation declaring both400and4XXgets a4XXstub sending 401, not one that would silently validate the400schema. A key no status can reach (a4XXalongside all 100 exact 4xx codes) is reported rather than stubbed, because no test could ever cover it. A key that is not a status, a range, ordefaultis reported as malformed and does not affect its operation's valid keys — rungesso doctorfor those.Specification extensions. An
x-key on a Responses Object is not a response and gets no stub.Content negotiation. Each response media type gets its own
Acceptheader, so two media types declared under one status do not both resolve to the same response. A range key (application/*) is exercised as a concrete type it covers — sending the range itself would make the validator read the body as non-JSON and skip the schema, so a violating body would pass. The declared key still names the test and the coverage tuple it closes. Which substitute works depends on the key: the JSON resolver takes an exact match first and otherwise the first JSON entry, so a range declared alongside a literal JSON key is unreachable through a JSON Content-Type. A non-JSON one reaches it — the general matcher matches<type>/*in its second pass — but only pays off when the range declares noschema, since a non-JSON type that lands on one is skipped as a contract this engine cannot evaluate. So a schema-less range is stubbed with a concrete non-JSON type, and a range carrying a schema next to a JSON sibling is reported rather than stubbed. The substitute is searched for rather than guessed, on both halves of the media type: a spec is free to declare the invented type the stub would have reached for, and*/*is only matched after every<type>/*sibling, so a document ranging overapplication/*andtext/*gets something likeimage/…for its full wildcard.Bodies only a skip can reach. OpenAPI 3.2
itemSchemastreaming cannot be checked from a buffered body, and a non-JSON media type carrying aschemais a contract this JSON Schema engine does not evaluate. Both still get a stub — it exercises the endpoint and moves the tuple offuncovered— but with aTODOsaying the assertion can only ever pass as Skipped, because the generatedisValid()is satisfied by a skip and the coverage document will keep reporting the tuple as skipped rather than validated. This applies to the request body as well as the response, except for thephpunitadapter, which never sends a body.Responses without
content. These become a single "no content" test, the same tuple the coverage tracker records for a 204.
#[OpenApiSpec('petstore')]
final class PostPetsTest extends TestCase
{
use ValidatesOpenApiSchema;
public function test_post_pets_201_application_json(): void
{
$this->markTestIncomplete('Exercise POST /pets returns 201 application/json.');
// TODO: adjust the payload your application expects.
$payload = [
'name' => 'Fido',
];
$response = $this->postJson(
'/pets',
$payload,
[
'Accept' => 'application/json',
],
);
$response->assertStatus(201);
$this->assertResponseMatchesOpenApiSchema($response);
}
}Each adapter follows its own quickstart idiom, so generated code reads like the documented usage rather than a dialect of its own. A request body that Laravel's JSON helpers cannot carry — a scalar, or a non-JSON media type — is sent through call() with an explicit Content-Type instead of postJson().
Pest stubs default into tests/Feature/Contract because they generate Laravel HTTP calls: they need the uses(TestCase::class, ValidatesOpenApiSchema::class)->in('Feature') binding from the Pest guide to have a harness once ->todo() comes off. Point --output elsewhere only if your uses(...) reaches there.
Re-running it
The command never overwrites a file. Once you have edited PostPetsTest.php, a later run reports it as untouched and writes only the operations that have appeared since. Output is deterministic — operations are ordered by METHOD path, responses by status then content type — so two runs on the same inputs produce byte-identical files.
That makes the loop straightforward: run your suite, regenerate, fill in the next batch.
vendor/bin/phpunit # writes build/coverage.json
vendor/bin/gesso stubs --spec=openapi.json --coverage=build/coverage.jsonClass names come from the method and path — GET /pets/{petId} becomes GetPetsPetIdTest — not from operationId, so a document that reuses an operationId cannot collide two operations into one file. Where two paths do normalise to the same name (/foo-bar and /foo/bar), each gets a suffix derived from its own endpoint, so neither is dropped and neither name shifts when an unrelated operation joins the spec. Collisions are resolved against every operation the spec declares, not just the uncovered ones, so a name stays put once the other side of a collision goes green.
--spec is loaded through the runtime loader, which resolves a name and searches .json before .yaml before .yml. Passing --spec=openapi.yaml next to an openapi.json fails rather than silently stubbing the JSON — the same shadowing check gesso doctor makes.
Scope
Only the methods the coverage tracker records are stubbed: GET, POST, PUT, PATCH, DELETE, QUERY, and OpenAPI 3.2 additionalOperations. OPTIONS, HEAD, and TRACE never appear in a coverage document, so a stub for one could never turn a tuple green.
Generated tests are yours to edit; they are ordinary test files with no runtime dependency on the generator.