DEV Community

Cover image for Four Ways a Test Can Lie to You
Ken W Alger
Ken W Alger Subscriber

Posted on Originally published at kenwalger.com

Four Ways a Test Can Lie to You

AI coding reveals why green tests lie

Green and worthless: what verifying one small application taught me, when an AI agent wrote most of the code.

The Sanity DEV Challenge wrapped up last week, and The Cellar won Path Two. The judges called the build writeup “a study in making verification hard to fake,” which is a generous reading of what was mostly me discovering that my verification was broken.

My submission covered what I built. This is about the part of the build I keep thinking about: how often my checks were wrong.

I spent two weeks building a wine cellar application that can be read at any date in its history. One developer, one agent, about 530 tests by the end. Three of the four real bugs in the project were invisible to every check designed for the feature they lived in.

That was uncomfortable, so I went looking. What I found was worse than missing tests: checks that were green, or red, and meant nothing either way. There turned out to be four distinct ways that happens, and I hit all four in a project where I was being unusually careful.

Lab scientists have had vocabulary for this for a long time. An experiment needs a positive control, proof that your method can detect the effect when it's present, and a negative control, proof that it stays quiet when nothing is there. Each of the four lies below is a missing control, or a control pointed at the wrong thing.

First: the test that cannot fail

No positive control.

The application resolves a wine's drinking window from dated claims. Claims can be proposed by an agent and must be accepted by a person, and a proposed claim is supposed to change nothing until someone accepts it.

That is the load-bearing rule of the whole review workflow, so it got a test. Build the cellar, inject a proposed claim, assert that all 542 bottle states are byte-identical. Green.

It was measuring the fixture, not the rule. Resolution picks the highest authority tier first, then the most recent claim within that tier:

Claim Tier Dated Wins?
The incumbent personal 2024 yes
The one under test personal 2019 no, loses on recency

Both claims are the same tier, so the one under test loses on recency, and it loses whether it is proposed or accepted. Delete the entire review-state filter from the source and the test still passes.

What caught it was a companion assertion written for a different reason: accepting must change at least one state. That one failed, which is how I learned the first one was vacuous.

The rule falls straight out of it. Every test asserting that X has no effect needs a paired assertion showing the effect exists when X is enabled. Without the pair, you haven't tested that X is inert. You've tested that your fixture was inert.

An audit of the rest of the suite found two more of the same shape. One claimed to check that a bottle opened during a period doesn't count as a missed opportunity, but two separate mechanisms excluded that bottle, so the clause under test could be deleted without anything turning red.

I assumed that clause couldn't be isolated and told the agent to fall back to a weaker assertion. It ran the experiment instead, and found a fixture by realizing that "now" in this system is a parameter rather than the clock. As of June 2024, what had I missed that year? is an ordinary question to ask a time machine, and it made the case constructible. The fix was better than the instruction.

All three fixes were verified by mutation: delete the clause the test claims to cover, and watch it finally fail. The original fixture stayed in the suite, renamed to say what it actually tests, because a record of the mistake is more useful than a quiet deletion.

Second: the check that always fails

No negative control.

A week later, building a public static bundle, I added a grep of the built output for leaked API credentials. Five seconds to run, and the failure it catches is unrecoverable, so it seemed like an obvious win.

It fired on every clean build. The pattern was sk[A-Za-z0-9]{20,}, and it matched skipCrossDatasetReferenceValidation, a legitimate export name inside @sanity/client.

This is the mirror image of the first lie, and in practice it's worse. A test that never fails is invisible: nobody notices it isn't doing anything. A check that always fails is noticed immediately, and then it is dismissed, every time, by the person who knows it's a false positive. After a week, the real failure it exists to catch would scroll past with the rest.

A check that always fails certifies exactly as little as one that never can. It just looks more diligent while doing it.

The fix was small. The bound went from twenty characters to forty, which real credentials clear easily and that export name doesn't. Then the scan moved into CI, where it runs on every push instead of whenever someone remembers. A check that always fails is worthless, and a check nobody runs is worth the same.

In control terms, the original grep had no negative control: nothing showing it stays quiet on a build known to be clean. One run against a clean build was all it took to expose it, which is how it was found, by running it rather than shipping it. The other half, proving the tightened pattern still fires on a planted fake credential, is the test I'd add next.

Third: the correct test of a wrong contract

Both controls present, measuring the wrong thing.

This one is the hardest to spot, because there is nothing structurally wrong with the test at all.

The application has a date control spanning 1996 to 2042. Typing a date into it worked for month and day, and the moment you started typing the year, the field snapped back to 1 January 1996. You had to finish with the picker.

The cause is a genuinely surprising piece of browser behavior. A native date input never reports partial text. It reports a complete, well-formed date with the year zero-padded to four digits. Typing 2023 into a field already holding a month and day emits this:

0002-12-31   ->  clamped to the 1996 floor
0020-12-31   ->  clamped to the 1996 floor
0202-12-31   ->  clamped to the 1996 floor
2023-12-31   ->  accepted, but the field was overwritten three keystrokes ago
💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด 💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด

Every intermediate value is a real, valid date. Every one is out of range. Every one got clamped, and the controlled input wrote the floor back over the edit.

There was a test covering this. It asserted that parseDateInput('1901-01-01') returns the 1996 floor. It passed, and it would have failed if clamping broke. Positive control, negative control, meaningful assertion, all present.

The contract it encoded was wrong. Clamping an out-of-range date is correct when the user has finished entering it and wrong while they're still typing, and the test had no way to know the difference because the specification didn't either.

That's the dangerous category, because the first two look like mistakes once you see them and this one doesn't. An assertion about clamping out-of-range input reads as diligence. It would survive code review. It survived mine. I covered this failure mode in more depth in The Contract Discovery Bottleneck.

Fourth: the right test against the wrong system

Controls present, pointed at something else.

To test the review workflow safely, I set up a staging copy of the dataset. The plan: point the authoring tool and the application at staging, propose a claim, accept it, and watch the right bottles change state, all without touching production.

The authoring tool honored the environment variable that selects a dataset. The application, built by the same command with the same variable set, silently ignored it. Its built bundle was byte-identical to a build with the variable unset, falling back to production. No warning, no error. It built and ran perfectly.

So the authoring tool would have written a proposal into staging while the application reported production. Every number on the screen would have been internally consistent, and about a different cellar.

No amount of rigor in the test helps here. The assertions can be perfect and still certify the wrong thing, because the question that came first, which system am I actually looking at?, was never asked.

The fix had two parts. The application's dataset became a hand-edited literal, so it can't be silently wrong: any change to it shows up in the diff. And the application now displays the dataset name in its header whenever it isn't production. The reasoning matters more than the fix. The question was never how to point it at staging. It was whether I was sure which dataset I had just written to.

The examples are the paths you already walk

Separate from all four, and the one I think about most.

The same date control had an arithmetic bug: the span between 31 January and 1 March computed wrong, because the implementation borrowed days from February and silently dropped the remainder.

I had five verification dates for that feature. They were chosen carefully, from real data, before the code existed. They're the dates in the demo video and the screenshots.

All five passed under the buggy version. The application worked. The screenshots were right. Every date I planned to show anyone was fine.

What found it was a property test across all 17,167 positions the slider can occupy, asserting an identity that has to hold for any pair of dates. The bug lived in a region nobody would drive to on purpose.

Demo-driven verification proves the paths you already walk. That's not nothing, and it's not what you think you're getting.

What actually worked

None of it was "write more tests."

Oracles generated before the implementation, with an explicit instruction not to repair them. Expected outputs computed independently and committed before the code existed. The prompt said to treat them as an oracle rather than as output to be fixed, and to stop and report on any disagreement. When a test fails, the cheapest available move is adjusting the fixture, and that instinct needs to be foreclosed in advance rather than resisted in the moment.

Mutation, but only on the tests that matter. Delete the clause the test claims to cover. If it stays green, the test is decorative. This found two of the three vacuous tests, and the agent started doing it unprompted after the first.

Measurement instead of reasoning, on anything with a number in it. A performance bug hid in four lines of correct-looking memoization that had already passed review. Rebuilding an index cost 6 ms; recomputing the thing the index served cost 0.07 ms. The code memoized the wrong one. Nothing about reading it revealed that. Benchmarking both took ten minutes.

A gate that named what would not count as proof. For checks that needed eyes on a rendered screen, I wrote out the full path being tested and then listed the ways it could be faked: don't satisfy this by calling the module from a script, querying the data another way, or pointing at the test suite. Then: you cannot see a browser, so do not report this as passed. Give me the command, the URL, and the expected numbers, and I'll confirm.

That last one matters more in an AI-assisted build than it used to. A model asked to confirm something it cannot observe is under real pressure to confirm it anyway. The fix isn't trusting it less. It's giving it an honest third answer between passed and failed: not verified, here's how you check. A result nobody could observe isn't a pass, and a harness that can't say so will round it up to one.

Agents are good. They aren't exempt.

The agent wrote nearly all of the code in this project, tests included. So it's worth being plain about who wrote most of the lies above: the same tool that wrote the code they were checking.

That isn't an indictment. Over two weeks the agent was better than I am at a lot of this. Before every stage, it read my specification against the platform and found errors in it; by the eighth session, that pass was turning up ten conflicts at a time, two of them outright mistakes in my spec. When I proposed testing a Function against production with a trigger scoped to a document type that didn't exist, it declined, correctly: scoping a trigger is safe, but the only way to invoke one is to write a matching document, and my plan would have left two permanent mutations in production's history to avoid creating a scratch dataset. It found the fixture I told it was impossible. It measured the cost of a feature I hadn't asked it to measure.

It was also wrong in ways a careful human would recognize. It confidently reported that an API couldn't do something the documentation demonstrated, then fixed it in one line once asked to check rather than assert. It reached the right conclusion about date comparison for the wrong reason, which is right only until the case changes. Asked for a list of the polish work that remained, it drafted the list from the build plan instead of the code, and three of the items were already done. A plan describes work that was scheduled, not work that is left.

And it broke rules. Twice, after being told not to run git commands that change the repository, it ran git checkout to undo its own uncommitted edits. Earlier, it had modified an oracle file it was explicitly told not to touch, then restored it. Every time, it reported the violation unprompted, before I found it. "The agent broke a rule and told me" is more useful data than either cleaner story, "it never broke a rule" or "it broke a rule and I caught it." The rule now says the file tools are the only way to revert anything, including its own edits.

The pattern underneath is the part worth keeping. An agent writes tests the way it writes code: quickly, fluently, plausibly, and from the same understanding of the problem. When one tool writes an implementation and its tests from the same prompt, they share one set of assumptions, and a wrong assumption gets encoded twice and then agrees with itself. That's the third lie, and agents make it cheaper to commit at scale. Speed doesn't remove the verification problem. It moves the bottleneck there, which I've written about before.

What changed the outcome wasn't trusting the agent less. It was making verification structurally hard to fake: oracles committed before the code, controls on every check, gates that named what wouldn't count, and final confirmation in the hands of the one participant with eyes. Under those conditions, the agent didn't just pass the checks. It started improving them.

It happened again

A month later I built a small experiment harness for something unrelated, comparing small language models against plain code on a Raspberry Pi. I knew this taxonomy by then. I found the same lies anyway.

A fixture that couldn't distinguish the behaviors it was meant to compare. A metric that rewarded a model for always giving the safe answer, which is a check that always passes wearing a lab coat. And a missing directory that ran an entire round of questions against an empty reference corpus, producing a detailed, confident, wrong write-up: the right test against the wrong system, again.

Knowing the categories didn't prevent them. It made them faster to recognize, which is the most I'd claim for any checklist.

The honest limit

The oracles catch implementation bugs. They can't catch specification bugs, because I generated them from the same specification.

One of them was stronger by accident. The missed-opportunity oracle used brute-force evaluation of every day in a period, while the implementation used a boundary scan that only evaluates the dates where state can change. Two genuinely different algorithms agreeing is better evidence than one algorithm agreeing with itself, for the same reason a winemaker checks a refractometer against a hydrometer: instruments that fail differently are unlikely to fail together. I wrote about that idea recently, too. But even that oracle shares the specification's assumptions about what a missed opportunity is.

Tests tell you the implementation disagrees with your expectations. They can't tell you whether your expectations deserve to win.

What I'd keep

Four habits, one per lie:

Give every inertness test a positive control. If it asserts that something has no effect, prove the effect exists when it should.

Give every alarm a negative control. If it can't be shown passing on known-clean input, it isn't a check. It's noise with a red icon.

Test properties across the domain, not just the examples. Your examples are the paths you already know work, and the contract is only as good as the cases you thought to ask about.

Verify the target before the result. Make the system under test visible on the screen and in the diff, so "which one am I looking at?" is never a guess.

None of those habits is about AI, and that's the point. The agent made every one of these lies cheaper to commit and faster to find. It didn't change which checks were honest.

The rest is the usual advice, and it wasn't what saved me.


This came out of building The Cellar, an event-sourced wine collection for the Sanity DEV Challenge. The full build is documented in the friction logs in that repository, including every check above and the audits that found them.

Top comments (46)

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
gwerk profile image
Guillermo Baigorria •

One more way a test can lie, when the thing you test is an AI model: the same version, run twice with nothing changed, doesn't always give the same results.

So before I trust that a prompt change helped, I run the old version twice and count how many cases changed on their own. That's my noise floor. If the new version changes fewer cases than that, the "improvement" may just be chance.

It's your negative control, applied to a model: a scale that drifts a pound on its own can't tell you that you lost half a pound.

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The paired-assertion rule is the one I'd steal — a test that X has no effect means nothing without the companion proving the effect exists when X is enabled. Your mutation check (delete the clause, watch it finally fail) is the honest version of coverage. Do you run that check across the whole suite now, or only on the tests you already suspect?

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
kenwalger profile image
Ken W Alger •

Targeted, not suite-wide. I haven't automated mutation testing across the full suite, and the checks I describe were performed manually during the audit.

I started with tests that looked suspicious, but I wouldn't want suspicion to be the permanent selection criterion. Some of the most dangerous vacuous tests look perfectly ordinary until you deliberately remove the behavior they're supposed to protect.

My longer-term preference would be to require positive controls and targeted mutation checks for load-bearing invariants, particularly authorization, state integrity, and irreversible operations. That gives the practice a defined scope without pretending every assertion needs the same level of scrutiny.

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

That scope is the honest middle — requiring positive controls plus mutation checks only where the invariant is load-bearing, rather than pretending every assertion deserves the same scrutiny. The selection problem you flag is the interesting one though: if suspicion can't be the permanent criterion, what replaces it — is it the invariant's blast radius, or do you rotate the target set so the ordinary-looking tests get their turn too?

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด Thread
 
kenwalger profile image
Ken W Alger •

I'd use both, but for different purposes. Blast radius would determine which tests get mandatory positive controls and mutation checks. Anything protecting authorization, data integrity, irreversible operations, or a central domain invariant should qualify regardless of whether the test looks suspicious.

But that still leaves the selection problem you identified. We don't necessarily know which ordinary-looking tests are protecting assumptions that turn out to be important. I'd use a rotating sample of the remaining suite, deliberately including tests that have been green for a long time and haven't attracted much attention. When one fails a mutation check, I'd look for other tests built around the same assumption or fixture pattern rather than treating it as an isolated defect.

I haven't implemented that rotation in Cellar, so this is how I'd approach scaling the practice rather than something I've already validated. The original audit taught me that suspicion is useful for finding the first problem, but it's not a reliable coverage strategy.

The goal wouldn't be to mutation-test everything eventually. It would be to make the selection criteria explicit and use what the sampled tests reveal to improve them.

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

Using both is the honest answer — blast radius for the mandatory set, rotation for everything else. The part I'd underline is treating a failed mutation check as a signal to audit the fixture pattern, not just the one test: that's where a single vacuous test becomes a systemic find. Do you rotate on a schedule, or does a change to a shared fixture trigger its dependents' turn?

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด Thread
 
kenwalger profile image
Ken W Alger •

I'd favor a change to a shared fixture triggering a review of its dependents, with periodic sampling to catch problems that don't announce themselves through a change. But that's a proposed approach, not something I've implemented in Cellar.

The larger lesson from this audit was that one vacuous test can reveal a shared assumption affecting several others. That's the part I'll carry forward into future projects.

Thanks for the thoughtful discussion!

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

That shared-assumption lesson is the part worth carrying forward — a vacuous test is rarely alone, since the fixture pattern behind it usually feeds several others. And the honesty about it being a proposed approach, not yet implemented, is exactly what makes it usable: it reads as a direction, not a claim. Good luck with the next audit — if the rotation ever surfaces a long-green test that fails its mutation check, I'd be curious what assumption it was quietly protecting.

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
heyvaldemar profile image
Vladimir Mikhalev •

A fifth one from container scanning: the gate that cannot tell "nothing critical" from "nothing read".

trivy image --exit-code 1 --severity CRITICAL busybox:1.37 exits 0 with no warning. Trivy detects no OS in that image, so it scans no packages, and the report has no Results at all. The signed cosign-vuln attestation carries the same absence, and it reads as clean. A positive control catches it only if the control image is one the scanner can read: alpine:3.10 fails on CVE-2021-36159.

The clause I added is your "not verified" outcome written into the admission policy: no severity is counted until the report has a Results entry of class os-pkgs. An empty scan is a missing scan. I reported the exit code upstream: trivy discussion #11403

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
kenwalger profile image
Ken W Alger •

That's an excellent fifth example. The distinction between "no critical vulnerabilities found" and "no OS packages examined" is exactly the kind of evidence boundary I was trying to get at.

What's particularly interesting is that signing the attestation doesn't resolve the problem. It can establish the provenance and integrity of the report without establishing that the report actually covered the intended material.

I like your admission-policy requirement for an os-pkgs result. It makes successful coverage a prerequisite for interpreting the severity findings, rather than allowing an empty result to masquerade as a clean one.

Thanks for sharing the concrete reproduction and upstream discussion. This is a particularly useful example of why verification has to establish what was examined before interpreting what was found.

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
heyvaldemar profile image
Vladimir Mikhalev •

Your split is the one I would keep: the signature says who produced the report and that nobody changed it since, and nothing about what it read. A Trivy maintainer answered the discussion on 27 October: no warning and no fail-on-empty are planned, since an image with no detected OS is expected for many targets, a scratch image with one binary among them. The one trace of the miss is the line OS is not detected., printed under --debug only.

So the coverage check stays on the consumer's side, written into the policy as a required os-pkgs result. The scanner will not say it for you.

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
lin7c profile image
xcl •

The positive and negative control framing is a much better vocabulary than just calling these weak tests. The first example is the one I recognise most: a test that was measuring the fixture rather than the rule, because the competing claim lost on recency regardless of its review state. Deleting the clause the test claims to cover is such a cheap mutation check, and it is telling that it found two more cases of the same shape. The always-failing grep is the mirror problem I worry about in CI, because once a team learns to dismiss a check as a known false positive, the check has effectively stopped existing. Do you apply the mutation step to every new inertness or alarm test as a routine gate, or only to the load-bearing ones, given the time it would take to do it across a whole suite?

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
kenwalger profile image
Ken W Alger •

Only the load-bearing ones, at least for now. I haven't made mutation testing a routine gate across the entire suite, and I wouldn't want to suggest otherwise. In this project, I used it where a passing test was supposed to establish an important behavioral boundary, particularly assertions that something cannot affect the result.

The proposed-claim example is a good illustration. The negative assertion was green, but deleting the review-state filter left it green. The paired positive assertion exposed the problem, and mutation confirmed that the repaired test actually depended on the rule it claimed to cover. I found two more cases with the same basic problem.

I think the practical approach is risk-based. If a test protects an authorization boundary, an irreversible operation, or a central domain invariant, I want evidence that it fails when the relevant protection disappears. For less consequential tests, applying that process universally could cost more than it returns.

And I agree about the always-failing grep. Once a team learns to ignore a red check, the check becomes operationally equivalent to one that never runs. Both create the appearance of verification without providing much of it.

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
gabrielsegura9 profile image
Gabriel Segura •

I agree

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
friga profile image
Artur Pokładek •

Your first one has a cousin I keep finding on client sites: an accessibility audit that's green because every image has alt="image". The check asks whether the attribute exists, and nothing in the pipeline ever reads what it says.

Do you have a control for that kind of check, or is that simply the point where a human has to look?

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
kenwalger profile image
Ken W Alger •

That's a great example of a check answering a much narrower question than the one we actually care about. The pipeline proves that an attribute exists, not that someone using a screen reader gets useful information from it.

I'd approach it with two layers. Automated checks can reject empty or generic values where descriptive alt text is expected, flag repeated descriptions, and identify suspicious patterns. But those are still heuristics. A human needs to assess whether the description communicates the image's purpose in context, including whether the image is decorative and should have empty alt text.

I'd want a known-bad control example, such as a meaningful image with alt="image", that the audit must reject. That at least proves the check can detect the failure it's supposed to catch. It doesn't eliminate the need for human review, but it makes the automated part more defensible.

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
friga profile image
Artur Pokładek •

A known-bad control is such a cheap fix too. One alt="image" left on a staging page on purpose, and the audit has to go red every time, or the audit is the thing that's broken.

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
alexshev profile image
Alex Shev •

The proposed-claim fixture is a great example of a test passing for the wrong reason. A useful companion is to mutate the review-state filter and require the test to fail; that checks the test can actually detect the rule it claims to protect.

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
kenwalger profile image
Ken W Alger •

Exactly, and that's what finally gave me confidence in the repaired test. Removing the review-state filter had to make the test fail. Before the repair, it stayed green because the fixture's recency ordering masked the missing rule.

I ended up finding two more tests with the same underlying weakness, and all three repairs were verified by deliberately removing the logic they claimed to protect. It's a simple experiment, but it changes the question from "does this test pass?" to "can this test actually detect the failure I care about?"

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
alexshev profile image
Alex Shev •

That is exactly the test I trust most: deliberately break the protected rule and watch the test fail for the expected reason. It is a small mutation test, but it catches fixtures that accidentally make a green result meaningless.

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
ivannovazzi profile image
Ivan Annovazzi •

Three of four real bugs slipping past every check built for the feature they lived in, that stat stuck with me. When the agent writes both the code and the tests, they can share the same wrong assumption, so green stops meaning anything. Did you ever break a function on purpose just to confirm something would turn red, or did that idea come later?

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
kenwalger profile image
Ken W Alger •

Yes, but that came later. It wasn't part of my original testing strategy, and that's probably the most important part of the story.

The turning point was a test asserting that a proposed claim couldn't change the cellar's state. It passed, but when I removed the review-state filter entirely, it still passed. The fixture had been constructed so the proposed claim would lose on recency anyway, regardless of whether the review rule existed.

After fixing that, I deliberately removed the relevant logic again to confirm the test would turn red. I applied the same approach to two other tests discovered during the audit.

That changed the question I was asking. Instead of just asking whether the implementation passes the test, I started asking whether the test can actually detect the failure it's supposed to prevent. It's a small wording change, but it makes a substantial difference in what a green result means.

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
golflover2023 profile image
golflover •

The paired-assertion rule deserves to be mechanical: if a test asserts "X causes no change", auto-generate its sibling that enables X and asserts the change exists - if you cannot write the sibling, the first test is vacuous. Mutation testing is the batch version: delete the review-state filter on purpose and count how many of the 530 tests go red. The scary number is not the 3 bugs that slipped through, it is how many of the 530 would stay green with their feature deleted. Did you ever run that count?

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
kenwalger profile image
Ken W Alger •

I didn't run a suite-wide mutation count, so I don't have that number. The mutations I performed were targeted: remove the review-state filter, confirm the relevant test fails, and repeat for the other cases uncovered during the audit.

I agree with making positive controls a more explicit requirement, although I'd distinguish generating a sibling assertion from proving that the sibling exercises the intended behavior. The original problem was precisely that the fixture made the rule irrelevant, so automatically generating another assertion wouldn't necessarily solve it.

I'd also be careful with the denominator. If removing the review-state filter leaves 500 unrelated tests green, that's not inherently concerning. What matters is how many tests claiming to protect that rule remain green after it's removed.

That's the measurement I'd want from a more systematic mutation-testing pass: not simply how many tests fail, but whether the tests responsible for each invariant can actually detect its absence.

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
challan116ux profile image
challan116-ux •

The control framing is what makes this more than a testing cautionary tale. A check with no positive control — nothing proving it can actually detect the thing it guards — produces green results that are really just unexamined results, and a suite of 530 of those feels safer than a suite of fifty honest ones while being the opposite. The detail that three of the four real bugs lived outside the view of the checks designed for their own feature is the sharpest part: the checks were not lazy, they were pointed at a model of the feature, and the bugs lived in the gap between the model and the thing itself.

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
kenwalger profile image
Ken W Alger •

I think you've captured the uncomfortable part of this really well. The tests weren't necessarily poorly written, and many of them were doing exactly what their assertions said they should do. The problem was that those assertions didn't establish the behavior I thought they established.

That's what made the three missed bugs so revealing. I had a substantial suite and plenty of green results, but the verification model had blind spots that the test count couldn't expose.

The distinction between testing our model of the feature and testing the actual behavior is one I'll be carrying into future projects. Thanks for putting it so clearly.

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
ascendra_ventures_92c21f2 profile image
Ascendra Ventures •

The recency example is the one that stays with me. The review state filter could be deleted and the test still passed, because the proposed claim lost on recency anyway. Nothing was wrong with the assertion. The setup simply made failure impossible.

Pairing every "X has no effect" check with a second test proving the effect exists when X is on is a cheap habit, and the targeted mutation idea from the comments makes it concrete: delete the clause, and if nothing goes red, the test was decoration.

Lie four worries me more on client work. An env var quietly pointing at production turns a green run into a statement about the wrong system. Kevin Hall's headcount band catching two of twelve domains mapped to the wrong company shows the same idea at the data level.

Your line about choosing tests by the consequence of a false green is how I would explain test budgets to a non technical owner. Do you keep a short list of the known bad controls per project?

💬 ชาวเน็ตแห่แชร์: kenwalger/four ways a test can lie to you ibf - สรุปดราม่าล่าสุด
 
kenwalger profile image
Ken W Alger •

Not as a formal project-wide catalog yet. In this audit, the known-bad cases were targeted at particular invariants, including removing the review-state filter and confirming that the repaired tests detected its absence.

I do think a small collection of known-bad controls would be useful, especially for authorization boundaries, data integrity rules, and other places where a false green has substantial consequences. It would also make those controls easier to preserve as the implementation evolves.

The important part would be keeping the controls tied to the behavior they demonstrate, rather than collecting fixtures that eventually become another set of assumptions nobody challenges. Thanks for connecting that back to the cost of a false green!

Some comments may only be visible to logged-in visitors. Sign in to view all comments.