3rd‐party property based testing - uhop/tape-six GitHub Wiki

3rd-party property-based testing

Property-based testing generates many random inputs and checks an invariant holds for all of them. tape-six doesn't ship a generator — bring fast-check (or another lib that throws on counterexample) and assert via t.*.

fast-check

Install: npm install --save-dev fast-check. Throws plain Error (not AssertionError) on counterexample, with a message containing the failing seed and shrunk inputs.

Passing property

import * as fc from 'fast-check';
import test from 'tape-six';

test('string concat is associative', t => {
  fc.assert(
    fc.property(fc.string(), fc.string(), fc.string(), (a, b, c) => {
      return a + (b + c) === a + b + c;
    })
  );
  t.pass('property held over 100 random inputs');
});

fc.assert runs the property a configurable number of times (default 100) and returns silently on success. Add a t.pass() so the test contributes a counted assertion.

Failing property — caught with t.throws

For negative-case tests where you expect the property to fail (e.g. demonstrating a counterexample exists), wrap with t.throws:

test('addition is not subtraction', t => {
  t.throws(
    () => fc.assert(fc.property(fc.integer(), fc.integer(), (a, b) => a + b === a - b)),
    err => err && /Property failed/.test(String(err.message)),
    'fast-check found a counterexample'
  );
});

Failure rendering

A real test failure (the property is supposed to hold but doesn't) surfaces as UNEXPECTED EXCEPTION with fast-check's diagnostic message rendered as-is:

not ok N UNEXPECTED EXCEPTION: Error: Property failed after 4 tests
  { seed: 2045860912, path: "3:0", endOnFailure: true }
  Counterexample: [...]
  Shrunk: ...

The seed and path let you reproduce the exact failure with fc.assert(prop, {seed: ..., path: ...}). fast-check's report is deliberately passed through unparsed — it is well-designed as-is.

fast-check also sets error.cause to the root failure from inside your predicate, and tape-six unchains cause (and AggregateError members) generically for every reported error — so the failing line of your code surfaces without any fast-check-specific handling:

  causes:
    - "Error: boom at length 3"
  causeStack:
    - "at file:///.../my-test.mjs:6:30"

Tuning runs and seeds

fc.assert(prop, {numRuns: 1000, seed: 42}); // deterministic, more iterations

Useful when a flaky property needs investigation, or when you want to lock down a known-good seed in CI.

Race conditions with fc.scheduler

fc.scheduler() is an arbitrary that takes control of promise resolution order and explores interleavings — first-class race-condition testing with the same shrink-and-replay mechanics as plain properties. Wrap the async operations under test with s.scheduleFunction(...) (or s.schedule(promise)), then await s.waitAll():

import * as fc from 'fast-check';
import test from 'tape-six';

test('fc.scheduler finds the lost-update interleaving', async t => {
  await t.rejects(
    fc.assert(
      fc.asyncProperty(fc.scheduler(), async s => {
        const counter = {value: 0};
        const increment = s.scheduleFunction(async function increment() {
          const read = counter.value; // read...
          await Promise.resolve(); // ...yield to other tasks...
          counter.value = read + 1; // ...write
        });
        const runs = Promise.all([increment(), increment()]);
        await s.waitAll();
        await runs;
        return counter.value === 2;
      })
    ),
    /Property failed/,
    'the racy counter loses an update under some ordering'
  );
});

Here the property should fail — the read-modify-write is not atomic, and the scheduler finds the ordering where both calls read 0 — so the demo asserts the rejection with t.rejects. In a real suite the property guards code that is supposed to be interleaving-safe, and a failure report carries the schedule that broke it plus the seed/path to replay it deterministically (fc.schedulerFor replays a specific ordering).

Model-based testing

fast-check also tests stateful APIs against a simplified model: commands mutate the real system and the model together and check they agree; on failure the command sequence shrinks to a minimal reproduction:

class Counter {
  #value = 0;
  increment() {
    ++this.#value;
  }
  reset() {
    this.#value = 0;
  }
  get value() {
    return this.#value;
  }
}

const incrementCmd = {
  check: () => true,
  run(model, real) {
    real.increment();
    ++model.count;
    if (real.value !== model.count) throw new Error(`drift: ${real.value} != ${model.count}`);
  },
  toString: () => 'increment'
};
const resetCmd = {
  check: () => true,
  run(model, real) {
    real.reset();
    model.count = 0;
    if (real.value !== model.count) throw new Error(`drift: ${real.value} != ${model.count}`);
  },
  toString: () => 'reset'
};

test('Counter matches its model under command sequences', t => {
  fc.assert(
    fc.property(fc.commands([fc.constant(incrementCmd), fc.constant(resetCmd)]), cmds => {
      fc.modelRun(() => ({model: {count: 0}, real: new Counter()}), cmds);
    })
  );
  t.pass('all command sequences agree with the model');
});

check(model) gates when a command is applicable (always true here); run(model, real) applies it to both and asserts agreement. Use fc.asyncModelRun for async systems. Real command arbitraries usually carry generated arguments — e.g. fc.nat().map(n => new AddCommand(n)) — rather than fc.constant.

Browser tests

fast-check ships an ESM build. Add it to your importmap:

{
  "tape6": {
    "importmap": {
      "imports": {
        "fast-check": "/node_modules/fast-check/lib/esm/fast-check.js"
      }
    }
  }
}

Verify the path against node_modules/fast-check/package.json#exports for the version you installed.

See also