Differential Testing: Validate New Code Against Known-Good Behavior
When you rewrite a component, migrate to a new library, or refactor a complex algorithm, the hardest question is: "Did I break anything?" Your test suite only catches what it explicitly tests. Differential testing asks a different question: on the same inputs, does the new code produce the same outputs as the old code?
Differential testing — also called differential fuzzing, back-to-back testing, or N-version testing — runs two implementations simultaneously on identical inputs and flags any divergence. It's particularly powerful for:
- Migrating between implementations (rewrite, new library, different language)
- Validating optimizations (the fast version should give the same results as the correct version)
- Testing compilers, parsers, and serializers
- Validating database query rewrites
The Core Pattern
The differential testing pattern is straightforward:
def differential_test(inputs, old_impl, new_impl):
for input_case in inputs:
old_output = old_impl(input_case)
new_output = new_impl(input_case)
if not equivalent(old_output, new_output):
yield DivergenceReport(
input=input_case,
old_output=old_output,
new_output=new_output
)The power is in the inputs. Instead of hand-crafted test cases, you can use:
- Production traffic (sampled or replayed)
- Property-based test generators
- Fuzzer-generated inputs
- Domain-specific corpus files
The comparison function equivalent() is where business logic goes — not every difference is a bug. Timestamps in output might differ legitimately. Floating-point results might differ in precision. The equivalence function encodes what "same" means for your specific use case.
Differential Testing in Practice: Python
Here's a practical example: validating a rewritten JSON serializer against the original:
import json
import ujson # Faster alternative we're evaluating
import hypothesis
from hypothesis import given, settings
from hypothesis import strategies as st
@given(st.recursive(
st.one_of(st.none(), st.booleans(), st.integers(), st.floats(allow_nan=False), st.text()),
lambda children: st.one_of(
st.lists(children),
st.dictionaries(st.text(), children)
),
max_leaves=20
))
@settings(max_examples=10000)
def test_ujson_matches_stdlib(data):
try:
stdlib_result = json.dumps(data)
ujson_result = ujson.dumps(data)
# Re-parse to normalize formatting differences
stdlib_parsed = json.loads(stdlib_result)
ujson_parsed = json.loads(ujson_result)
assert stdlib_parsed == ujson_parsed, \
f"Divergence on input {data!r}:\n stdlib: {stdlib_result}\n ujson: {ujson_result}"
except (TypeError, OverflowError):
pass # Both should handle or both should fail - check consistency hereHypothesis generates thousands of structured inputs automatically. When it finds a divergence, it shrinks the input to the minimal case that reproduces the failure — making debugging much faster than raw fuzzing.
Differential Testing for HTTP APIs
For API migrations (new framework, new backend, new database), differential testing compares responses from two running versions of your service on identical request replays:
import aiohttp
import asyncio
from dataclasses import dataclass
from typing import Any
@dataclass
class DivergenceReport:
path: str
method: str
request_body: Any
v1_status: int
v2_status: int
v1_body: Any
v2_body: Any
async def compare_responses(session, request, v1_url, v2_url):
v1_resp, v2_resp = await asyncio.gather(
session.request(request['method'], v1_url + request['path'],
json=request.get('body')),
session.request(request['method'], v2_url + request['path'],
json=request.get('body'))
)
v1_body = await v1_resp.json()
v2_body = await v2_resp.json()
# Normalize fields that legitimately differ
for body in [v1_body, v2_body]:
body.pop('requestId', None)
body.pop('timestamp', None)
body.pop('processingTime', None)
if v1_resp.status != v2_resp.status or v1_body != v2_body:
return DivergenceReport(
path=request['path'],
method=request['method'],
request_body=request.get('body'),
v1_status=v1_resp.status,
v2_status=v2_resp.status,
v1_body=v1_body,
v2_body=v2_body
)
return None
async def run_differential_suite(requests, v1_url, v2_url):
async with aiohttp.ClientSession() as session:
tasks = [compare_responses(session, req, v1_url, v2_url) for req in requests]
results = await asyncio.gather(*tasks)
return [r for r in results if r is not None]Running this against a replay of sampled production traffic gives you differential coverage proportional to real usage — the most common paths are tested most often, and edge cases that real users trigger are tested even if no one wrote a test case for them.
Differential Fuzzing
For parsers, compilers, and format validators, differential fuzzing is particularly effective. You run two implementations on the same randomly-generated or mutated inputs and look for any case where they disagree:
# Using AFL++ for differential fuzzing of two JSON parsers
afl-fuzz -i seeds/ -o output_v1/ -- ./json_parser_v1 @@
afl-fuzz -i seeds/ -o output_v2/ -- ./json_parser_v2 @@Then compare outputs:
for f in output_v1/queue/*; do
v1=$(./json_parser_v1 "$f" 2>&1)
v2=$(./json_parser_v2 "$f" 2>&1)
if [ "$v1" != "$v2" ]; then
echo "Divergence on $(basename $f)"
diff <(echo "$v1") <(echo "$v2")
fi
doneProjects like OSS-Fuzz use differential fuzzing extensively for XML parsers, image decoders, and cryptographic libraries.
Handling Non-Determinism
Some functions are deliberately non-deterministic — they include random elements, depend on current time, or produce results in non-deterministic order. Differential testing still works, but the equivalence function needs to handle this:
def equivalent_search_results(old_results, new_results):
# Order may differ, but the set of result IDs should match
return set(r['id'] for r in old_results) == set(r['id'] for r in new_results)
def equivalent_statistics(old_stats, new_stats):
# Floating point results can differ within tolerance
return all(
abs(old_stats[k] - new_stats[k]) < 0.001
for k in old_stats
)The key is to be explicit about what "equivalent" means. An overly strict equivalence function produces false positives. An overly loose one misses real bugs.
Integration with CI
Differential testing runs best as a nightly job against a corpus, rather than on every commit:
# .github/workflows/differential.yml
name: Differential Testing
on:
schedule:
- cron: '0 2 * * *' # Nightly at 2am
jobs:
differential:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Start old version
run: docker run -d -p 8080:8080 myapp:stable
- name: Start new version
run: docker run -d -p 8081:8080 myapp:candidate
- name: Run differential suite
run: python differential_test.py --v1 http://localhost:8080 --v2 http://localhost:8081
- name: Report divergences
if: failure()
uses: actions/upload-artifact@v3
with:
name: divergence-report
path: divergences.jsonA divergence report that's empty means your new version is behaviorally equivalent to the old one on the full corpus. That's a strong signal — stronger than passing a test suite that only covers scenarios a developer thought to write.
Where Differential Testing Fits
Differential testing is a migration and validation technique, not a replacement for unit tests. It answers "is the new thing equivalent to the old thing?" — not "is the thing correct?" If the old implementation had a bug, the new implementation will be tested against that bug.
For ongoing quality assurance after migration, HelpMeTest provides continuous API monitoring that catches behavioral changes as they happen in production — complementing the one-time validation differential testing provides during migration.