
An unexpected input becomes much easier to fix when a test can produce it again. Property testing starts with a rule, generates inputs that exercise it, and reduces a failing input to a smaller example. Keeping that example makes the discovery useful long after the first failure.
Write the rule before the examples
An example test might check that reading and writing a record preserves a person’s name. A property test states the broader rule: for every valid record the generator produces, reading and writing it preserves all its fields.
The generator should include the awkward cases that ordinary examples tend to miss: empty values, unusual characters, nested objects, and keys that resemble language features. Those inputs are valuable because they challenge assumptions the happy path never touches.
A small key can expose a large assumption
One useful example is this JSON object:
{"__proto__":0}
JSON.parse treats __proto__ as an ordinary key. Some ways of copying properties onto an ordinary JavaScript object treat the same name specially. A copy can therefore lose the key instead of preserving the JSON value.
GhostGet keeps a regression test for this case and a nested variant. The example gives a maintainer something concrete to understand, while a generated round-trip property continues looking for related cases.
Keep the replay recipe with the failure
A generated test uses a seed to choose its starting sequence. When a case fails, the testing library tries smaller inputs that still fail; the shrink path records that reduction. With the same test, generator, and library version, the seed and path reproduce the case.
Here is a complete fast-check example. It checks that reversing an array twice preserves its values, and records replay information if the rule fails:
import assert from "node:assert/strict";
import fc from "fast-check";
const property = fc.property(fc.array(fc.integer()), (values) => {
assert.deepEqual(values.toReversed().toReversed(), values);
});
const result = fc.check(property);
if (result.failed && result.counterexamplePath !== null) {
console.log({ seed: result.seed, path: result.counterexamplePath });
fc.assert(property, {
seed: result.seed,
path: result.counterexamplePath,
endOnFailure: true,
});
}
The final call runs the failing case again. For a real failure, save those reported values and rerun the same property with that seed and path while you investigate.
Save the smallest failing input as a named example as well. A library upgrade can change what a seed generates; the explicit example preserves the behavior you intended to protect.
Test sequences as well as values
Some bugs depend on order. A send succeeds, the connection drops, the program restarts, and the request is submitted again. Each individual step may look reasonable while the sequence duplicates the message.
A stateful property test generates actions and compares the running program with a simpler model after each step. For storage, a useful rule is that an interrupted update leaves either the old value or the new one, never half of each. For a consequential request, recovery should preserve whether it may already have been sent.
Choose failure points around the steps that change state: before a write, after it reaches disk, and before the caller receives a reply. A restart should preserve enough information to decide what happened.
Use a second implementation for shared standards
When a format has a written standard, an independently written implementation provides another useful comparison. Run the same input through both and investigate disagreements. URL handling and canonical JSON, a consistent byte representation used when hashing structured data, both have standards against which implementations can be compared.
Agreement on generated inputs adds evidence; it does not establish agreement on every possible input. Combine it with examples from the standard and permanent regressions for disagreements you resolve. The fast-check documentation explains how to build the generators and stateful models.