Viscosity
Review
Part VI · The compiler middle-end · Day 22

88. Testing compilers

A compiler bug is the worst kind: your program is right, and what runs is wrong. Viscosity is tested the way chapter 15 promised, by running two programs that must agree, and this chapter shows every kind of check in the repository and the one it doesn’t have yet.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Explain how Viscosity's differential test uses the original program as its own oracle, comparing output and errors.
  • Say why a compiler's tests must also check what it decided, not only that behaviour is unchanged.
  • Describe round-trip testing, and the identity and parser-equivalence tests in the repository.
  • Write a small random-program differential tester, and explain what fuzzing adds.

The worst kind of bug

When your code has a bug, you read your code. When the compiler has a bug, your code is fine and the program still does the wrong thing. Viscosity rewrites your arrays into columns. If the rewritten program ever prints something different, or fails differently, Viscosity is broken, however fast it is.

Writing expected outputs by hand for every test program would be slow and error-prone. Chapter 15 showed the way out: you don’t need the right answer if you have two programs that must agree.

Intuition

A translator’s work can be checked without speaking the language: hand the original and the translation to two people, ask both to follow the instructions, and see whether they end up in the same place.

Definition

Differential testing runs two implementations of the same thing on many inputs and compares the results. For a compiler, the two are usually the program before and after compiling, or two compilers. Every difference is a bug in one of them, and no expected outputs are needed.

Original against compiled

The repository’s test corpus is a folder of small JavaScript programs, each built to probe one case: an escape, a shape mismatch, an out-of-bounds read. The first line says what Viscosity should decide:

// viscosity: ps=aos
const ps = [];
for (let i = 0; i < 100; i++)
  ps.push({ x: i, y: i });
for (let i = 0; i < ps.length; i++)
  ps[i].x += 1;
console.log(ps[3]);

The test, from test/differential.test.ts, simplified:

for (const f of corpusFiles) {
  it(f, () => {
    const src = read(f);
    const r = compile(src, jsPlugin());
    expect(layouts(r.plan))
      .toEqual(header(src));
    const before = runIsolated(src);
    const after = runIsolated(r.code);
    expect(after).toEqual(before);
  });
}

runIsolated, in test/run.ts, runs code in a brand-new Node VM context, with its own globals, and records what happened:

const out = [];
const context = vm.createContext({
  console: { log: (...a) =>
    out.push(fmt(a)) } });
try {
  new vm.Script(code)
    .runInContext(context);
  return { out, error: null };
} catch (e) {
  const name = e.constructor.name;
  return { out,
    error: name + ': ' + e.message };
}

Step through one corpus program, and then the bug this catches:

Step throughThe differential testing loop
A corpus program is compiled; the plan is checked against its header; original and compiled versions run in fresh contexts and their outcomes are comparedout_of_bounds.jscompileplan vs header…originalcompiledFRESH CONTEXT Anot run yetFRESH CONTEXT Bnot run yettoEqual(before)…
One corpus file, out_of_bounds.js. Its first line is a header saying what Viscosity should decide: // viscosity: ps=soa. Its last line reads ps[100].a, one past the end.
1/7

The last step is a real experiment, not a test in the repository: compiling that file with --bounds assume produces code that prints 0 where the original threw a TypeError. Comparing errors as well as output is what catches it. And the header check catches the opposite failure. A compiler that never transforms anything passes every behaviour test, so the corpus also pins down what Viscosity should decide.

Two parsers, one answer

Viscosity has two JavaScript parsers: acorn, a popular library, and a small hand-written one. mini-parser.test.ts is differential too. For every corpus program and every end-to-end benchmark program, both must give byte-identical compiled code and explanations:

const a = compile(src,
  jsPlugin({ parser: 'acorn' }));
const m = compile(src,
  jsPlugin({ parser: 'mini' }));
expect(m.code).toBe(a.code);
expect(m.explain).toBe(a.explain);

Round trips

There is a third trick for front-ends. Parse a program, print the tree back to source, and parse that: you must get the same tree. Any difference means the parser or the printer lost something.

Definition

Round-trip testing checks that a pair of opposite operations undo each other: parse → print → parse gives the same tree, or encode → decode gives the same value. Like differential testing, it needs no expected outputs, only the input.

Viscosity doesn’t print from a tree; it edits only the parts of the source it changes. So its round trip is the identity: a program with nothing to transform must come back byte for byte, comment included:

const src = 'const a = [1, 2, 3];\n' +
  '// a comment\n' +
  'console.log(a.length);\n';
expect(compile(src, jsPlugin()).code)
  .toBe(src);

Part VII returns to round-trip testing the front-end in detail.

The Drip corpus

Your own interpreters are tested the same way. The companion’s corpus has 265 Drip programs with their expected output written beside the code, as // expect: comments. The tree-walker and the VM run the same files (each skips a handful, such as the benchmarks), and a sample also runs under stress GC, which collects on every allocation, so a missing root shows up at once. Both suites, run for this chapter:

$ npx vitest run packages/frontend-js
      Tests  64 passed (64)

$ npx vitest run   # book/companion/ts
      Tests  583 passed (583)

Fuzzing

The corpus only contains programs someone thought of. Fuzzing lets the computer think of them: generate random programs, run each through both implementations, and keep any that disagree. Viscosity doesn’t fuzz yet; this is the next step. Here is the idea at toy size: random expressions, and an “optimizer” with algebraic rules, one of them wrong:

Try itA tiny differential fuzzer
// Seeded randomness: one seed always
// gives the same programs, so a failure
// can be reproduced exactly.
let seed = 7;
function rand(k) {
  seed = (seed * 16807) % 2147483647;
  return seed % k;
}

// A random expression over x: a number,
// 'x', or [op, left, right].
function gen(d) {
  const r = rand(4);
  if (d === 0 || r === 0) {
    return rand(4);
  }
  if (r === 1) return 'x';
  const op = ['+', '-', '*'][rand(3)];
  return [op, gen(d - 1), gen(d - 1)];
}

function run(e, x) {
  if (e === 'x') return x;
  if (typeof e === 'number') return e;
  const a = run(e[1], x);
  const b = run(e[2], x);
  if (e[0] === '+') return a + b;
  if (e[0] === '-') return a - b;
  return a * b;
}

// The "optimizer": algebraic rules.
function fold(e) {
  if (!Array.isArray(e)) return e;
  const op = e[0];
  const a = fold(e[1]);
  const b = fold(e[2]);
  const na = typeof a === 'number';
  const nb = typeof b === 'number';
  if (na && nb) return run([op, a, b]);
  const zero = a === 0 || b === 0;
  if (op === '*' && zero) return 0;
  if (op !== '*' && a === 0) return b;
  if (op !== '*' && b === 0) return a;
  return [op, a, b];
}

let bad = false;
for (let t = 1; t <= 2000; t++) {
  const e = gen(3);
  const x = rand(10);
  const want = run(e, x);
  const got = run(fold(e), x);
  if (got !== want) {
    console.log('program', t, 'x =', x);
    console.log(JSON.stringify(e));
    console.log('want', want);
    console.log('got', got);
    bad = true;
    break;
  }
}
if (!bad) console.log('2000 agreed');
// Find the wrong rule and fix it.
Definition

Fuzzing generates large numbers of random inputs, for a compiler random programs, to find bugs that no hand-written test covers. Paired with differential testing, it needs no expected outputs at all. Use a seed, so any failure can be generated again, and reduce each failing program to the smallest one that still fails.

The hard part is generating programs worth running. A random string of characters doesn’t parse, and a random program that parses may read uninitialized memory or loop forever. Then a disagreement means nothing.

Under the hood

Csmith is the classic program fuzzer: a random generator of C programs whose main purpose is finding compiler bugs, using differential testing as the oracle. Its generated programs are free of undefined behaviour, which its authors note is far from trivial[1]. The other approach is a huge suite of known answers: Test262 is JavaScript’s conformance suite, with tens of thousands of test files covering the language specification[2]. Viscosity has neither yet. It has a hand-built differential corpus, a parser-equivalence test, an identity round trip, and, for Drip, an expectation corpus run on two implementations.

Your turn

ExerciseCompare outcomes, errors included

Write diffTest(f, g, gen, n). For i from 0 to n - 1, make an input x = gen(i) and run both f(x) and g(x). An outcome is either a returned value or a thrown error. Two outcomes agree if both returned === values, or both threw errors with the same message. Return the first x whose outcomes disagree, or undefined if all agree.

function diffTest(f, g, gen, n) {
  for (let i = 0; i < n; i++) {
    const x = gen(i);
    if (f(x) !== g(x)) return x;
  }
  return undefined;
}
Extra practiceShrink a failing input

A fuzzer found an array xs for which fails(xs) is true. Write shrink(xs, fails): repeatedly try removing one element at a time, keeping any removal after which fails is still true, until no single removal still fails. Return the smaller array. Don’t change xs itself.

function shrink(xs, fails) {
  // your code here
  return xs;
}

// Fails whenever 3 and 5 are both in.
const fails = (ys) =>
  ys.includes(3) && ys.includes(5);
console.log(
  shrink([1, 3, 8, 5, 2], fails));

What you learned

  • Differential testing compares two implementations on the same inputs. Viscosity compares each program before and after compiling, output and errors, in fresh VM contexts.
  • Behaviour checks alone pass a compiler that does nothing, so the corpus headers also check each layout decision.
  • Two parsers that must agree, byte for byte, is differential testing too.
  • Round-trip testing checks that opposite operations undo each other. Viscosity’s version: nothing to transform means identical output.
  • Fuzzing generates random programs. Seed it, keep the programs valid, and reduce what fails. The repository doesn’t fuzz yet.

Check your understanding

0/6
  1. 1.

    Viscosity’s differential test needs no expected outputs. Why?

  2. 2.

    With --bounds assume, the compiled out_of_bounds.js prints 0 where the original throws. Which check catches it?

  3. 3.

    A change makes Viscosity keep every collection as an array of objects. Which tests fail?

  4. 4.

    Which is a round-trip test?

  5. 5.

    What is true of fuzzing in this repository?

  6. 6.

    Why does Csmith take care to generate C programs free of undefined behaviour?

References

  1. [1] 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)
  2. [2] Ecma TC39 and Test262 contributors. Test262: ECMAScript Test Suite. github.com/tc39/test262. github.com/tc39/test262 (checked 2026-10-31)