How Software Development ChangedLesson 22 of 87

Contents

How Software Development Changed

Lesson 22 of 87. Sign in to keep your place.

Module 3 · Building oracles that stay ahead

The old version is your best oracle

10 min read

The strongest position you can be in

When the required behaviour is “exactly what it did before”, verification is close to free. You have a reference implementation, it is in git, and you can run both.

This is why refactoring is the best-suited task for generated code and why it is undersold in every discussion of these tools. The specification is perfect, the oracle is perfect, and the failure mode, a subtle semantic change, is precisely what differential testing catches and human review does not.

The basic shape

Python
import old_billing, new_billing
from hypothesis import given, strategies as st

@given(invoices())          # your own strategy for realistic invoices
def test_same_behaviour(inv):
    assert new_billing.total(inv) == old_billing.total(inv)

Keep the old module beside the new one for the length of the migration, run this in CI, and delete it when you are confident. Two hundred lines of temporary scaffolding is a small price for knowing.

Where the function is not pure, compare effects instead of return values: the rows written, the requests made, the messages emitted. Capture them and diff.

Real traffic beats generated inputs

A day of production requests contains edge cases nobody would invent: the customer whose name is one character, the order with 4,000 line items, the address field with an embedded newline, the request that arrives twice within the same millisecond.

Log-and-replay is the cheapest version. Sample inputs from production, store them, run both implementations over the file, diff the outputs.

Bash
# 10,000 sampled requests, both versions, structural diff
jq -c '.' sampled_requests.jsonl \
  | parallel --pipe -N100 'python run_both.py' \
  > diffs.jsonl
wc -l diffs.jsonl

Shadow traffic is the stronger version. Send every live request to both implementations, serve the response from the old one, and record where they disagree. The new implementation is exercised at full production volume and scale with zero user-visible risk. This is how large migrations are done safely and it is available to small teams too: a few lines in a proxy or a middleware.

One rule: the shadow path must not have side effects. Writing to the database twice, sending two emails, charging a card twice. Either point the shadow at a copy or make the write path a no-op, and check this before you enable it rather than afterwards.

Golden files, and their trap

Approval testing (run the code, save the output, commit it, fail when it changes) is the cheapest differential technique and the easiest to get wrong.

Bash
pytest --snapshot-update      # regenerate
git diff tests/__snapshots__  # review as a human

The trap is that regenerating is one flag away. Under time pressure a failing snapshot gets updated rather than investigated, and the snapshot is now a record of the bug. Two things prevent it: the updated file must appear in the diff and be reviewed like code, and the pipeline must never update snapshots automatically. A team that adopts approval testing without that discipline has adopted a slower way of doing nothing.

Handling the things that legitimately differ

Most differential comparisons fail on the first run for reasons that are not bugs. Deal with them explicitly rather than by loosening the comparison.

  • Time. Inject a clock. A function that calls datetime.now() internally cannot be differentially tested and should not be written that way regardless.
  • Randomness. Seed it, or inject the generator.
  • Ordering. Dictionary iteration, set iteration, GROUP BY without ORDER BY, parallel result collection. Normalise before comparing (sort the output, canonicalise the JSON key order) rather than declaring the difference acceptable.
  • IDs and timestamps in payloads. Strip them, or replace with a placeholder, in one shared normalisation function so the rule is visible.
  • Floating point. Compare with a tolerance you chose and can defend, not with the default of whatever library you are using. And if this is money, the real bug is the float.

Each of these is a place where the temptation is to widen the comparison until it passes. Every widening is a hole, and holes accumulate. Write the normalisation, name it, and keep the comparison exact.

What it cannot do

Differential testing verifies that the new thing matches the old thing. It says nothing about whether the old thing was right.

So it is exactly the correct tool for a refactor, a rewrite, a language migration, a performance optimisation, or replacing a library. It is the wrong tool for a behaviour change, and the most common misuse is running it during a change that was supposed to fix a bug, then “accepting” the differences one by one until the fix is indistinguishable from a regression.

When behaviour is meant to change, split the work: one change that is provably identical, verified this way, and a second, tiny change that alters behaviour and is reviewed by a human. That split is worth doing for its own sake and this is one more reason for it.

Every lesson here is open. Your progress is recorded once you pass the AI at Work exam.

Check your understanding

A team shadow-runs a rewritten pricing service against live traffic. Outputs match on 99.4% of requests. They investigate the 0.6% and find all of them involve orders placed within a second of a rate change. What should they conclude?

Pick the one you would defend. Your answer stays private.

Next lesson · 9 minThe type checker got more valuable

Stuck on something?Ask Larissa, your AI tutor

How to cite: Addaly (2026) ‘The old version is your best oracle’, in How Software Development Changed. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/how-software-changed/differential-testing (Accessed: 13 October 2026).