AdvancedAdvanced testing and verification~45 min

Advanced Testing and Verification

Property-based, mutation, fuzz, and differential testing as complements to example-based tests, hermetic builds for reproducibility, and static analysis via codemods.

By the end of this lesson, you can

  • Write a property-based test that checks an invariant across many generated inputs, not one example
  • Explain what mutation testing measures that code coverage alone does not
  • Explain what a hermetic build guarantees and why non-hermetic builds undermine reproducibility
  • Write a simple codemod that transforms source code programmatically, with a fixture-based test

Why it matters

Testing and Maintainability covered example-based unit tests, test doubles, and determinism. Beyond examples, a family of techniques checks broader properties, actively hunts for weaknesses in a test suite itself, and β€” for a tool that transforms code rather than just running it β€” a fixture-based testing approach of its own.

Property-based testing

An example-based test checks one specific input/output pair. A property-based test instead states an invariant that should hold for any valid input, and a library generates many varied inputs (including deliberately tricky edge cases) to check it:

JavaScriptA property-based test, checking an invariant across generated inputs
import fc from 'fast-check';

// Property: reversing an array twice always returns the original array
fc.assert(
  fc.property(fc.array(fc.integer()), (arr) => {
    const reversed = [...arr].reverse();
    const reversedTwice = [...reversed].reverse();
    return JSON.stringify(reversedTwice) === JSON.stringify(arr);
  }),
);

The library runs this property against dozens or hundreds of generated arrays β€” varying length, values, and edge cases (empty array, single element, duplicates) β€” and, on failure, automatically shrinks the failing input down to the smallest example that still reproduces it, far more useful for debugging than whatever large random input first triggered the failure.

Mutation testing: testing your tests

Code coverage measures which lines ran during a test suite β€” it says nothing about whether the test actually verified anything meaningful about the code that ran. Mutation testing checks this directly: it automatically introduces small bugs (β€œmutants”) into the code β€” flipping a < to <=, changing + to -, deleting a line β€” and reruns the test suite against each mutant.

What a mutation testing run reports
OutcomeMeaning
Mutant killedA test failed against the mutated code β€” the suite genuinely caught this specific bug
Mutant survivedEvery test still passed against the mutated (broken) code β€” the suite has a real gap here
// Original
function isAdult(age) {
  return age >= 18;
}

// A mutant: >= changed to >
function isAdult(age) {
  return age > 18;
}
// If no test checks age === 18 specifically, this mutant survives β€”
// revealing that 100% line coverage of isAdult still missed a real boundary case.

Fuzz and differential testing

Fuzz testing feeds a function large volumes of randomized, often malformed input, specifically looking for crashes or unexpected exceptions β€” most valuable for code parsing untrusted input (a file format parser, a request body parser) where a genuinely malformed input is a realistic, expected scenario, not a hypothetical.

Differential testing runs two independent implementations of the same behavior against the same inputs and compares their outputs β€” useful when migrating between implementations (an old parser and a new one) or validating a fast, optimized version against a simple, obviously-correct reference implementation.

// Differential testing: comparing a fast implementation against a simple reference
function referenceSum(arr) {
  let total = 0;
  for (const n of arr) total += n;
  return total;
}

function optimizedSum(arr) {
  return arr.reduce((total, n) => total + n, 0); // "optimized" here just for illustration
}

fc.assert(
  fc.property(fc.array(fc.integer()), (arr) => {
    return referenceSum(arr) === optimizedSum(arr);
  }),
);

Hermetic builds and reproducibility

A hermetic build produces an identical output given the same inputs, regardless of what else is on the machine running it β€” no dependency on ambient system state, unpinned tool versions, network access during the build, or the current date/time.

Codemods and static analysis

A codemod is a program that transforms source code programmatically β€” a scripted, repeatable refactor applied across an entire codebase, built on parsing source into an AST (recall this is covered more fully in the next lesson), transforming it, and printing it back out:

// A simplified codemod sketch: rename every call to oldFunctionName to newFunctionName
function codemod(sourceCode) {
  const ast = parse(sourceCode);
  traverse(ast, {
    CallExpression(path) {
      if (path.node.callee.name === 'oldFunctionName') {
        path.node.callee.name = 'newFunctionName';
      }
    },
  });
  return generate(ast);
}

A trustworthy codemod is tested against fixtures β€” real (or realistic) before/after code samples β€” checked for idempotency (running it twice produces the same result as running it once, so accidentally running it again causes no harm), and shipped with rollback guidance for anyone who needs to undo it.

Check your understanding

A test suite has 100% line coverage of a boundary-check function, but mutation testing reveals the mutant that changes >= to > survives. What does this tell you?
Or reveal the answer without checking

Answer:Every line ran during testing, but no test actually asserts on the specific boundary value where >= and > disagree β€” a real gap coverage alone didn't reveal
A surviving mutant means the test suite didn't distinguish between the original and mutated behavior β€” here, no test checks the exact boundary value (age === 18) where >= and > produce different results. Full line coverage doesn't guarantee this; mutation testing specifically surfaces it.

Why is fuzz testing especially valuable for a function that parses an untrusted file format?
Or reveal the answer without checking

Answer:Malformed, adversarial input is a realistic, expected scenario for such a function β€” fuzzing systematically searches for inputs that crash or misbehave it, rather than relying on the test author to imagine every malformed case
A parser for untrusted input will genuinely encounter malformed data in production β€” fuzz testing automates the search for inputs that break it, covering a much larger space than a human author manually writing malformed-input examples could realistically anticipate.

Practice: warm-up

Write one property (in your own words, no library required) that should hold for any correct implementation of a sortArray(arr) function, beyond β€œthe output looks sorted.”

Stuck? Reveal one hint at a time.

  1. Hint 1

    A sort that just returns an empty array, or a fixed array, would "look sorted" too β€” what property specifically rules that out?

  2. Hint 2

    Think about what must be true relating the multiset of the OUTPUT to the multiset of the INPUT.

Reveal an example property

Try the problem yourself before reading this. There is often more than one reasonable approach β€” treat this as one worked example, not the only correct answer.

Property: sortArray(arr) contains exactly the same elements as arr (same multiset β€”
same values, same counts, just possibly reordered), for any array.

This rules out a "sort" that returns [] or a hard-coded constant array, which would
otherwise trivially satisfy "the output looks sorted" alone.

Practice: apply it

Sketch (as pseudocode or real code) a codemod that finds every var declaration in a file and converts it to let. What fixture-based test structure would you use to verify it?

Stuck? Reveal one hint at a time.

  1. Hint 1

    The transform itself walks the AST looking for var declaration nodes and changes their kind to let β€” similar shape to the rename example in the lesson.

  2. Hint 2

    A fixture test pairs an input file with its expected output file, running the codemod against the input and diffing the result against the expected output.

Reveal a reference sketch

Try the problem yourself before reading this. There is often more than one reasonable approach β€” treat this as one worked example, not the only correct answer.

function codemod(sourceCode) {
  const ast = parse(sourceCode);
  traverse(ast, {
    VariableDeclaration(path) {
      if (path.node.kind === 'var') {
        path.node.kind = 'let';
      }
    },
  });
  return generate(ast);
}

// Fixture test structure:
// fixtures/var-to-let/input.js   β€” before
// fixtures/var-to-let/output.js  β€” expected after
// test: codemod(read('input.js')) === read('output.js')
// Also verify: codemod(codemod(input)) === codemod(input) β€” idempotency

Summary

  • Property-based testing checks an invariant across many generated inputs, complementing rather than replacing specific example-based tests, and shrinks a failing case to its smallest reproducer.
  • Mutation testing measures whether a test suite would actually catch a specific introduced bug β€” something code coverage alone cannot tell you, since coverage only measures which lines ran.
  • Fuzz testing systematically searches for crash-inducing input, most valuable for code parsing untrusted data; differential testing compares two implementations against the same inputs.
  • A hermetic build produces identical output from the same inputs regardless of ambient machine state β€” a pinned lockfile alone doesn’t guarantee this if the build process reaches outside it.
  • A codemod transforms source code programmatically via an AST; a trustworthy one is tested against before/after fixtures, verified idempotent, and shipped with rollback guidance.