Test AI-Written PATCH Handlers for Missing, Null, and Empty Values

AI-generated illustration. Should the update ship? Define the outcomes for omitted, null, empty, and replacement values before accepting the generated handler.
To test AI-generated code against requirements, turn each meaningful input distinction into an expected response and stored-state outcome. For a PATCH handler, decide what an omitted field, null, and an empty string mean before accepting the generated change.
Should the display-name update ship? A small contract helps you decide without leaving an implicit product choice to the coding agent.
Write the contract before reviewing the implementation
“Let users update their display name” leaves a question unanswered: how does a client say “leave it alone” versus “clear it”?
For this illustrative endpoint, assume:
- Requests contain JSON objects.
- The stored
displayNamecan be a string ornull. - Successful updates return HTTP
200with the resultingdisplayNamein the response body. - An empty string returns HTTP
422with the error codeINVALID_DISPLAY_NAMEand leaves the record unchanged.
These are example product decisions, not rules that PATCH automatically imposes on every API. Choose different semantics if needed, but make them explicit.
Each row starts independently with displayName = "Ada".
| Request body | Required meaning | Expected response | Stored displayName afterward |
|---|---|---|---|
{} |
Leave the field unchanged | 200, body contains displayName: "Ada" |
"Ada" |
{"displayName": null} |
Explicitly clear the field | 200, body contains displayName: null |
null |
{"displayName": ""} |
Reject the empty value without mutation | 422, body contains error: "INVALID_DISPLAY_NAME" |
"Ada" |
{"displayName": "Grace"} |
Replace the field | 200, body contains displayName: "Grace" |
"Grace" |
The distinction matters: absence means preserve; null means clear. Treating them identically cannot satisfy this contract.
Use a counterexample to expose the ambiguity
Consider this JavaScript assignment inside a proposed handler:
const nextDisplayName =
body.displayName ?? existing.displayName;
For an omitted field, body.displayName is undefined, so the expression preserves "Ada". That matches the first row.
For an explicit null, it also preserves "Ada". The contract requires storing null, so the second row exposes the bug.
The expression passes an empty string through unchanged. Without separate validation before persistence, the third row fails too.
The handler needs to distinguish field presence from field value. For an ordinary parsed JSON object, Object.hasOwn(body, "displayName") can support that distinction. The handler must still validate the supplied value and persist only an accepted update.
Generate expected outcomes from the agreed requirement, not from the implementation. Otherwise, generated tests could encode the same mistaken fallback.

AI-generated illustration. Check both the handler’s response and an independent read of stored state. A correct response alone does not establish that persistence behaved correctly.
Check the response and the persistence path
A correct response does not prove that the database changed correctly. Checking only stored state misses incorrect status codes or misleading response bodies.
The illustrative JavaScript function below checks both. Its adapters are project-specific placeholders, not a library API:
createFixturecreates a fresh record in an isolated test store.patchinvokes the handler through your chosen request path.readStoredindependently reads the resulting record, rather than reusing the response or an in-memory object.disposecleans up the fixture.
import assert from "node:assert/strict";
async function checkDisplayNameContract(createFixture) {
const cases = [
{ name: "omitted", body: {}, status: 200, stored: "Ada" },
{
name: "null",
body: { displayName: null },
status: 200,
stored: null,
},
{
name: "empty",
body: { displayName: "" },
status: 422,
stored: "Ada",
},
{
name: "replacement",
body: { displayName: "Grace" },
status: 200,
stored: "Grace",
},
];
for (const example of cases) {
const fixture = await createFixture({ displayName: "Ada" });
try {
const response = await fixture.patch(example.body);
assert.equal(response.status, example.status, example.name);
if (example.status === 200) {
assert.equal(response.body.displayName, example.stored);
} else {
assert.equal(response.body.error, "INVALID_DISPLAY_NAME");
}
const stored = await fixture.readStored();
assert.equal(stored.displayName, example.stored);
} finally {
await fixture.dispose();
}
}
}
This example has not been run against your endpoint. Adapt it to your test framework and use only isolated test resources, never live customer records.
Keep the fixtures independent. A shared record lets one update change the next case’s starting condition, obscuring what a failure means.
Use the tests as a bounded acceptance gate
Before accepting the diff, confirm the table with whoever owns the requirement. Compare the handler against each row, then run the acceptance tests through the intended persistence path.
If an expected result changes, ask whether the requirement changed or the implementation drifted. Do not simply edit the assertion to match the output.
Passing these tests shows that the declared examples behaved as expected in the tested environment. It does not establish correctness for concurrent updates, other fields, malformed bodies, every possible string, or a different production persistence configuration. Checking displayName alone also does not establish that every other column remained unchanged.
Whitespace-only strings, length limits, and non-string values are useful next decisions if they are in scope. Decide their intended behavior before adding cases.
Use this contract as a starting point to gate your first AI change with ThunderLang.