112. Differential testing at scale
Chapter 88 showed how one program can test a compiler. This chapter is about the folder of programs: what goes into each file, how 55 programs become 115 tests and the Rust port’s fixtures, and what the folder still can’t find.
- Explain what a test corpus is, and the rules each corpus file follows.
- Describe how Viscosity’s programs multiply into checks across parsers, languages and implementations.
- Say how the TypeScript compiler serves as the oracle for the Rust port.
- Build a seeded random-program generator and use it to find a bug in a toy compiler.
From one test to a folder
Chapter 88 gave you the method: compile a program, run both versions,
and the original is the oracle. One program checks one thing. Viscosity
has to be right about escapes, aliases, try, NaN, strings in number
fields, Python’s integers, two parsers and a second
implementation in Rust. That takes many programs, and a way of using
each one many times.
Here is the whole TypeScript test suite, run for this chapter:
$ cd engine/ts && npx vitest run
✓ frontend-js/test/mini-parser.test.ts
(43 tests)
✓ frontend-js/test/differential.test.ts
(38 tests)
✓ bench/test/layouts.test.ts (8 tests)
✓ core/test/core.test.ts (11 tests)
✓ frontend-python/test/differential...
(15 tests)
Test Files 5 passed (5)
Tests 115 passed (115)
(Paths shortened and wrapped.) 91 of those 115 tests were never written one by one: they are generated, one per program, from folders of programs.
A driving examiner doesn’t invent a new route for every learner. There is a route, chosen to include the hard junction, the hill start and the school crossing. Every learner drives it. When someone finds a new way to fail, a new turn is added to the route.
A test corpus is a large, curated collection of programs used as test inputs. Each program is chosen to exercise one case. The tests are generated from the collection, so adding a case means adding a file, not writing test code.
What goes in a corpus file
The JavaScript corpus has 36 files, each probing one decision. Here is
try_catch.js:
// viscosity: ps=soa
const ps = [];
for (let i = 0; i < 100; i++)
ps.push({ x: i });
let s = 0;
try {
for (let i = 0; i < ps.length; i++)
s += ps[i].x;
ps[1000].x = 1;
} catch (e) {
console.log('caught',
e.constructor.name, e.message);
}
console.log(s);
(Long lines wrapped.) It follows the rules every corpus file follows:
- One idea. This file asks a single question: does an out-of-bounds
write inside
trythrow the same error after compiling? The name says what it probes. - A header with the expected decision for every collection, so a compiler that stops optimizing fails too (chapter 88).
- Observable. It prints the error’s class and message, and the sum. A difference that isn’t printed can’t be caught.
- Small and deterministic. No timing and no randomness, so two correct runs always print the same thing.
Some files are traps for a specific kind of mistake. In
typed_column_semantics.js, every field is Infinity or -Infinity,
then multiplied by 0 and checked for NaN, a case where a typed column
must behave exactly like a plain number. In the Python corpus,
ints_stay_ints.py prints type(cs[5].count).__name__: a careless
array('d') column would turn the integer 6 into 6.0, and the
output would say float. Viscosity gives count a list column and
the output stays int.
One file, many checks
The trick that makes the corpus scale is that each program is used by several tests, for different questions. Step through the matrix:
All of these tests share one shape. The real loop, from
differential.test.ts:
for (const f of readdirSync(dir)
.filter((f) => f.endsWith('.js'))
.sort()) {
it(f, () => { /* ...compile, check
the header, run both... */ });
}
The tests are written once, per kind of check. The cases are data.
Other languages, other processes
The Python test can’t use Node’s VM contexts, so it writes both
programs to a temporary folder and runs each with the real python3.
It compares standard output, and for a crash the last line of standard
error, where Python prints the exception:
const run = (file) => {
try {
return { out: execFileSync('python3',
[file], opts), err: null };
} catch (e) {
return { out: e.stdout, err: e.stderr
.trim().split('\n').pop() };
}
};
expect(run(b)).toEqual(run(a));
The oracle is still the original program. Only the way of running it changed.
The TypeScript compiler as an oracle
Part XI rebuilds Viscosity in Rust. How do you know the Rust version decides and emits the same things? The same way: differential testing, with the TypeScript compiler as the oracle.
engine/rust/scripts/gen_expected.mjs makes the fixtures. For every JS
corpus file, every benchmark program and 20 extra programs written for
the port, it runs the TypeScript compiler
twice, once with the default bounds: 'check' and once with
bounds: 'assume' (so the unchecked code paths are covered too), and
writes both the compiled code and the explain report. That is 61
programs, 2 modes and 2 files: 244 fixture files, in
engine/rust/tests/expected/.
The parity test, engine/rust/crates/cli/tests/parity.rs, recompiles
each source in Rust and compares byte for byte. Run for this chapter,
all 122 fixture pairs match. Part XI builds the port that passes it.
The parity work also paid the reference back. It found bugs in the
TypeScript compiler, among them a soundness bug: an element thrown from
inside a loop wasn’t seen as escaping. All are fixed now.
What a corpus can’t find
Every file in the corpus was written by someone who thought of the case. The corpus is very good at keeping found bugs found, and at checking that a new feature didn’t break an old one. It can’t find the case nobody imagined. For that you need programs nobody wrote: the random programs of chapter 88, at scale, which Part XI returns to with coverage-guided fuzzing. Viscosity doesn’t fuzz yet.
Language implementations live by their corpora. Test262, the conformance suite for JavaScript itself, is a corpus of tens of thousands of test files covering the language specification[1]. Csmith is the opposite strategy: a generator of random C programs that finds compiler bugs by differential testing, rather than from hand-written cases[2]. Real compilers use both: a growing corpus of known cases, and a generator that keeps adding the unknown ones.
Your turn
A toy language has three statements over a list of records: ['push', v] adds { x: v }, ['inc', k] adds k to every x, and ['get', i] prints element i’s x, or none. runAoS runs a program with objects; runSoA, the “compiled” version, uses one Float64Array column, and has bugs. Write gen(rand): a random program of 1 to 8 statements, each a push of rand(10), an inc of 1 + rand(3), or a get of rand(6), all equally likely. Then write fuzz(seed, n, f, g): make one generator with makeRand(seed), generate up to n programs, and return the first program on which f and g give different output, or null.
// Given: a seeded random generator.
function makeRand(seed) {
let s = seed;
return (k) => {
s = (s * 16807) % 2147483647;
return s % k;
};
}
// Given: the original, objects.
function runAoS(prog) {
const ps = [];
const out = [];
for (const [op, a] of prog) {
if (op === 'push') {
ps.push({ x: a });
}
if (op === 'inc') {
for (const p of ps) p.x += a;
}
if (op === 'get') {
const p = ps[a];
out.push(p ? p.x : 'none');
}
}
return out.join(',');
}
// Given: the "compiled" version.
function runSoA(prog) {
const xs = new Float64Array(4);
let n = 0;
const out = [];
for (const [op, a] of prog) {
if (op === 'push') xs[n++] = a;
if (op === 'inc') {
for (let i = 0; i < n; i++) {
xs[i] += a;
}
}
if (op === 'get') {
const ok = a < xs.length;
out.push(ok ? xs[a] : 'none');
}
}
return out.join(',');
}
// Your code: a random program.
function gen(rand) {
return [];
}
// Your code: the differential loop.
function fuzz(seed, n, f, g) {
return null;
}Write parseHeader(src). Find the first line that starts with // viscosity or # viscosity (not // @viscosity, which is an annotation). Return null if there is none. Otherwise return { mode, want }: mode is 'opt-in' if the line contains (opt-in), else 'auto'; want maps each name to its layout, from the comma-separated name=layout pairs after the first :. Ignore empty pairs.
function parseHeader(src) {
const first = src.split('\n')[0];
const [name, layout] = first
.slice(first.indexOf(':') + 1)
.trim().split('=');
return {
mode: 'auto',
want: { [name]: layout },
};
}What you learned
- A test corpus is a curated collection of programs; the tests are generated from it, so a new case is a new file.
- A good corpus file probes one idea, states the expected decisions, prints what could differ, and is deterministic.
- Each program feeds several checks: decisions, behaviour, parser equivalence and, for the Rust port, stored fixtures. 55 programs give 115 tests.
- The TypeScript compiler is the Rust port’s oracle: 244 fixture files, compared byte for byte by the Rust parity test.
- A corpus keeps known bugs found. Random programs find unknown ones.
Check your understanding
0/6- 1.
What makes a collection of test programs a corpus in Viscosity’s sense?
- 2.
A corpus program computes something but prints only “done”. What is wrong with it?
- 3.
How many tests does adding one JavaScript corpus file add to the TypeScript suite?
- 4.
What is true of the Rust port’s parity testing today?
- 5.
Why does the Python differential test run
python3in a subprocess instead of a VM context? - 6.
A fuzzer creates a new random generator from the same seed for every program. What happens?
References
- [1] Ecma TC39 and Test262 contributors. Test262: ECMAScript Test Suite. github.com/tc39/test262. github.com/tc39/test262 (checked 2026-10-31)
- [2] Xuejun Yang, Yang Chen, Eric Eide, John Regehr. Csmith: a random generator of C programs. github.com/csmith-project/csmith (README). github.com/csmith-project/csmith (checked 2026-10-31)