breakhound StarGitHub stars

Guide

Property based testing without writing tests

Property based testing is powerful, but most teams never get past the blank file where the first property should go. breakhound runs the common invariants for you, straight from your TypeScript types.

By Alexandre KohlerUpdated 4 min read

Why properties are hard to start

The idea behind property based testing is simple: instead of asserting that slugify('Hello World') equals 'hello-world', you state a rule that must hold for every input and let a machine hunt for a counterexample. The hard part is not the machine. It is deciding which rules to write, and then writing an input generator (an arbitrary) that produces the nasty values your real users will eventually send.

In practice that cost is why the test file stays empty. You need to know the library well enough to name its invariants, and you need to know the platform well enough to generate lone surrogates, -0, dates before 1970 and inputs that change meaning under a different time zone. This guide shows a shortcut: for a surprisingly large family of bugs, the invariants are generic and can be checked without you authoring anything.

What fast-check gives you, honestly

fast-check is the standard property based testing library for JavaScript, and it is excellent. You write the property, you choose the arbitraries, and when something fails it shrinks the input to a small counterexample. If your team already has the habit, keep using it. Here is the idempotence property for a slug function:

slugify.property.test.ts
import fc from 'fast-check';
import { test } from 'vitest';
import { slugify } from './slugify';

test('slugify is idempotent', () => {
  fc.assert(
    fc.property(fc.string(), (s) => slugify(slugify(s)) === slugify(s))
  );
});

That is nine lines for one function and one invariant. It is also only as good as the arbitrary you pick: a plain string arbitrary will not hit every platform edge by default, and a time zone dependency needs you to write the harness that switches TZ. fast-check solves "how do I express and shrink a property." It does not solve "which properties should I write for the other fifty exports."

Invariants you can check for free

Some rules apply to nearly any function, and they are exactly the ones people forget to write:

  • Idempotence: for normalizing functions such as slugify and canonicalizers, f(f(x)) should equal f(x).
  • Roundtrip: if a library exposes encodeX and decodeX, then decodeX(encodeX(x)) should return x. See roundtrip testing for encode and decode pairs.
  • Determinism: calling a function twice with the same input should give the same answer.
  • No NaN leak: finite input should not produce NaN.
  • Well formed output: a string result should not contain a lone surrogate (see unicode surrogate pair bugs).
  • No unexpected crash: a call whose arguments match the declared types should not throw a TypeError.

None of these need domain knowledge. They only need a generator that knows what a string, a number or a Date looks like at the edges, and that is derivable from the type signature.

A failing example

Here is a slug function that looks reasonable in review. It lowercases, converts underscores to hyphens, then turns whitespace into underscores.

slugify.ts
export function slugify(input: string): string {
  return input
    .trim()
    .toLowerCase()
    .replace(/_/g, '-')
    .replace(/\s+/g, '_');
}

Now slugify('x y') returns 'x_y', and feeding that result back in returns 'x-y'. The two passes disagree about which separator is canonical, so the output of the function is not a fixed point of the function. In a real system that means duplicate or unstable URLs the moment someone re-saves a record. The fix is to converge on one separator in a single pass:

slugify.ts
export function slugify(input: string): string {
  return input
    .trim()
    .toLowerCase()
    .replace(/[\s_]+/g, '-');
}

Running breakhound on it

breakhound reads the .d.ts types, generates inputs per parameter (including edge cases from the edgegoat package), and applies the invariants above as oracles. You do not write a property or an arbitrary:

Terminal
npx breakhound ./my-lib --rules not-idempotent,roundtrip,nondeterministic

On the buggy-lib fixture, the plain output includes this finding:

Terminal output
breakhound v0.1.0  buggy-lib@1.0.0 (local)
23 functions analyzed, 1 skipped | 4444 calls | 6 time zones | seed 42 | 6.8s

  [not-idempotent] slugify is not idempotent (confidence medium)
    slugify('x y')  -> 'x_y'
    slugify(slugify('x y'))  -> 'x-y'

Every finding also comes with a minimal reproduction script and a ready Vitest test, so the counterexample becomes a permanent regression test without any extra typing:

slugify.test.ts
import { expect, test } from 'vitest';
import { slugify } from 'buggy-lib';

test("slugify is idempotent", () => {
  const once = slugify('x y');
  expect(slugify(once)).toEqual(once);
});

Runs are seeded (default seed 42), so the same command finds the same inputs tomorrow. Because the target is executed in an isolated child process with a timeout, a hang or a crash in one function does not stop the hunt.

When to keep writing properties

Generic oracles cover rules that hold for any function of a given shape. They cannot know that your sortInvoices must preserve the total, or that your parser must accept every output of your printer. Those domain properties are where fast-check earns its place, and they are worth writing by hand.

A sensible split is to run breakhound first, because it costs one command and clears the generic bugs, then spend your property-writing time on the rules only you know. If you also care about crashes in parsers on hostile bytes, a coverage guided fuzzer such as Jazzer.js is the better tool, and fuzz testing in TypeScript explains where each approach fits. For the general workflow, start with finding bugs automatically in JavaScript, and when you are ready to enforce it on every pull request, see automated testing in GitHub Actions.

Use --fail-on high to make the command exit with code 1 only when a high severity finding appears, so you can adopt the checks gradually.

FAQ

Is this really property based testing if I write nothing?

The checks are properties (idempotence, roundtrip, determinism) evaluated over generated inputs, so the technique is the same. The difference is who writes the property and the generator: breakhound ships a fixed set of oracles and derives inputs from your types.

Does breakhound replace fast-check?

No. fast-check is the right tool for properties that encode your domain rules, and it has shrinking. breakhound covers generic invariants with zero authoring, so the two complement each other.

Which invariants does breakhound check?

The rule ids are lone-surrogate-output, timezone-dependent, unexpected-crash, nan-leak, roundtrip, not-idempotent, negative-zero-sign and nondeterministic. Use --rules to select a subset.

Do I need TypeScript types for it to work?

breakhound generates inputs from the declared types in the .d.ts files, so typed packages give the best results. It works on both npm packages and local paths.

Are the results reproducible?

Yes. The random inputs are seeded (default 42, change it with --seed), and each finding includes a minimal reproduction script and a Vitest test.