diff --git a/api-only-transcriberj/README.adoc b/api-only-transcriberj/README.adoc index f4ff7b0..184eb0d 100644 --- a/api-only-transcriberj/README.adoc +++ b/api-only-transcriberj/README.adoc @@ -616,6 +616,7 @@ It does not prove that the payload is valid against the schema, and nothing here Constraints are constants, not checks. Generated bodies are built from the arguments given, and a body built from arguments that violate a constraint violates it too. Invalid and partial bodies, for the tests of 4xx responses, are written by hand. +link:../docs/guides/hand-written-contract-tests/overview.adoc[The guide to hand-written contract tests →] says how to write them, and anything else the generator could not, on top of the generated classes -- and how many to write. [#development] == Development diff --git a/docs/README.adoc b/docs/README.adoc index 8ac0e67..4149714 100644 --- a/docs/README.adoc +++ b/docs/README.adoc @@ -199,6 +199,13 @@ A guide covers one feature across the tools, rather than one way of adopting the link:guides/description-bundles/tutorial.adoc[Tutorial →] + link:guides/description-bundles/prompt-claude-code.md[Claude Code prompt →] + link:guides/description-bundles/prompt-codex.md[Codex prompt →] + +|**Contract tests the generator cannot write, on top of the classes it does** +|What to do when a generated method throws, how to write that part by hand from the generated pieces so that it still breaks when the contract moves, and why a contract test needs one example per error response rather than one per invalid input. +|link:guides/hand-written-contract-tests/overview.adoc[Overview →] + +link:guides/hand-written-contract-tests/tutorial.adoc[Tutorial →] + +link:guides/hand-written-contract-tests/prompt-claude-code.md[Claude Code prompt →] + +link:guides/hand-written-contract-tests/prompt-codex.md[Codex prompt →] |=== [#reference] diff --git a/docs/guides/hand-written-contract-tests/overview.adoc b/docs/guides/hand-written-contract-tests/overview.adoc new file mode 100644 index 0000000..9a4f14c --- /dev/null +++ b/docs/guides/hand-written-contract-tests/overview.adoc @@ -0,0 +1,140 @@ += Guide: contract tests the generator cannot write, on top of the classes it does +:toc: left +:toc-title: Contents +:toclevels: 2 +:icons: font +:source-highlighter: rouge + +[NOTE] +.Where the links in this document go +==== +Every link ends with a marker saying where it leads, so you can tell before you follow it: + +[horizontal] +`↗`:: An *external* site, outside this repository. +`→`:: *Another file* in this repository. +`§`:: *A section of this same file.* +==== + +[#in-short] +== In short + +The API-Only TranscriberJ generates, from a contract, the classes a contract test is built from: an operation's path and status codes, a body's constants and constraints, and the fields a response should have. +It does not write the tests, and it cannot generate everything: some constructs yield a method that throws, with the reason. + +This guide is about the tests you write on top of those classes, and in particular the two questions a team meets early: + +* **"The generator refused to write this method -- now what?"** +Almost everything else about the class is still there. +Write the one missing part by hand, from those pieces, in the generated package, and guard it so that it breaks when the contract moves. +* **"How many invalid-input tests should I write?"** +One per error shape the contract describes -- not one per way to be wrong. +<> says why. + +[#where-it-stops] +== Where the generated tree stops + +Knowing the boundary is what makes it workable; discovering it by surprise makes the tool look broken. + +* **Constraints are constants, not checks.** +`AmountV1.MAXIMUM` is there to build a test from; a generated `body(...)` writes whatever it is given, so a body built from arguments that break a constraint breaks it too. +* **Field descriptions are a projection.** +A field is a path, a type, whether it is optional, and a description. +A test that checks a response against them proves the response has the fields the contract describes, of those types -- not that it is valid against the schema. +* **Some constructs degrade.** +A construct no rule represents yet, such as a `oneOf` with a branch written inline, turns the methods it reaches into ones that throw `UnsupportedOperationException`, with the reason and, where there is one, the remedy. +The build never stops over it; the generation report lists every such method. + +link:../../../api-only-transcriberj/README.adoc#limits[What this cannot do →] and link:../../../api-only-transcriberj/README.adoc#unrepresentable[Constructs no rule represents yet →], in the TranscriberJ's README, are the reference. + +[#what-is-left] +== What you still have when a method refuses + +A degraded `body(...)` does not mean an empty class. +When the request body cannot be generated: + +* the body's class still has its provenance -- the fragment it came from and that fragment's hash -- and its description; +* the operation's class still has the path, the method, the request's content type, and every status code and content type it answers with; +* the components the body is built from still have their constraints: a minimum, a maximum, a pattern; +* the *responses* are usually generated in full, error responses included, with their REST Docs descriptors. + +A request body written by hand from those pieces is still derived from the contract in every part except the one the contract could not express. +And because it uses them, it still breaks when the contract moves -- which is the property worth protecting. + +[#where-it-goes] +== Where hand-written code goes: a design decision + +[IMPORTANT] +.Hand-written test code goes in the generated package +==== +Code that builds on the generated classes goes in the subscription's `basePackage`, in the test source set: `src/test/java//`. + +That is deliberate. +The generated tree makes public only what callers outside its package need; everything else is package-private, including `ContractJson` -- the JSON writer every generated body uses -- and the scalar components that carry constraints such as a maximum or a pattern. +Hand-written code in the same package reaches all of it, writes JSON the way the generated bodies do, and sits next to what it draws on. + +Those package-private members are *not* a published API: they are regenerated on every build and may change when the TranscriberJ is upgraded, in which case the hand-written code that uses them no longer compiles and has to be adjusted. +==== + +link:../../../api-only-transcriberj/README.adoc#hand-written-code[Hand-written code beside the generated classes →], in the TranscriberJ's README, is the reference. + +[#responsibilities] +== What you own when you write it + +Some of what a contract says is generated nowhere, and hand-written code that needs it types it: + +* the members of a schema whose body could not be generated -- their names, which are required, how they nest; +* `const` values, such as the `destination` that says which branch of a `oneOf` a body is; +* constraints written inline inside an inline branch; only components a branch refers to carry theirs. + +Whatever you type, the project -- not the generator -- is responsible for keeping in line with the contract, and nothing checks it for you: the generator verifies the generated tree, not your code. +That comes with four duties: + +. **Draw on the generated constants wherever they hold the value**, so a change to them breaks the build at the line that used them. +. **Pin each hand-written part to the hash of the fragment it was written against**, in a test; it is the only thing that fails when a member you typed is renamed. +When it fails, check your code against the contract, then update the pinned hash. +. **Assert that the degraded method still throws**, and delete your replacement when it no longer does. +. **Write only what is missing**, and prefer taking the report's remedy to writing anything at all. + +link:../../../api-only-transcriberj/README.adoc#your-responsibilities[What the generated classes do not hold →] is the reference. + +[#how-many] +== How many tests, and where + +**A contract test checks the shape of a response, not the behaviour that produced it.** + +A contract promises a client what it will get back: this status, this content type, these fields. +A contract test holds the service to that promise. +One invalid username is enough to see that a 400 comes back as the contract's problem document, with the fields the contract lists. +A second invalid username tests nothing new about the contract: the answer has the same shape. + +*Which* usernames are rejected, and with which message, is behaviour -- the service's rules, not the contract's shape. +It belongs in the tests of the service's behaviour, as scenarios: each rule, each way to break it, and what the service does. +Written as contract tests instead, those scenarios become a long list of near-identical HTTP calls, each re-checking the same response shape, slow to run and brittle when a message is reworded -- and still worse at stating the rules than a scenario is. + +So: + +* **one contract test per response the contract lists**: 201, 400, 404 and 422 are four tests, whatever number of ways there are to get each; +* **one behaviour test per rule**, or per scenario, where all the ways to be invalid live -- with the service, not the HTTP contract, as the subject. + +A 422 whose trigger is a business rule the schema cannot carry is the clearest case. +Its contract test needs one request that breaks the rule; the rule itself, with its edge cases, is behaviour. + +[#adoption] +== Adoption in broad strokes + +. **Read the report.** `build/reports/transcriberj/.txt` lists every method that throws, and why. +. **Fix the contract where you can.** Most degradations have a remedy -- name the branches of a `oneOf`, make an inline schema a component -- and the report gives it. +A contract that can be generated is better for every consumer, not just your tests. +. **Where you cannot, write the missing part by hand**: in the test source set, in the generated package, from the generated constants -- and own what you type, as <> says. +. **Guard it**: pin it to the hash of the fragment it was written against, and assert that the method still throws, so you hear when the contract changes and when the generator can take over. +. **Write one contract test per response**, checking status, content type and fields with the generated descriptors. +. **Put every other invalid input in behaviour tests**, as scenarios. + +[#next] +== Where to go next + +* link:tutorial.adoc[The tutorial →] builds this step by step on a refunds service whose request body the generator cannot write. +* link:prompt-claude-code.md[The Claude Code prompt →] and link:prompt-codex.md[the Codex prompt →] have an AI coding assistant do the same in your own project. +* https://github.com/Arc-E-Tect/SoftwareEngineeringDoneRight-Library/tree/main/examples/api-only-transcriberj/hand-written-contract-tests[The worked example ↗] is the tutorial's result, built in CI. +* link:../../../api-only-transcriberj/README.adoc#generated-code[The TranscriberJ's README →] lists what each generated class holds. diff --git a/docs/guides/hand-written-contract-tests/prompt-claude-code.md b/docs/guides/hand-written-contract-tests/prompt-claude-code.md new file mode 100644 index 0000000..4098d61 --- /dev/null +++ b/docs/guides/hand-written-contract-tests/prompt-claude-code.md @@ -0,0 +1,134 @@ +--- +description: Write the contract tests the API-Only TranscriberJ cannot generate, on top of the classes it does, test first. +--- + +# Write the contract tests the generator cannot write, on top of the classes it does + +Help me write contract tests for this Gradle project, which generates classes from its API contract with the API-Only TranscriberJ, where the generated classes do not cover everything: a method the generator could not write, or a response whose trigger the schema cannot express, such as a 422 from a business rule. + +When we are done: + +- every response each operation in scope lists has one contract test, checking status, content type and fields against the generated classes; +- whatever the generator could not write is written by hand from the generated pieces, and fails a test when the contract changes underneath it; +- every other way a request can be invalid, and every business rule, is tested as behaviour, not as a contract test; +- `./gradlew check` passes. + +## How to work + +- Start in plan mode. + Investigate first, then present a plan that lists every file you will create or change, and wait for my approval before you edit anything. +- Keep a todo list of the steps below, and update it as you finish each one. +- Work test first. + Create each failing check before the change that makes it pass, run it, and show me the failure. +- Report a check as passing only after you ran it and saw it pass, and show the relevant output. +- Do not commit, push, or change anything outside this project. + At the end, tell me what to commit. +- Do not invent generated classes, constants or methods. + Read what was generated, under `build/generated/sources/transcriberj/`, and use only what is there. + If you need something that is not, stop and ask me. + +## Principles + +- **A contract test checks the shape of a response, not the behaviour that produced it.** + One contract test per response the contract lists: one example request is enough for each. + Every other way to be invalid, and every business rule, is a behaviour test, written as scenarios against the service. + Do not write one contract test per invalid value. +- **Prefer fixing the contract.** Where the generation report gives a remedy -- name the branches of a `oneOf`, make an inline schema a component -- tell me before writing anything by hand: fixing the contract gives every consumer the generated code. + Write it by hand only when I say the contract cannot change. +- **Write by hand only what could not be generated**, and build it from the generated pieces: constants, constraints, `ContractJson`. + A literal where a generated constant exists is not allowed. +- **Guard what is hand-written**, so that it fails a test when the contract changes and when the generator can take over. +- **Generated code is for tests.** Never add `main` to a subscription's `sourceSets`, and never make application code import a generated class. +- **Never edit generated sources.** + +## Versions + +Wherever one of these names appears below, in braces, it stands for this version: + +| Name | Version | +|---|---| +| `{api-only-transcriberj-version}` | `0.3.3` | +| `{api-only-transcriberj-restdocs-version}` | `0.1.1` | + +## Facts: API-Only TranscriberJ {api-only-transcriberj-version} + +- The generated tree is in `build/generated/sources/transcriberj//`, in the subscription's `basePackage`, compiled by the source sets in `sourceSets` (`test` by default). +- `build/reports/transcriberj/.txt` lists every *degraded* method -- one generated to throw `UnsupportedOperationException` because a construct cannot be represented -- with the construct, its location and, where there is one, the remedy. + A degraded `body(...)` takes `Object...`. +- What a class holds, whether or not a method degraded: + - provenance: `FRAGMENT_PATH` and `FRAGMENT_SHA256` (SHA-256 of the fragment as bundled), or `LOCATION` and `SCHEMA_SHA256` for an inline schema; `CONTRACT_VERSION`; + - constraints as constants: `MINIMUM`, `MAXIMUM`, `MIN_LENGTH`, `MAX_LENGTH`, `PATTERN`, `FORMAT`, `ENUM` and the rest; an object's are prefixed with the property, e.g. `EMAIL_ADDRESS_MIN_LENGTH`; + - `description()`; `body(...)` and `fields(prefix)` for an object; `OPEN`. +- An operation's class `Operation`: `OPERATION_ID`, `METHOD`, `PATH`, `REQUEST_CONTENT_TYPE` where there is a body, `STATUS_` and `CONTENT_TYPE_` per response, and `path(...)` filling in the placeholders. +- **Constraints are constants, not checks**: a generated `body(...)` writes whatever it is given. +- **Field descriptions are a projection** (path, type, optional, description): a response that matches them has the fields the contract describes, which is not schema validity. +- **Visibility**: a class is public when code outside the package needs it (a body, a parameter's schema, a non-schema component, what a public `body(...)` takes, a branch of a `oneOf`/`anyOf` a public body is). + Everything else is package-private, including scalar components such as a constrained string or integer, and `ContractJson`. + Hand-written code that needs them goes **in the generated package, in the test source set** (`src/test/java//`), next to the generated classes. + This is a design decision of the TranscriberJ, not a workaround. +- **The package-private members are not a published API.** They are regenerated every build and may change when the TranscriberJ is upgraded; hand-written code using them then stops compiling and must be adjusted. +- **Generated nowhere, so typed by hand and owned by the project**: the members of a schema whose `body(...)` degraded (names, which are required, nesting); `const` values (private in named components, absent for inline branches); constraints written inline inside an inline branch. + Nothing verifies hand-written code against the contract: `verifyContractSources` checks only the generated tree. +- `ContractJson` (package-private): `string(value)` quotes a string, `member(json, name, value)` appends a member to a `StringBuilder` started with `"{"`, `close(json)` ends it with `}` and a newline, `embed(body)` nests a body another `body(...)` returned, and `array(values)` joins values already written as JSON -- the same way generated bodies write. +- A `oneOf`/`anyOf` with an inline object branch degrades `body(...)` and `fields(...)`; remedy: name the branches. + A choice whose branches are all references has no `body(...)` of its own; each branch has one. + +## Facts: the REST Docs emitter {api-only-transcriberj-restdocs-version} + +- `Docs.responseFields()` and `requestFields()`, in `.restdocs`, for every public class with a body; used with `document(...)`, they fail when a documented required field is missing or an undocumented one is present. +- A companion method whose core method degraded throws too. + +## Step 1: Investigate and propose + +Do not edit anything in this step. +Find out and report: + +1. The TranscriberJ version, the subscription's `basePackage` and `sourceSets`, and whether the REST Docs emitter is applied. + If the version is older than `{api-only-transcriberj-version}`, stop and tell me. +2. Every degraded method in `build/reports/transcriberj/.txt`, and its remedy. +3. For each operation in scope: the responses it lists, and which of them the schema cannot fully explain (a 422 from a business rule, say). +4. The web framework and how the project's tests call it (MockMvc, WebTestClient, a running server). +5. Existing contract tests and behaviour tests, and where they are. + +Then present the plan: which remedies to propose to the contract's owners, what to write by hand and in which file, the contract tests (one per response), and the behaviour tests. +Wait for my approval. + +## Step 2: The failing tests + +1. For each hand-written part, a guard test in the generated package: + - the class's `FRAGMENT_SHA256` equals a `WRITTEN_AGAINST` constant in the hand-written class, with a message naming the fragment and saying to check the hand-written class and update the constant; + - the degraded method still throws `UnsupportedOperationException`, with a comment saying that when it no longer does, the hand-written part goes; + - every sample value satisfies the generated constraints. +2. One contract test per response, using the operation's `PATH`, `REQUEST_CONTENT_TYPE`, `STATUS_` and `CONTENT_TYPE_`, and the response's `Docs.responseFields()`. + Each uses one example request. +3. Behaviour tests for the rules and the invalid inputs, as scenarios; parameterised where the scenarios differ only in data. + +Run them and show me that they fail, and why. + +## Step 3: Make them pass + +1. Write the hand-written body (or other missing part) in the generated package, in the test source set, using the generated constants and `ContractJson`; type only what no generated class holds, such as the member names of an inline branch. +2. Set `WRITTEN_AGAINST` to the current `FRAGMENT_SHA256`. +3. Change the service only where a test shows it does not do what the contract promises, and tell me before you do. + +Run `./gradlew check` and show me that it passes. + +## Step 4: Prove the guards work + +Show me each of these on a copy, or undo it afterwards: + +1. Remove a constraint the hand-written code uses from the contract and raise the version: the tests no longer compile. +2. Rename a member of the schema the hand-written code types, and raise the version: only the `WRITTEN_AGAINST` guard fails, while the contract tests still pass -- which is why the guard exists. + +## Step 5: Wrap up + +1. Tell me which files to commit, and which remedies to take to the contract's owners. +2. List, per hand-written class, every value it types that no generated class holds, and say plainly that keeping those in line with the contract is now this project's responsibility, guarded only by the `WRITTEN_AGAINST` test. +3. Offer to add a section like this to `CLAUDE.md`, and write it only if I agree: + + ```markdown + ## Contract tests + - One contract test per response an operation lists; it checks status, content type and fields with the generated classes. Invalid inputs and business rules are behaviour tests. + - What the TranscriberJ cannot generate is written by hand in the generated package under src/test/java, from generated constants, and pinned to its fragment's FRAGMENT_SHA256. + - A failing WRITTEN_AGAINST guard means the contract changed: check the hand-written code against it, then update the constant. + ``` diff --git a/docs/guides/hand-written-contract-tests/prompt-codex.md b/docs/guides/hand-written-contract-tests/prompt-codex.md new file mode 100644 index 0000000..35deb14 --- /dev/null +++ b/docs/guides/hand-written-contract-tests/prompt-codex.md @@ -0,0 +1,137 @@ +--- +description: Write the contract tests the API-Only TranscriberJ cannot generate, on top of the classes it does, test first. +--- + +# Write the contract tests the generator cannot write, on top of the classes it does + +Help me write contract tests for this Gradle project, which generates classes from its API contract with the API-Only TranscriberJ, where the generated classes do not cover everything: a method the generator could not write, or a response whose trigger the schema cannot express, such as a 422 from a business rule. + +When we are done: + +- every response each operation in scope lists has one contract test, checking status, content type and fields against the generated classes; +- whatever the generator could not write is written by hand from the generated pieces, and fails a test when the contract changes underneath it; +- every other way a request can be invalid, and every business rule, is tested as behaviour, not as a contract test; +- `./gradlew check` passes. + +## How to work + +- Begin with a read-only investigation. + Then present a plan that lists every file you will create or change, and do not edit anything until I approve it. +- Keep your plan updated as you finish each step below. +- Work test first. + Create each failing check before the change that makes it pass, run it, and show me the failure. +- Report a check as passing only after you ran it and saw it pass, and show the relevant output. +- Do not commit, push, or change anything outside this project. + At the end, tell me what to commit. +- Do not invent generated classes, constants or methods. + Read what was generated, under `build/generated/sources/transcriberj/`, and use only what is there. + If you need something that is not, stop and ask me. +- **Network access**: Gradle resolving plugins and test libraries needs it, and so does the Publisher if this project builds its own contract. + If the sandbox blocks network access, ask me to approve those commands with network access, or to run them myself, rather than working around the failure. + +## Principles + +- **A contract test checks the shape of a response, not the behaviour that produced it.** + One contract test per response the contract lists: one example request is enough for each. + Every other way to be invalid, and every business rule, is a behaviour test, written as scenarios against the service. + Do not write one contract test per invalid value. +- **Prefer fixing the contract.** Where the generation report gives a remedy -- name the branches of a `oneOf`, make an inline schema a component -- tell me before writing anything by hand: fixing the contract gives every consumer the generated code. + Write it by hand only when I say the contract cannot change. +- **Write by hand only what could not be generated**, and build it from the generated pieces: constants, constraints, `ContractJson`. + A literal where a generated constant exists is not allowed. +- **Guard what is hand-written**, so that it fails a test when the contract changes and when the generator can take over. +- **Generated code is for tests.** Never add `main` to a subscription's `sourceSets`, and never make application code import a generated class. +- **Never edit generated sources.** + +## Versions + +Wherever one of these names appears below, in braces, it stands for this version: + +| Name | Version | +|---|---| +| `{api-only-transcriberj-version}` | `0.3.3` | +| `{api-only-transcriberj-restdocs-version}` | `0.1.1` | + +## Facts: API-Only TranscriberJ {api-only-transcriberj-version} + +- The generated tree is in `build/generated/sources/transcriberj//`, in the subscription's `basePackage`, compiled by the source sets in `sourceSets` (`test` by default). +- `build/reports/transcriberj/.txt` lists every *degraded* method -- one generated to throw `UnsupportedOperationException` because a construct cannot be represented -- with the construct, its location and, where there is one, the remedy. + A degraded `body(...)` takes `Object...`. +- What a class holds, whether or not a method degraded: + - provenance: `FRAGMENT_PATH` and `FRAGMENT_SHA256` (SHA-256 of the fragment as bundled), or `LOCATION` and `SCHEMA_SHA256` for an inline schema; `CONTRACT_VERSION`; + - constraints as constants: `MINIMUM`, `MAXIMUM`, `MIN_LENGTH`, `MAX_LENGTH`, `PATTERN`, `FORMAT`, `ENUM` and the rest; an object's are prefixed with the property, e.g. `EMAIL_ADDRESS_MIN_LENGTH`; + - `description()`; `body(...)` and `fields(prefix)` for an object; `OPEN`. +- An operation's class `Operation`: `OPERATION_ID`, `METHOD`, `PATH`, `REQUEST_CONTENT_TYPE` where there is a body, `STATUS_` and `CONTENT_TYPE_` per response, and `path(...)` filling in the placeholders. +- **Constraints are constants, not checks**: a generated `body(...)` writes whatever it is given. +- **Field descriptions are a projection** (path, type, optional, description): a response that matches them has the fields the contract describes, which is not schema validity. +- **Visibility**: a class is public when code outside the package needs it (a body, a parameter's schema, a non-schema component, what a public `body(...)` takes, a branch of a `oneOf`/`anyOf` a public body is). + Everything else is package-private, including scalar components such as a constrained string or integer, and `ContractJson`. + Hand-written code that needs them goes **in the generated package, in the test source set** (`src/test/java//`), next to the generated classes. + This is a design decision of the TranscriberJ, not a workaround. +- **The package-private members are not a published API.** They are regenerated every build and may change when the TranscriberJ is upgraded; hand-written code using them then stops compiling and must be adjusted. +- **Generated nowhere, so typed by hand and owned by the project**: the members of a schema whose `body(...)` degraded (names, which are required, nesting); `const` values (private in named components, absent for inline branches); constraints written inline inside an inline branch. + Nothing verifies hand-written code against the contract: `verifyContractSources` checks only the generated tree. +- `ContractJson` (package-private): `string(value)` quotes a string, `member(json, name, value)` appends a member to a `StringBuilder` started with `"{"`, `close(json)` ends it with `}` and a newline, `embed(body)` nests a body another `body(...)` returned, and `array(values)` joins values already written as JSON -- the same way generated bodies write. +- A `oneOf`/`anyOf` with an inline object branch degrades `body(...)` and `fields(...)`; remedy: name the branches. + A choice whose branches are all references has no `body(...)` of its own; each branch has one. + +## Facts: the REST Docs emitter {api-only-transcriberj-restdocs-version} + +- `Docs.responseFields()` and `requestFields()`, in `.restdocs`, for every public class with a body; used with `document(...)`, they fail when a documented required field is missing or an undocumented one is present. +- A companion method whose core method degraded throws too. + +## Step 1: Investigate and propose + +Read only; do not edit anything in this step. +Find out and report: + +1. The TranscriberJ version, the subscription's `basePackage` and `sourceSets`, and whether the REST Docs emitter is applied. + If the version is older than `{api-only-transcriberj-version}`, stop and tell me. +2. Every degraded method in `build/reports/transcriberj/.txt`, and its remedy. +3. For each operation in scope: the responses it lists, and which of them the schema cannot fully explain (a 422 from a business rule, say). +4. The web framework and how the project's tests call it (MockMvc, WebTestClient, a running server). +5. Existing contract tests and behaviour tests, and where they are. + +Then present the plan: which remedies to propose to the contract's owners, what to write by hand and in which file, the contract tests (one per response), and the behaviour tests. +Wait for my approval. + +## Step 2: The failing tests + +1. For each hand-written part, a guard test in the generated package: + - the class's `FRAGMENT_SHA256` equals a `WRITTEN_AGAINST` constant in the hand-written class, with a message naming the fragment and saying to check the hand-written class and update the constant; + - the degraded method still throws `UnsupportedOperationException`, with a comment saying that when it no longer does, the hand-written part goes; + - every sample value satisfies the generated constraints. +2. One contract test per response, using the operation's `PATH`, `REQUEST_CONTENT_TYPE`, `STATUS_` and `CONTENT_TYPE_`, and the response's `Docs.responseFields()`. + Each uses one example request. +3. Behaviour tests for the rules and the invalid inputs, as scenarios; parameterised where the scenarios differ only in data. + +Run them and show me that they fail, and why. + +## Step 3: Make them pass + +1. Write the hand-written body (or other missing part) in the generated package, in the test source set, using the generated constants and `ContractJson`; type only what no generated class holds, such as the member names of an inline branch. +2. Set `WRITTEN_AGAINST` to the current `FRAGMENT_SHA256`. +3. Change the service only where a test shows it does not do what the contract promises, and tell me before you do. + +Run `./gradlew check` and show me that it passes. + +## Step 4: Prove the guards work + +Show me each of these on a copy, or undo it afterwards: + +1. Remove a constraint the hand-written code uses from the contract and raise the version: the tests no longer compile. +2. Rename a member of the schema the hand-written code types, and raise the version: only the `WRITTEN_AGAINST` guard fails, while the contract tests still pass -- which is why the guard exists. + +## Step 5: Wrap up + +1. Tell me which files to commit, and which remedies to take to the contract's owners. +2. List, per hand-written class, every value it types that no generated class holds, and say plainly that keeping those in line with the contract is now this project's responsibility, guarded only by the `WRITTEN_AGAINST` test. +3. Offer to add a section like this to `AGENTS.md`, and write it only if I agree: + + ```markdown + ## Contract tests + - One contract test per response an operation lists; it checks status, content type and fields with the generated classes. Invalid inputs and business rules are behaviour tests. + - What the TranscriberJ cannot generate is written by hand in the generated package under src/test/java, from generated constants, and pinned to its fragment's FRAGMENT_SHA256. + - A failing WRITTEN_AGAINST guard means the contract changed: check the hand-written code against it, then update the constant. + - Running a Gradle build that resolves plugins needs network access. + ``` diff --git a/docs/guides/hand-written-contract-tests/tutorial.adoc b/docs/guides/hand-written-contract-tests/tutorial.adoc new file mode 100644 index 0000000..ee3d5b3 --- /dev/null +++ b/docs/guides/hand-written-contract-tests/tutorial.adoc @@ -0,0 +1,780 @@ += Tutorial: contract tests the generator cannot write, for a refunds service +:toc: left +:toc-title: Contents +:toclevels: 2 +:icons: font +:source-highlighter: rouge +// The component versions this document describes; change them here only. +:api-only-transcriberj-version: 0.3.3 +:api-only-transcriberj-restdocs-version: 0.1.1 +:api-only-publisher-version: 0.5.0 +:spring-boot-version: 4.1.1 +:example-url: https://github.com/Arc-E-Tect/SoftwareEngineeringDoneRight-Library/tree/main/examples/api-only-transcriberj/hand-written-contract-tests + +[NOTE] +.Where the links in this document go +==== +Every link ends with a marker saying where it leads, so you can tell before you follow it: + +[horizontal] +`↗`:: An *external* site, outside this repository. +`→`:: *Another file* in this repository. +`§`:: *A section of this same file.* +==== + +This tutorial writes the contract tests of a refunds service whose request body the API-Only TranscriberJ cannot generate, on top of the classes it can. +Every file below is copied from {example-url}[the worked example ↗], and every command was run in it, with API-Only TranscriberJ {api-only-transcriberj-version}, its REST Docs emitter {api-only-transcriberj-restdocs-version}, API-Only Publisher {api-only-publisher-version} and Spring Boot {spring-boot-version}. +The example is built in CI. + +It assumes a Gradle project that already generates classes from its contract with the TranscriberJ; how the contract gets there is link:../../use-cases/provider/1-contract-in-the-implementing-project/tutorial.adoc[provider use case 1 →], and the example does exactly that. +link:overview.adoc[The overview →] makes the argument this tutorial puts into practice; link:../../../api-only-transcriberj/README.adoc#generated-code[the TranscriberJ's README →] lists everything a generated class holds. + +[#before-you-start] +== Before you start + +[cols="1,2", options="header"] +|=== +|You need |Version + +|Java +|21 or newer + +|Node.js and npm, for the Publisher that builds the example's contract +|Node.js `^22.14.0`, `^24.10.0` or `>=26.0.0` + +|The API-Only TranscriberJ +|{api-only-transcriberj-version} or newer, and its REST Docs emitter +|=== + +To follow along in the example itself: + +[source,console] +---- +git clone https://github.com/Arc-E-Tect/SoftwareEngineeringDoneRight-Library.git +cd SoftwareEngineeringDoneRight-Library/examples/api-only-transcriberj/hand-written-contract-tests +./gradlew check +---- + +[#boundary] +== Step 0: Know where the generated tree stops + +Three things the generated classes do not do, and that no amount of configuration changes: + +* **Constraints are constants, not checks.** +`AmountV1.MAXIMUM` is a number to build a test from. +A generated `body(...)` writes the arguments it is given, so a body built from arguments that break a constraint breaks it too. +* **Field descriptions are a projection.** +A field is a path, a JSON type, whether it is optional, and a description. +A response checked against them has the fields the contract describes, of those types; whether it is valid against the schema is not what the check says. +* **Some constructs degrade.** +The methods a construct no rule represents reaches are generated as ones that throw `UnsupportedOperationException`, with the reason and the remedy. +The class, its constants and every other method are still generated, and the build does not stop. + +link:../../../api-only-transcriberj/README.adoc#limits[What this cannot do →] and link:../../../api-only-transcriberj/README.adoc#unrepresentable[Constructs no rule represents yet →] are the reference for all three. + +[#contract] +== Step 1: A contract that cannot be generated in full + +The service refunds an order, to the card it was paid with or to a bank account. +The request body says which, with a `oneOf` whose two branches are written inline. + +`src/main/api/openapi/components/schemas/RefundRequestV1.yaml`: + +[source,yaml] +---- +type: object +description: Where a refund goes, and how much of the order it returns. +oneOf: + - type: object + description: A refund to the card the order was paid with. + required: + - destination + - amount + - cardLast4 + properties: + destination: + const: card + amount: + $ref: AmountV1.yaml + cardLast4: + $ref: CardLast4V1.yaml + - type: object + description: A refund to a bank account, for an order paid by bank transfer. + required: + - destination + - amount + - iban + properties: + destination: + const: bank-account + amount: + $ref: AmountV1.yaml + iban: + $ref: IbanV1.yaml +---- + +The amount, the card digits and the IBAN are components of their own, with their constraints, such as `src/main/api/openapi/components/schemas/AmountV1.yaml`: + +[source,yaml] +---- +type: integer +description: An amount of money, in cents. +minimum: 1 +maximum: 1000000 +---- + +The operation, in `src/main/api/openapi/paths/Refunds.yaml`, lists four responses. +Its description carries two rules the schema cannot: a refund goes back the way the order was paid, and refunds never add up to more than the order. +A request that breaks one is valid against the schema, and answered with 422: + +[source,yaml] +---- +post: + operationId: createRefund + summary: Refund part or all of an order. + description: >- + A refund goes back the way the order was paid, and never exceeds what is + left of the order to refund. Neither rule can be written in the schema; a + request that breaks one is answered with 422. + parameters: + - name: orderId + in: path + required: true + description: The order's identifier. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: ../components/schemas/RefundRequestV1.yaml + responses: + '201': + description: The refund was accepted. + content: + application/json: + schema: + $ref: ../components/schemas/RefundV1.yaml + '400': + description: The request does not match the contract. + content: + application/problem+json: + schema: + $ref: ../components/schemas/ProblemV1.yaml + '404': + description: No order has that identifier. + content: + application/problem+json: + schema: + $ref: ../components/schemas/ProblemV1.yaml + '422': + description: The request matches the contract, but breaks a business rule. + content: + application/problem+json: + schema: + $ref: ../components/schemas/ProblemV1.yaml +---- + +[#report] +== Step 2: Read what the generator could not write + +The generated classes go to the test source set only; the service is written by hand and never imports them. +`build.gradle`: + +[source,groovy] +---- +dependencies { + // The service: hand-written, and it never imports the generated classes. + implementation platform(SpringBootPlugin.BOM_COORDINATES) + implementation 'org.springframework.boot:spring-boot-starter-webmvc' + + // The tests: the generated classes, their REST Docs companions, and MockMvc. + transcriberjEmitters libs.api.only.transcriberj.restdocs + testImplementation platform(SpringBootPlugin.BOM_COORDINATES) + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testImplementation 'org.springframework.boot:spring-boot-restdocs' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} +---- + +[source,groovy] +---- +apiOnlyTranscriberJ { + subscription('refunds') { + basePackage = 'com.example.contract' + // The REST Docs field tables show the contract's descriptions. + generateDocs = true + } +} +---- + +Generate, and read the report: + +[source,console] +---- +./gradlew generateContractSourcesRefunds +cat build/reports/transcriberj/refunds.txt +---- + +[source,text] +---- +API-Only TranscriberJ: refunds 1.0.0 +2 degraded method(s), 0 recommendation(s), 0 undecided construct(s), 0 warning(s) + +Degraded methods -- these throw UnsupportedOperationException: + RefundRequestV1.body(...) [core]: ONE_OF_INLINE_BRANCHES at /components/schemas/RefundRequestV1 (oneOf: , ); remedy: name the branches in the specification so that each becomes a component + RefundRequestV1.fields(String) [core]: ONE_OF_INLINE_BRANCHES at /components/schemas/RefundRequestV1 (oneOf: , ); remedy: name the branches in the specification so that each becomes a component +---- + +The report names both methods, the reason, and the remedy. +`RefundRequestV1.body(...)` takes `Object...`, since its parameters cannot be known, and throws: + +[source,java] +---- + public static String body(Object... values) { + throw new UnsupportedOperationException("RefundRequestV1.body(...) cannot be generated from contract refunds: ONE_OF_INLINE_BRANCHES at /components/schemas/RefundRequestV1 (oneOf: , ); name the branches in the specification so that each becomes a component. See the API-Only TranscriberJ report."); + } +---- + +[#what-is-left] +== Step 3: Take stock of what is still there + +The class whose body cannot be generated still carries its provenance: + +[source,java] +---- +public final class RefundRequestV1 { + + /** The fragment this class was generated from. */ + public static final String FRAGMENT_PATH = "openapi/components/schemas/RefundRequestV1.yaml"; + + /** The SHA-256 of that fragment as bundled, in RFC 8785 canonical JSON. */ + public static final String FRAGMENT_SHA256 = "f20b627c2a6f8effb19706bfc3bc3b2854a494c0b8bcf79598c46db8666529b6"; + + /** The version of the contract this class was generated from. */ + public static final String CONTRACT_VERSION = "1.0.0"; + + /** Whether a body may carry members the contract does not list. */ + public static final boolean OPEN = false; +---- + +The operation's class has everything a request needs except the body: + +[source,console] +---- +grep -h "public static final\|public static String path" build/generated/sources/transcriberj/refunds/com/example/contract/CreateRefundOperation.java +---- + +[source,java] +---- + public static final String OPERATION_ID = "createRefund"; + public static final String METHOD = "POST"; + public static final String PATH = "/orders/{orderId}/refunds"; + public static final String LOCATION = "/paths/~1orders~1{orderId}~1refunds/post"; + public static final String OPERATION_SHA256 = "06d2ccf8fa6d3b14002147b2b23848b850aa92ba58a7dbc76915c6c5cdbdb37a"; + public static final String CONTRACT_VERSION = "1.0.0"; + public static final String REQUEST_CONTENT_TYPE = "application/json"; + public static final int STATUS_201 = 201; + public static final String CONTENT_TYPE_201 = "application/json"; + public static final int STATUS_400 = 400; + public static final String CONTENT_TYPE_400 = "application/problem+json"; + public static final int STATUS_404 = 404; + public static final String CONTENT_TYPE_404 = "application/problem+json"; + public static final int STATUS_422 = 422; + public static final String CONTENT_TYPE_422 = "application/problem+json"; + public static String path(String orderId) { +---- + +And beyond those: + +[cols="2,3", options="header"] +|=== +|Generated |What a hand-written test takes from it + +|`AmountV1.MINIMUM`, `AmountV1.MAXIMUM`, `CardLast4V1.PATTERN`, `IbanV1.PATTERN` +|Values the contract accepts, and one it rejects. + +|`RefundV1Docs.responseFields()`, `ProblemV1Docs.responseFields()` +|The response side, in full: every field of the refund and of the problem document, as REST Docs descriptors. +The error side can usually be generated even when the request side cannot. + +|`ContractJson` +|The JSON writer the generated bodies use, so a hand-written body writes JSON the same way. +Package-private, regenerated every build, and not a published API. +|=== + +`AmountV1`, `CardLast4V1`, `IbanV1` and `ContractJson` are package-private: code outside `com.example.contract` cannot see them. +That decides where the hand-written body goes. + +[#write-it] +== Step 4: Write the missing part by hand, in the generated package + +The missing part is the request body, so that -- and only that -- is written by hand. + +[IMPORTANT] +.A design decision: hand-written test code goes in the generated package +==== +Hand-written code that builds on the generated classes goes in the subscription's `basePackage`, in the test source set. +The generated tree makes public only what callers outside the package need, and keeps the rest -- `ContractJson`, and scalar components such as `AmountV1` -- package-private; from the same package, hand-written code reaches all of it. + +Those package-private members are regenerated on every build and are *not* a published API. +An upgrade of the TranscriberJ may change them; hand-written code that uses them then stops compiling, and is adjusted. +link:../../../api-only-transcriberj/README.adoc#hand-written-code[Hand-written code beside the generated classes →] is the reference. +==== + +`src/test/java/com/example/contract/RefundRequests.java`, in the *test* source set, in the *generated* package: + +[source,java] +---- +package com.example.contract; + +/** + * Refund requests, written by hand: {@link RefundRequestV1#body(Object...)} cannot be + * generated, because the contract's {@code oneOf} branches are inline. + * + *

It sits in the generated package, in the test source set, so it writes JSON with the + * same {@link ContractJson} the generated bodies do, and it takes everything else it can + * from the generated classes. The member names and the two {@code destination} values are + * the only things typed here; {@link #WRITTEN_AGAINST} is how a change to them is noticed. + */ +public final class RefundRequests { + + /** + * The {@link RefundRequestV1#FRAGMENT_SHA256} this class was written against. When the + * request schema changes, a test fails until this class is checked against it again. + */ + public static final String WRITTEN_AGAINST = + "f20b627c2a6f8effb19706bfc3bc3b2854a494c0b8bcf79598c46db8666529b6"; + + /** An amount the contract accepts: the smallest. */ + public static final int SOME_AMOUNT = AmountV1.MINIMUM; + + /** An amount the contract rejects: one more than the largest. */ + public static final int TOO_LARGE_AN_AMOUNT = AmountV1.MAXIMUM + 1; + + /** Card digits the contract accepts; checked against {@link CardLast4V1#PATTERN}. */ + public static final String SOME_CARD_LAST4 = "4242"; + + /** An account the contract accepts; checked against {@link IbanV1#PATTERN}. */ + public static final String SOME_IBAN = "NL91ABNA0417164300"; + + private RefundRequests() { + } + + /** + * A refund to the card the order was paid with. + * + * @param amount the amount, in cents; see {@link AmountV1} + * @param cardLast4 the card's last four digits; see {@link CardLast4V1#PATTERN} + * @return the request body + */ + public static String toCard(int amount, String cardLast4) { + StringBuilder json = new StringBuilder("{"); + ContractJson.member(json, "destination", ContractJson.string("card")); + ContractJson.member(json, "amount", String.valueOf(amount)); + ContractJson.member(json, "cardLast4", ContractJson.string(cardLast4)); + return ContractJson.close(json); + } + + /** + * A refund to a bank account. + * + * @param amount the amount, in cents; see {@link AmountV1} + * @param iban the account; see {@link IbanV1#PATTERN} + * @return the request body + */ + public static String toBankAccount(int amount, String iban) { + StringBuilder json = new StringBuilder("{"); + ContractJson.member(json, "destination", ContractJson.string("bank-account")); + ContractJson.member(json, "amount", String.valueOf(amount)); + ContractJson.member(json, "iban", ContractJson.string(iban)); + return ContractJson.close(json); + } +} +---- + +What to consider:: +* **Same package, different directory.** The generated classes are in `build/generated/sources/transcriberj/refunds/`, and this class is in `src/test/java/`; both are the test source set's, so Java sees one package, and the package-private constants and `ContractJson` are in reach. +Generation replaces its own directory on every build and never touches `src/`. +* **Draw on the generated pieces even where a literal would be shorter.** +`AmountV1.MAXIMUM + 1` is one more than the contract's maximum today and after the next change; `1000001` is right only until then. +A hand-written body that references nothing generated never breaks at compile time, and so rots without anyone noticing. +* **Type only what the contract does not give you.** +<> lists what that is here. + +[#owned] +=== What this class now owns + +Some of what the contract says is generated nowhere, so `RefundRequests` types it, and from here on the project is responsible for it matching the contract -- nothing else checks it: + +[cols="2,3", options="header"] +|=== +|Typed in `RefundRequests` |Why no generated class holds it + +|The members `destination`, `amount`, `cardLast4` and `iban`, and which branch requires which +|They belong to the inline branches of a `oneOf` whose `body(...)` and `fields(...)` degraded. + +|The `destination` values `card` and `bank-account` +|They are `const` values of inline branches; even a named component keeps its `const` values in a private constant. + +|`SOME_CARD_LAST4` and `SOME_IBAN` +|Sample values, checked against `CardLast4V1.PATTERN` and `IbanV1.PATTERN` in <>. +Had the branches written those patterns inline instead of referring to components, no constant would hold them either. +|=== + +That responsibility comes with four duties, and the next step turns three of them into tests: + +. draw on the generated constants wherever they hold the value, as `SOME_AMOUNT` and `TOO_LARGE_AN_AMOUNT` do; +. pin the class to the hash of the fragment it was written against, and check it against the contract whenever that fails; +. assert that the degraded method still throws, and delete the class when it no longer does; +. write only what is missing: <> shows the remedy that leaves nothing to type. + +link:../../../api-only-transcriberj/README.adoc#your-responsibilities[What the generated classes do not hold →] is the reference. + +[#guard] +== Step 5: Make the hand-written part break when the contract moves + +A generated constant that disappears breaks the build at compile time -- the good case. +What does not break it is a change the hand-written code does not refer to: a member renamed inside the `oneOf`. +`src/test/java/com/example/contract/RefundRequestsTest.java` covers both that and the day the generator can take over: + +[source,java] +---- +package com.example.contract; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * What keeps the hand-written {@link RefundRequests} honest as the contract moves. + */ +class RefundRequestsTest { + + @Test + void theHandWrittenBodyWasWrittenAgainstTheRequestSchemaAsItIsNow() { + assertThat(RefundRequestV1.FRAGMENT_SHA256) + .as("%s changed; check RefundRequests against it, then update WRITTEN_AGAINST", + RefundRequestV1.FRAGMENT_PATH) + .isEqualTo(RefundRequests.WRITTEN_AGAINST); + } + + @Test + void theGeneratorStillCannotWriteThisBody() { + // When this fails, the contract names its branches and the generator writes the + // body itself: use the generated classes, and delete RefundRequests. + assertThatThrownBy(RefundRequestV1::body) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("ONE_OF_INLINE_BRANCHES"); + } + + @Test + void theSampleValuesAreOnesTheContractAccepts() { + // CardLast4V1 and IbanV1 are package-private: nothing public in the generated tree + // takes them, because the body that would have is the one that could not be generated. + assertThat(RefundRequests.SOME_AMOUNT).isBetween(AmountV1.MINIMUM, AmountV1.MAXIMUM); + assertThat(RefundRequests.SOME_CARD_LAST4).matches(CardLast4V1.PATTERN); + assertThat(RefundRequests.SOME_IBAN).matches(IbanV1.PATTERN); + } + + @Test + void itWritesTheMembersEachBranchRequires() { + assertThat(RefundRequests.toCard(RefundRequests.SOME_AMOUNT, RefundRequests.SOME_CARD_LAST4)) + .isEqualTo("{\"destination\":\"card\",\"amount\":1,\"cardLast4\":\"4242\"}\n"); + assertThat(RefundRequests.toBankAccount(RefundRequests.SOME_AMOUNT, RefundRequests.SOME_IBAN)) + .isEqualTo("{\"destination\":\"bank-account\",\"amount\":1,\"iban\":\"NL91ABNA0417164300\"}\n"); + } +} +---- + +* `theHandWrittenBodyWasWrittenAgainstTheRequestSchemaAsItIsNow` pins the body to the fragment's hash, `RefundRequestV1.FRAGMENT_SHA256`. +Any change to the request schema fails it, with a message that says what to do. +* `theGeneratorStillCannotWriteThisBody` fails when the contract, or the generator, changes so that the body can be generated; then the hand-written one goes. +* `theSampleValuesAreOnesTheContractAccepts` keeps the typed sample values inside the constraints. + +[#how-many] +== Step 6: Decide how many tests, and where they go + +**A contract test checks the shape of a response, not the behaviour that produced it.** + +The contract promises a client a status, a content type and a set of fields for each response it lists. +One request that gets a 400 is enough to check that the 400 comes back as the contract's problem document. +A second invalid amount, a third, and every way to get the card digits wrong come back in the same shape; as contract tests they check nothing new about the contract. + +*Which* requests the service rejects, and why, is behaviour. +It belongs with the service, as scenarios: every way to be invalid, every business rule, and what the service does -- tested where they can be stated plainly, without going through HTTP each time. + +So the example has two kinds of test: + +[cols="1,2,2", options="header"] +|=== +| |Contract tests |Behaviour tests + +|Subject +|The HTTP answer, against the contract +|The service's decisions + +|How many +|One per response the contract lists: four +|One per rule or scenario: as many as there are + +|Checks +|Status, content type, fields +|Which requests are refused, and the message +|=== + +The contract tests, `src/test/java/com/example/refunds/CreateRefundContractTest.java`, check each response against the generated operation constants and REST Docs descriptors: + +[source,java] +---- +/** + * The contract tests of {@code createRefund}: one per response the contract lists, each + * checking the response's shape -- its status, its content type and its fields -- against + * the contract. Which requests the service rejects, and why, is behaviour, and is tested + * in {@link RefundsTest}. + */ +// The service itself, not a stand-in: the answers are the ones a client gets. +@WebMvcTest(RefundController.class) +@Import({Refunds.class, Orders.class}) +@AutoConfigureRestDocs +class CreateRefundContractTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void anAcceptedRefundIsAnsweredInTheShapeTheContractDescribes() throws Exception { + mockMvc.perform(post(CreateRefundOperation.PATH, Orders.PAID_BY_CARD) + .contentType(CreateRefundOperation.REQUEST_CONTENT_TYPE) + .content(RefundRequests.toCard(RefundRequests.SOME_AMOUNT, RefundRequests.SOME_CARD_LAST4))) + .andExpect(status().is(CreateRefundOperation.STATUS_201)) + .andExpect(content().contentType(CreateRefundOperation.CONTENT_TYPE_201)) + .andDo(document("create-refund", RefundV1Docs.responseFields())); + } + + @Test + void aRequestTheContractRejectsIsAnsweredWithAProblem() throws Exception { + // One example of a request the contract does not allow is enough to see the shape + // of the answer. Every other way to be invalid is behaviour: see RefundsTest. + mockMvc.perform(post(CreateRefundOperation.PATH, Orders.PAID_BY_CARD) + .contentType(CreateRefundOperation.REQUEST_CONTENT_TYPE) + .content(RefundRequests.toCard(RefundRequests.TOO_LARGE_AN_AMOUNT, RefundRequests.SOME_CARD_LAST4))) + .andExpect(status().is(CreateRefundOperation.STATUS_400)) + .andExpect(content().contentType(CreateRefundOperation.CONTENT_TYPE_400)) + .andDo(document("create-refund-invalid", ProblemV1Docs.responseFields())); + } + + @Test + void anUnknownOrderIsAnsweredWithAProblem() throws Exception { + mockMvc.perform(post(CreateRefundOperation.PATH, "no-such-order") + .contentType(CreateRefundOperation.REQUEST_CONTENT_TYPE) + .content(RefundRequests.toCard(RefundRequests.SOME_AMOUNT, RefundRequests.SOME_CARD_LAST4))) + .andExpect(status().is(CreateRefundOperation.STATUS_404)) + .andExpect(content().contentType(CreateRefundOperation.CONTENT_TYPE_404)) + .andDo(document("create-refund-unknown-order", ProblemV1Docs.responseFields())); + } + + @Test + void aRefundThatBreaksABusinessRuleIsAnsweredWithAProblem() throws Exception { + // Valid against the schema, and still refused: the order was paid by card, so its + // refund goes back to the card. The schema cannot say that; the 422 is how the + // contract tells a client, and its shape is what this test checks. + mockMvc.perform(post(CreateRefundOperation.PATH, Orders.PAID_BY_CARD) + .contentType(CreateRefundOperation.REQUEST_CONTENT_TYPE) + .content(RefundRequests.toBankAccount(RefundRequests.SOME_AMOUNT, RefundRequests.SOME_IBAN))) + .andExpect(status().is(CreateRefundOperation.STATUS_422)) + .andExpect(content().contentType(CreateRefundOperation.CONTENT_TYPE_422)) + .andDo(document("create-refund-rejected", ProblemV1Docs.responseFields())); + } +} +---- + +`ProblemV1Docs.responseFields()` fails when the problem document lacks a field the contract requires, or carries one the contract does not list, so these four tests check the shape of every response, error responses included. +The 422 test needs one request that breaks one rule; it does not test the rule. + +The behaviour tests, `src/test/java/com/example/refunds/RefundsTest.java`, test the rules, and are where all the invalid inputs are: + +[source,java] +---- +/** + * What the service does with a refund request: which ones it refuses, and why. These are + * scenarios, one per rule, and they are the place for every way a request can be wrong -- + * {@link CreateRefundContractTest} needs only one of them to check the shape of the answer. + */ +class RefundsTest { + + private final Refunds refunds = new Refunds(new Orders()); + + private static RefundRequest toCard(Integer amount, String cardLast4) { + return new RefundRequest("card", amount, cardLast4, null); + } + + private static RefundRequest toBankAccount(Integer amount, String iban) { + return new RefundRequest("bank-account", amount, null, iban); + } + + @ParameterizedTest(name = "{0}") + @CsvSource(delimiter = '|', nullValues = "null", textBlock = """ + no amount | card | null | 4242 | null | amount | is required + a zero amount | card | 0 | 4242 | null | amount | must be between 1 and 1000000 + too large an amount | card | 1000001 | 4242 | null | amount | must be between 1 and 1000000 + no card digits | card | 100 | null | null | cardLast4 | must be the last four digits of the card + three card digits | card | 100 | 424 | null | cardLast4 | must be the last four digits of the card + letters for digits | card | 100 | abcd | null | cardLast4 | must be the last four digits of the card + no IBAN | bank-account | 100 | null | null | iban | must be an IBAN without spaces + an IBAN with spaces | bank-account | 100 | null | NL91 ABNA 0417 1643 00 | iban | must be an IBAN without spaces + no destination | null | 100 | 4242 | null | destination | must be card or bank-account + an unknown destination | cash | 100 | 4242 | null | destination | must be card or bank-account + """) + void aRequestTheContractDoesNotAllowIsInvalid(String scenario, String destination, Integer amount, + String cardLast4, String iban, String field, String message) { + Outcome outcome = refunds.refund(Orders.PAID_BY_CARD, new RefundRequest(destination, amount, cardLast4, iban)); + + assertThat(outcome).isEqualTo(new Outcome.Invalid(List.of(new FieldError(field, message)))); + } +---- + +[source,java] +---- + @Test + void aRefundGoesBackTheWayTheOrderWasPaid() { + assertThat(refunds.refund(Orders.PAID_BY_CARD, toBankAccount(100, "NL91ABNA0417164300"))) + .isEqualTo(new Outcome.Rejected("The order was paid by card, so its refunds go back to the card.")); + assertThat(refunds.refund(Orders.PAID_BY_BANK_TRANSFER, toCard(100, "4242"))) + .isEqualTo(new Outcome.Rejected( + "The order was paid by bank-account, so its refunds go back to the bank-account.")); + } +---- + +[source,java] +---- + @Test + void refundsNeverAddUpToMoreThanTheOrder() { + assertThat(refunds.refund(Orders.PAID_BY_CARD, toCard(3000, "4242"))).isInstanceOf(Outcome.Accepted.class); + + assertThat(refunds.refund(Orders.PAID_BY_CARD, toCard(2500, "4242"))) + .isEqualTo(new Outcome.Rejected("Only 2000 of the order is left to refund.")); + assertThat(refunds.refund(Orders.PAID_BY_CARD, toCard(2000, "4242"))).isInstanceOf(Outcome.Accepted.class); + } +---- + +Ten ways to be invalid are ten rows in one behaviour test; they are one contract test. + +[#run] +== Step 7: Run it + +[source,console] +---- +./gradlew check +---- + +23 tests pass: 4 contract tests, 4 guards, 15 behaviour tests. +The REST Docs snippets of the four responses are in `build/generated-snippets/`, and the service's coverage is in `build/reports/jacoco/test/html/index.html`; the generated classes are not measured, since each carries an annotation that JaCoCo skips. + +[#contract-changes] +== Step 8: When the contract changes underneath + +[#constant-removed] +=== A constant the hand-written code uses disappears + +Remove `maximum` from `AmountV1.yaml`, raise the contract's version in `refunds.bundle.properties` and `gradle.properties`, and run `./gradlew check`: + +[source,text] +---- +src/test/java/com/example/contract/RefundRequests.java:25: error: cannot find symbol + public static final int TOO_LARGE_AN_AMOUNT = AmountV1.MAXIMUM + 1; + ^ + symbol: variable MAXIMUM + location: class AmountV1 +---- + +The build stops at the line that relied on the contract, before any test runs. + +[#member-renamed] +=== A member the hand-written code types is renamed + +Rename `cardLast4` to `lastFourDigits` in `RefundRequestV1.yaml`, raise the version, and run `./gradlew check`. +Everything compiles. +Every contract test still passes: the service is unchanged, and still accepts `cardLast4`. +Only the guard fails: + +[source,text] +---- +[openapi/components/schemas/RefundRequestV1.yaml changed; check RefundRequests against it, then update WRITTEN_AGAINST] +expected: "f20b627c2a6f8effb19706bfc3bc3b2854a494c0b8bcf79598c46db8666529b6" + but was: "f2f323a2ddd3337b792b243b2afe0b1936a0f82036f8d4f820d05ae10c5c962e" +---- + +Without that guard, the tests would go on sending a request the contract no longer describes, and passing. + +[#remedy] +== Step 9: Prefer fixing the contract, where you can + +The report gave a remedy: name the branches, so each becomes a component. +Most degradations have one -- an inline branch that could be a named component, an inline request schema that could be a fragment -- and taking it is usually the better fix: every consumer of the contract gets the generated body, not only your tests. + +Move each branch to a fragment of its own, `CardRefundV1.yaml` and `BankAccountRefundV1.yaml`, with the branch's content, and refer to them: + +[source,yaml] +---- +type: object +description: Where a refund goes, and how much of the order it returns. +oneOf: + - $ref: CardRefundV1.yaml + - $ref: BankAccountRefundV1.yaml +---- + +After raising the version, `./gradlew check` stops at the guard that said this would happen: + +[source,text] +---- +src/test/java/com/example/contract/RefundRequestsTest.java:25: error: invalid method reference + assertThatThrownBy(RefundRequestV1::body) + ^ + cannot find symbol +---- + +`RefundRequestV1` no longer has a body of its own; its Javadoc says where it is: + +[source,java] +---- +/** + * Generated from schema openapi/components/schemas/RefundRequestV1.yaml. + * + *

A body of it is one of: {@link CardRefundV1}, {@link BankAccountRefundV1}. + */ +@com.arc_e_tect.sedr.utils.jacoco.marker.ExcludeFromJacocoGeneratedCodeCoverage(justification = "Generated by API-Only TranscriberJ") +public final class RefundRequestV1 { +---- + +and each branch has one, with its `destination` written for you: + +[source,java] +---- + public static String body(int amount, String cardLast4) { + java.util.Objects.requireNonNull(cardLast4, "cardLast4"); + StringBuilder json = new StringBuilder("{"); + ContractJson.member(json, "destination", ContractJson.string(DESTINATION)); + ContractJson.member(json, "amount", String.valueOf(amount)); + ContractJson.member(json, "cardLast4", ContractJson.string(cardLast4)); + return ContractJson.close(json); + } +---- + +The branches are public, with REST Docs companions, so the contract tests use `CardRefundV1.body(...)` and `BankAccountRefundV1.body(...)` directly. +Delete `RefundRequests` and its test; the sample values still need the package-private constraint classes, so they stay in the generated package, in a class of their own. + +Hand-writing the body is the answer when the contract cannot change: someone else owns it, or the construct is what the API genuinely needs. + +[#result] +== What you have now + +* The request body the generator could not write, written by hand from the generated pieces, in the generated package of the test source set. +* Guards that fail when the request schema changes and when the generator can take over. +* One contract test per response, checking shape with the generated descriptors. +* The rules, and every way to break them, in behaviour tests. + +{example-url}[The worked example ↗] has all of it, with its own README.