Skip to main content
ThunderLang
← All articles
ai-engineering

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

5 min read · 2026-10-06 · Allen Codewell

A clay tile waits outside a closed gate with four differently shaped recesses.

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 displayName can be a string or null.
  • Successful updates return HTTP 200 with the resulting displayName in the response body.
  • An empty string returns HTTP 422 with the error code INVALID_DISPLAY_NAME and 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.

Two magnifying glasses separately inspect a blank envelope and an open drawer holding a blue bead.

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:

  • createFixture creates a fresh record in an isolated test store.
  • patch invokes the handler through your chosen request path.
  • readStored independently reads the resulting record, rather than reusing the response or an in-memory object.
  • dispose cleans 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.