An AI coding agent implements a feature, generates tests, runs them, and opens a pull request with a green build.
That looks like independent verification. It often is not.
If the implementation and the tests came from the same prompt, repository context, and interpretation of the requirement, they can repeat the same mistake. The code is wrong. The tests confidently prove that the wrong behavior works exactly as implemented.
The failure is not that AI cannot write tests. The failure is treating correlated output as independent evidence.
The Test Passed Because It Copied the Assumption
Imagine a billing requirement:
Apply a 20% discount to the order subtotal, then add tax to the discounted amount.
The intended calculation is:
subtotal = 100.00
discount = 20.00
taxable amount = 80.00
tax = 8.00
final total = 88.00
An agent misreads the requirement and calculates tax on the undiscounted subtotal instead of the discounted amount:
export function calculateTotal(subtotal, taxRate, discountRate) {
const tax = subtotal * taxRate;
const discount = subtotal * discountRate;
return subtotal + tax - discount;
}
When the same agent generates the unit test in the same session, it applies the exact same faulty mental model (100 + 10 - 20 = 90). The generated test passes:
import { describe, expect, it } from "vitest";
import { calculateTotal } from "./billing.js";
describe("calculateTotal", () => {
it("applies a discount and tax", () => {
expect(calculateTotal(100, 0.1, 0.2)).toBe(90);
});
});
The test looks reasonable at a glance. It uses concrete inputs, asserts a specific number, and produces a green build.
Because the $2.00 gap (subtotal * taxRate * discountRate) is small, a reviewer skimming the pull request can easily miss that 90 came from the agent's own implementation rather than the requirement (88). The shared assumption becomes a production billing bug.
Coverage Is Not an Oracle
Code coverage answers questions such as:
- Was this line executed?
- Was this branch visited?
- Was this function called?
It does not answer:
- Was the expected value correct?
- Did the test encode the product requirement?
- Would the test fail for a plausible wrong implementation?
- Did the system preserve a business invariant?
A generated test suite can reach 100% line coverage while asserting the implementation's current behavior back at itself.
This pattern is especially weak:
const result = calculateTotal(order);
expect(result.subtotal).toBe(order.subtotal);
expect(result.tax).toBe(result.taxableAmount * order.taxRate);
expect(result.total).toBe(
result.subtotal + result.tax - result.discount
);
The test reconstructs the same formula used by the implementation. If the formula is wrong, both sides remain wrong together.
A test needs an oracle: an independent reason to believe the expected result is correct.
Shared Context Creates Correlated Failures
When one agent writes the code and tests in the same session, both artifacts may inherit:
- the same ambiguous requirement
- the same incomplete repository context
- the same mistaken domain assumption
- the same missing edge case
- the same naming-driven interpretation
- the same outdated example
Regenerating the test in the same conversation may not add meaningful independence. The model still sees its earlier reasoning and implementation.
Even using a second model is not automatically enough. If both models receive the same ambiguous prompt and derive expectations from the same implementation, they can converge on the same attractive mistake.
The goal is not merely a different generator. The goal is a different source of truth.
The Dangerous Test Patterns
1. Asserting the implementation against itself
expect(formatPrice(input)).toBe(formatPrice(input));
This extreme example is obvious, but indirect versions are common. A test may call the same helper, parser, mapper, or formula as the production path.
2. Snapshotting unknown behavior
expect(calculateInvoice(order)).toMatchSnapshot();
The first snapshot records whatever the implementation produced. Approving it proves that someone accepted the output, not that the output matches the requirement.
Snapshots are useful for detecting change. They are weak at establishing initial correctness when nobody verifies their meaning.
3. Mirroring private implementation details
expect(cache.internalEntries.size).toBe(1);
The test confirms structure rather than observable behavior. It may pass while users receive stale data, and fail during a safe refactor.
4. Testing only the example from the prompt
If an issue contains one example, an agent will often reproduce it as one happy-path test. That example may not distinguish competing interpretations.
5. Mocking away the failure
paymentGateway.charge = vi.fn().mockResolvedValue({ status: "paid" });
The test proves behavior for a dependency that always succeeds instantly. It says nothing about timeouts, duplicated responses, uncertain outcomes, or retries after the remote side processed the charge.
A quick review question For every generated assertion, ask: > Where did this expected value come from? If the answer is "from the implementation," "from the agent's earlier output," or "from a snapshot we did not inspect," the test is not independent enough for a critical behavior.Start With Invariants, Not Examples
Examples verify selected points. Invariants describe properties that must hold across many inputs.
For a discount calculation, useful invariants might include:
- a discount cannot increase the pre-tax amount
- a 0% discount changes nothing
- a 100% discount produces no taxable amount
- the total cannot be negative
- changing item order cannot change the total
- rounding happens at the boundary defined by the business rules
Property-based tests can exercise these rules across generated inputs:
import fc from "fast-check";
import { expect, it } from "vitest";
import { calculateTotal } from "./billing.js";
it("never increases the total when the discount increases", () => {
fc.assert(
fc.property(
fc.double({ min: 0, max: 100_000, noNaN: true }),
fc.double({ min: 0, max: 0.3, noNaN: true }),
fc.double({ min: 0, max: 1, noNaN: true }),
fc.double({ min: 0, max: 1, noNaN: true }),
(subtotal, taxRate, firstDiscount, secondDiscount) => {
const lower = Math.min(firstDiscount, secondDiscount);
const higher = Math.max(firstDiscount, secondDiscount);
const totalWithLowerDiscount = calculateTotal(
subtotal,
taxRate,
lower
);
const totalWithHigherDiscount = calculateTotal(
subtotal,
taxRate,
higher
);
expect(totalWithHigherDiscount).toBeLessThanOrEqual(
totalWithLowerDiscount
);
}
)
);
});
This still does not prove every domain rule. It does make it harder for one convenient example to hide a broken relationship.
Derive Acceptance Tests From a Separate Source
The strongest acceptance tests should come from artifacts that existed independently of the implementation:
- product acceptance criteria
- API contracts
- regulatory rules
- protocol specifications
- previously verified behavior
- production examples approved by domain experts
- compatibility fixtures from another implementation
For the billing example, create a decision table before asking an agent to implement it:
Case: percentage discount before tax
Subtotal: 125.00
Discount: 20%
Tax rate: 8%
Expected discount: 25.00
Expected taxable amount: 100.00
Expected tax: 8.00
Expected total: 108.00
Then convert that independently reviewed fixture into a test:
it("taxes the amount after the percentage discount", () => {
expect(calculateTotal(125, 0.08, 0.2)).toBe(108);
});
Because the expected value (108) was derived from the specification before the code existed, the test immediately rejects the 110 produced by taxing the undiscounted subtotal.
Make the Test Fight the Implementation
Mutation testing changes the code in small ways and checks whether the tests fail.
For example, a mutation tool might replace:
const taxableAmount = subtotal - discount;
with:
const taxableAmount = subtotal + discount;
or change:
if (total >= freeShippingThreshold)
into:
if (total > freeShippingThreshold)
If the test suite stays green, it did not protect that behavior.
A mutation score is not a perfect quality metric. Equivalent mutations and costly test runs are real constraints. But targeted mutation testing is valuable for authorization, billing, retries, idempotency, limits, and other logic where a plausible one-character error matters.
Meta reported a related change-aware approach in its 2026 work on Just-in-Time catching tests. Instead of only generating tests that pass on the proposed code, catching tests are designed to surface regressions before a change lands. In an evaluation of 22,126 generated tests, change-aware methods improved candidate catch generation, and the reported process found confirmed serious failures. The important idea is practical: generated tests should actively try to falsify the change, not merely document it.
Use Differential Testing When You Have Two Implementations
Differential testing runs the same inputs through two independent implementations and compares the outputs.
This is useful during rewrites, migrations, and optimizations:
for (const order of generatedOrders) {
const legacyResult = legacyCalculator(order);
const newResult = newCalculator(order);
expect(newResult).toEqual(legacyResult);
}
The reference implementation does not need to be fast or elegant. It needs to be trusted for the behavior under comparison.
Be careful: if the legacy behavior contains known bugs, encode those exceptions explicitly. Otherwise, differential testing can preserve defects just as snapshots do.
Separate the Roles in the Workflow
A safer AI-assisted workflow creates friction between implementation and validation.
Step 1: Extract the contract
Before generating code, write down:
- inputs and outputs
- invariants
- boundary conditions
- error behavior
- security constraints
- examples that distinguish plausible interpretations
Step 2: Generate the implementation
Give the coding agent the contract, but do not let its implementation become the source of expected values.
Step 3: Generate adversarial tests in a fresh context
Provide another agent or session with:
- the contract
- public interfaces (without the implementation diff, to avoid priming the tester on the author's code structure)
- known failure classes
Ask it to find counterexamples that fail on the pre-patch branch first, not to improve coverage.
A useful prompt is:
Act as an adversarial test engineer.
Given the requirement and public interface:
1. List plausible incorrect interpretations.
2. Create inputs that distinguish them.
3. Identify boundary conditions and invariants.
4. Write tests that would fail for each plausible wrong implementation.
5. Do not derive expected values from the implementation under test.
Step 4: Run targeted mutation testing
Focus on critical decisions rather than mutating the entire repository on every commit.
Step 5: Review the oracle
Reviewers should spend less time asking whether the test is readable and more time asking:
- Why is this expected value correct?
- Which wrong implementation would this test reject?
- Is the test independent of production helpers?
- What happens at the exact boundary?
- Which requirement remains untested?
Step 6: Keep merge controls
A green agent-generated pull request still needs repository protections, required checks, and human review. GitHub explicitly requires human review before merging draft pull requests produced by its coding agent and layers security scanning and branch restrictions around generated changes.
That is not ceremony. It is recognition that generated code plus generated tests is evidence, not authority.
A Practical Review Example
Suppose an agent adds this rate limiter:
export function isAllowed(requestCount, limit) {
return requestCount <= limit;
}
It also generates:
it("allows requests within the limit", () => {
expect(isAllowed(3, 5)).toBe(true);
});
it("blocks requests above the limit", () => {
expect(isAllowed(6, 5)).toBe(false);
});
Both tests pass. Coverage may be complete.
But the critical question is whether the fifth request is allowed or whether limit represents the number of requests already consumed. The tests avoid the exact boundary where the interpretation matters.
Add the distinguishing case:
it("defines behavior at the exact limit", () => {
expect(isAllowed(5, 5)).toBe(false);
});
Whether false is correct depends on the contract. The key is that a human or independent specification must decide it. The implementation cannot decide its own requirement.
Actionable Checklist
Before trusting AI-generated tests, verify that:
- [ ] Expected values come from a specification, fixture, reference implementation, or reviewed calculation.
- [ ] Tests do not call production helpers to calculate their own expectations.
- [ ] Boundary values are tested explicitly.
- [ ] At least one case distinguishes plausible interpretations of the requirement.
- [ ] Important behavior is expressed as an invariant where possible.
- [ ] Snapshots were inspected for meaning before approval.
- [ ] Mocks include failures, delays, retries, and uncertain outcomes.
- [ ] Generated tests attempt to falsify the change, not only confirm it.
- [ ] Critical logic receives targeted mutation testing.
- [ ] Rewrites are compared against a trusted implementation where appropriate.
- [ ] A reviewer examines the test oracle, not only coverage.
- [ ] Agent-created pull requests remain subject to required checks and human review.
CTA: Pick one AI-generated test from your current project and trace every expected value back to its source. If the trail ends at the implementation itself, replace that assertion with an independent fixture, invariant, or boundary case.
The Takeaway
AI-generated tests are useful. They can produce scaffolding, cover routine branches, explore edge cases, and shorten feedback loops.
But tests generated from the same context as the implementation are not automatically independent validation.
They may encode the same misunderstanding twice:
incorrect requirement interpretation
β
incorrect implementation
β
matching incorrect tests
β
green build
Do not measure confidence only by the number of generated tests or the coverage percentage.
Increase independence instead:
- derive expectations from reviewed contracts
- test invariants and exact boundaries
- use fresh adversarial context
- apply mutation and differential testing
- review where each oracle came from
- keep humans responsible for the merge decision
A test suite is valuable when it can disagree with the implementation.
What is the most convincing green test suite you have seen that still allowed a production bug through?
Sources
- The Death of Traditional Testing: Agentic Development Broke a 50-Year-Old Field, JiTTesting Can Revive It
- Just-in-Time Catching Test Generation at Meta
- Risks and mitigations for GitHub Copilot cloud agent
Connect with Me
If you found this article helpful, let's connect!
- π» GitHub: johnnylemonny
Top comments (11)
Great article, John! πΈ
I really liked the point that AI-generated code and tests can share the same misunderstanding, so a green build isnβt necessarily independent evidence.
The idea of using a different source of truth, like contracts, invariants, boundary cases, or a fresh adversarial context, feels much stronger than simply asking another model to generate more tests.
I especially liked the focus on asking where each expected value actually came from. That feels like the real test of whether the suite can challenge the implementation rather than just agree with it. π
The oracle framing is the right one, and the trap you name under "asserting the implementation against itself" is the one I keep meeting in agent-written suites. One thing in the worked example does not hold, and it is checkable in a minute.
Take the two functions as printed. Intended: discount first, then tax on the discounted base, which gives 88.00. The snippet does this:
90.00. So the test asserting
.toBe(88)goes red against the code it is paired with, and the example ends up showing a test that disagrees with the bug rather than agreeing with it. It is also not a values problem: the difference is exactlysubtotal * taxRate * discountRate-- the tax charged on the discount, 2.00 here. I ran both formulas over 90,000 strictly-positive (rate, discount) pairs and found zero where they agree; they can only coincide when one of the two rates is 0. (88.00 is what a third implementation returns, the one that takes the discount on the tax-inclusive amount: (100 + 10) * 0.8.)The useful version of that fact is that the gap scales as the product of the two rates. At 10% and 20% it is 2.00 on a 100.00 order -- the size of a rounding difference, which is why a mismatch of this kind survives review. Your decision-table example is the right repair and this gives it a reason: push both rates up and the interpretations separate, 108.00 against 110.00 at 125.00 / 8% / 20%.
One addition to the mutation section, because there the correlation reappears one level down: a mutant set drawn from the implementation cannot see an oracle that drifted, since the mutants and the oracle read the same code. I scored two versions of one test against a battery in your vocabulary -- three syntactic mutants of the correct implementation, then one more drawn from the claim ("the discount is taken after tax"):
The drifted oracle there pins 110.00, which is what the buggy implementation produces at that point -- an expected value that came from the code. So step 3 of the workflow, listing plausible incorrect interpretations, is not a nicety: it is the only source of mutants that can separate a right expected value from a plausible wrong one, and it is why the adversarial tester should be asked for interpretations before it is asked for test cases.
To your closing question, the most convincing green suite I have seen reported this year was 42 integration tests that skipped themselves on every dependency pull request, with the required check satisfied by the skips.
Brilliant breakdown - especially pinpointing that 88 came from the tax-inclusive sequential math (100 + 10) * 0.8. Fixing that snippet!
βYour point on mutation testing is pure gold: if mutants are only drawn from the implementation, they inherit the exact same blind spot. Generating mutants from competing interpretations (claim-drawn) rather than just syntax flips is crucial to preventing oracle drift.
βAlso, 42 self-skipping tests satisfying CI is equal parts terrifying and hilarious. Thanks for such a high-signal contribution!
Love the core point, correlated output isn't independent evidence. Fun twist though: I ran the example by hand and the "wrong" calculateTotal(100, 0.1, 0.2) returns 100 + 10 - 20 = 90, not 88, so that generated test would actually fail π Which kind of proves your point: it looked right, and I almost scrolled past without checking the math, same as a green build.
Eagle eye! Youβre completely right β 100 + 10 - 20 = 90. π
Peak irony that a post about not blindly trusting plausible assertions had an assertion slip through because 88 just "felt" right. Thanks for being the independent oracle the article was advocating for - fixing the snippet now!
The leak I kept hitting when splitting the workflow into an adversarial test agent was giving that second agent access to the pull request diff.
Even when the tester prompt says to act as an adversarial tester and find bugs in this change, handing it the diff immediately primes the model on the implementer's variable names, error codes, and edge-case boundaries. It ends up writing tests tailored to the shape of the patch rather than the shape of the domain requirement.
The setup that actually caught mismatches for me was keeping the diff completely out of the tester's context. The tester gets only the issue description and the repo at the merge-base commit. It has to write a test that fails on main first, proving that the test is testing the missing requirement, before the candidate patch is merged to run against it. If the test cannot fail against the pre-patch commit, the test is usually either vacuous or asserting an internal helper that did not exist yet.
For the merge base check, I'd ask the tester to include the failure itself. An import error for a new public API makes the run red without exercising its behavior. For a fix behind an existing interface, the useful check is an assertion that runs on both revisions, fails before the fix and passes after it. If the interface is new, its absence on the old revision should be reported separately from a reproduced behavioral failure.
Critical nuance - an
ImportErrororTypeError: undefined is not a functionis not the same as a red behavioral test.Distinguishing compilation/interface absence from a true assertion failure on existing behavior prevents the agent from gaming the merge-base check. Requiring the failure output/stack trace as explicit evidence is definitely the missing piece for new APIs. Thanks for adding this!
Spot-on critique, including the diff in Step 3 is definitely an information leak.
Handing over the patch primes the tester to mirror the author's abstractions and assumptions, which partially defeats the fresh context.
Enforcing agentic TDD (requiring the test to fail on the merge-base before running against the PR) is a much cleaner filter. It proves the test actually has teeth and eliminates vacuous assertions or leaks of internal helpers. Stealing this workflow tweak!
The billing example is a good one because the gap is small enough to pass a skim. A $2 difference on a $100 order looks like rounding, not like tax applied in the wrong order.
The fix you describe, deriving acceptance tests from a separate source, matches what has worked for us on client builds. The expected values come from the written requirement before any code exists, ideally checked by the person who owns that requirement. The agent can then generate as many extra tests as it likes, but it cannot edit the ones that define done.
Mutation testing is the part I see teams skip because it feels slow. In your experience, is a targeted run on the changed functions enough, or do you need the full suite to catch this kind of shared assumption?
Targeted runs on changed functions (or diff-scoped) are more than enough β and practically the only way to make mutation testing viable in daily CI/PR workflows. Running it across the entire suite produces too much runtime overhead and equivalent-mutant fatigue for marginal extra gain.
The key to catching shared assumptions isn't test breadth across other files, but the oracle anchoring the mutated code:
Targeted AST mutations (e.g., swapping operators, zeroing discounts, flipping relational signs) on the modified functions immediately expose whether tests actually assert business logic or just mirror happy paths.
Locked acceptance tests: As you pointed out, the real safeguard is ensuring those independent acceptance fixtures can't be mutated or rewritten by the agent to match the implementation. As long as the targeted mutation runner executes against those locked fixtures, an inverted or missing requirement gets caught immediately.
Love that policy of yours: "the agent can generate extra tests, but cannot edit the ones that define done." That single constraint eliminates half of the prompt-drift risks.