DEV Community

Cover image for Silent Failure #5: The Suite Was Green Because It Asserted the Bug

Silent Failure #5: The Suite Was Green Because It Asserted the Bug

Renhua Wei on November 13, 2026

A tool-calling request went out of an OpenAI-compatible gateway with its envelope missing. The upstream answered 200, wrote a normal assistant mess...
Collapse
 
rushdeploy profile image
Rishabh Mishra •

I assert on the serialised string for outbound payloads: it catches exactly this kind of flattened envelope, where the parsed object still looks plausible. I still keep parsed-object assertions too, but only for the values inside tools[0].function, never for the envelope itself.

Collapse
 
dafili profile image
Renhua Wei •

Serialised-string assertions are the right call here, with one caveat: pin the key order or run a stable stringify, otherwise a formatting change fails a payload that is actually fine. Add one structural check next to it — JSON.parse(out).tools[0].function === undefined — because that names the exact flattening and the failure message tells you what broke instead of just that the string changed.

Collapse
 
rushdeploy profile image
Rishabh Mishra •

Fair point, I should have said that plainly. I use a stable stringify with sorted keys for the envelope check, and I keep the negative assertion alongside it so a flattened shape fails loudly instead of passing quietly.

Thread Thread
 
dafili profile image
Renhua Wei •

Then make that negative assertion prove it can fail: mutate one value inside the golden string with sed -i 's/"type":"function"/"type":"function_v2"/' test/fixtures/envelope.json and run only that file. If it stays green, the sorted-key comparison is being matched against a stale fixture and neither assertion is load-bearing.

Thread Thread
 
rushdeploy profile image
Rishabh Mishra •

Good catch — I'll try that mutation against the live serialised output, not just the stored fixture, so a stale copy can't mask it.
If it stays green, I'll treat the envelope check as unproven and rebuild it from the actual request body.
Thanks for pushing on the negative case; that's the part that makes the green run mean something.

Thread Thread
 
dafili profile image
Renhua Wei •

One trap with mutating the live serialised output: if anything downstream re-stringifies from the parsed object, the mutation is silently dropped and the check goes green for the wrong reason. Assert the mutant survived the round-trip first — expect(mutated).toContain('"type":"function_v2"') — then assert the envelope check throws on it. Two lines, and a discarded mutation can no longer impersonate a passing test.

Collapse
 
eye_java_420f1faa10ae8b86 profile image
Nan •

The sed-one-value trick deserves to be a permanent CI check rather than a one-off demo. For every golden payload, keep a corrupted twin next to it in the repo: same shape, one field deliberately wrong. The test suite asserts both directions: the real fixture passes, the twin fails. If a refactor ever makes the twin pass, the assertion is no longer testing what you think it tests.

The twin has to live in version control with the test, not in a scratch file. A twin that lives only in your head or in a gist goes stale the moment the schema drifts. In CI the pair can be generated from one template with the corruption applied programmatically, so the twin updates when the schema updates.

This is the part mutation frameworks charge for, but for one golden payload a hand-written twin and two assertions are enough.

Collapse
 
dafili profile image
Renhua Wei •

Then make the twin fail on the field, not on the parse: expect(() => assertEnvelope(twin)).toThrow(/tools\[0\]\.type/) — a twin that dies with Cannot read properties of undefined is a green tick for a check that never ran. Generate both fixtures from one template (makeFixture({ mutate: ['tools[0].function.type', undefined] })) so the corruption tracks the schema instead of drifting in its own file, and run the pair with bun test test/providers/buddy-donor-gaps.test.ts -t envelope in pre-push.

Collapse
 
nexusverify profile image
Nexus Verify •

The detail that stays with me: the provider's own suite was green because the test had quietly encoded the broken envelope as the expected one. A test that mirrors the implementation's assumption isn't testing the contract — it's a snapshot of the bug, and it will pass forever.

The discipline that catches this class is a negative control at the boundary. For every "well-formed input works" test, there should be one that sends the malformed shape on purpose — envelope missing, key dropped — and requires the far side to reject it loudly instead of answering 200 with silence. And periodically: break the thing deliberately and watch the suite. If nothing turns red when you remove the "type" field by hand, the suite was never measuring the contract in the first place.

Green is only evidence if the test was capable of being red.

Collapse
 
dafili profile image
Renhua Wei •

Two assertions would have caught it and still read as normal tests: expect(payload.tools[0].type).toBe('function') and expect(payload.tools[0].function.name).toBe('find'). On the broken commit 05ffff40 the first one throws before the second ever runs; on main both pass. If a test file never mentions function as a key, treat that file as untested, not as coverage.

Collapse
 
sebastian_gedda_08bcd28f8 profile image
Sebastian Gedda •

"The assertions encoded its absence" is such a good way to put it. Same trap shows up with hand-written mocks on the consumer side: if the fixture is written to match what the code produces, the test agrees with the bug forever. The negative assertion tip is great. The other thing that's helped me is keeping at least one fixture captured from the real upstream, so the contract shape comes from the wire and not from whoever wrote the test. And re-record it now and then and diff against the stored version, so you catch when the real API drifts away from your mock.

Collapse
 
dafili profile image
Renhua Wei •

A captured-from-wire fixture only stays honest if the re-record step is allowed to fail: write the fresh capture to resp.new.json and run diff <(jq -S 'paths|join(".")' stored.json) <(jq -S 'paths|join(".")' resp.new.json) || exit 1, so drift is a red job instead of a silently overwritten fixture. Re-record on a clock, not on demand — one capture per endpoint every 30 days, and if two successive captures diff clean, suspect the recorder's cache before trusting the green.

Collapse
 
enoch_hou profile image
Enoch Hou •

测试断言的是转换后的形状而不是合同本身,这个教训很深刻,序列化断言加反向断言的组合很实用,谢谢分享。