Property Based Testing Guide for Reliable Edge Case Coverage

Build useful property based tests with fast check by defining invariants, generating valid inputs, inspecting shrinking, and preserving reproducible counterexamples.

Generated input tokens passing through an assertion check with an exceptional case under a magnifying glass

A passing example can miss a zero position. A useful property can explore that boundary and explain why the output is wrong.

A list splitting helper can pass examples for positions two and five while mishandling zero. A generated test can explore those positions without requiring someone to enumerate every list. Its usefulness depends on whether the assertion describes the splitting contract and whether the generator can reach the troublesome input.

Property based testing, often shortened to PBT, combines a rule about behavior with generated inputs. The library repeatedly checks that rule and, when it fails, attempts to simplify the failing input. The fast check introduction explains these mechanics. A completed run samples the chosen domain. It does not prove the property for every possible input.

All code and scenarios below are hypothetical illustrations, not measured results from a product or an experiment. The JavaScript example uses fast check, whose package name is fast-check.

Choose a property worth testing

Keep examples beside generated checks

An example states that splitting [4, 8] at zero returns [[], [4, 8]]. A property states that joining both returned parts reconstructs the original list for every supported list and position. Examples make specific decisions readable. Generated checks explore combinations around those decisions.

Start where the input space expands faster than a useful example table. Collection transformations and parsers are good candidates. For a parser, define whether the contract promises exact preservation of text or preservation of meaning after serialization. Whitespace normalization makes those different requirements.

Known boundaries still deserve explicit examples. A generated run need not select the largest allowed list or a particular duplicate pattern. Preserve important regressions as named cases even after adding a property. Review those examples whenever the public contract changes. Together, these checks form an executable specification of the behavior your test suite actually asserts.

Give the oracle independent reasoning

The oracle is how a test decides whether the result is acceptable. Comparing a function to itself through another wrapper supplies no independent evidence. Copying the production loop into an expected value helper can preserve the same mistake.

Prefer an observable relation with a separate justification. Splitting must preserve element order and content. A successful insertion into a set must leave that element present. An encoder and decoder can satisfy a round trip relation, but coordinated defects can cancel each other. Add known format fixtures when compatibility with another implementation matters.

Several properties may be necessary. Reconstruction alone accepts a splitting function that always returns an empty left part and the entire input on the right. Check the partition size as well. Write down a plausible wrong implementation for each proposed property and ask whether the assertion would reject it.

Choose a property testing library for your language

Choose tooling where the code under test already runs. QuickCheck provides random testing of program properties for Haskell. Hypothesis serves Python projects, while fast check supports JavaScript and TypeScript. Keeping a property beside an existing unit test makes it easier to share domain fixtures and investigate a failure in the same debugger. Moving a business rule into another language just to exercise it introduces another implementation to maintain.

These libraries share the idea of generated checks, but their failure handling needs separate configuration. The Hypothesis replay guide describes its example database and explicit examples. It recommends explicit examples for inputs that must always run because database entries can become invalid after changes. Fast check seed and path options are specific to its runner. Translate the specification when changing libraries, then read the destination library's generation and replay rules.

Property testing also overlaps with fuzz testing. Both can exercise code with generated test data. A fuzz target can check whether a parser crashes, while a richer property can check whether accepted input preserves meaning after serialization. Fuzz targets can include semantic assertions too. The fast check fuzzing guide describes repeated predicate execution for fuzzing workloads. Choose the failure condition first, then decide how broadly and how long to explore inputs.

Build a useful property based test

Property based testing workflow showing an invariant, valid generated inputs, and a preserved counterexample
Define the invariant, generate valid inputs, and preserve the counterexample with its replay information.

Move from the contract to a repeatable failure

Define the invariant

Consider a pure function that splits an array at a nonnegative integer position. Positions beyond its length put everything on the left. The function returns fresh arrays and leaves the input unchanged. This contract deliberately excludes negative positions, fractional positions, and arbitrary JavaScript objects.

One implementation is const splitAt = (values, cut) => [values.slice(0, cut), values.slice(cut)];. Keep it in an application module when adopting the test. Install the reviewed development dependency with npm install --save-dev fast-check and preserve the lockfile. The official getting started guide shows how to use the library with an existing test runner.

In a Node test file, import test from "node:test", assert from "node:assert/strict", and fc from "fast-check". Import the application's splitAt too. Define const valuesArb = fc.array(fc.integer({ min: -1000, max: 1000 }), { maxLength: 40 }); and const cutArb = fc.integer({ min: 0, max: 40 });.

The property is const splitProperty = fc.property(valuesArb, cutArb, (values, cut) => { const before = [...values]; const input = [...values]; const [left, right] = splitAt(input, cut); assert.deepEqual([...left, ...right], before); assert.equal(left.length, Math.min(cut, before.length)); assert.deepEqual(input, before); assert.notEqual(left, input); assert.notEqual(right, input); assert.notEqual(left, right); });.

Register it with test("split preserves the partition contract", () => { fc.assert(splitProperty, { numRuns: 300 }); });. Save the test as an ES module, such as split.test.mjs, and run node --test split.test.mjs. The property reference documents assertion failures inside a synchronous property and warns against mutating generated inputs. Pass a clone to the application so a faulty implementation cannot alter the generated value. Keep a separate snapshot to detect mutation.

Generate valid inputs

The generator bounds make execution manageable and keep the contract visible. Empty arrays and zero positions are allowed. Negative elements are valid because splitting does not interpret their values. Both integer bounds are inclusive. These small integers avoid distracting floating point questions.

The array arbitrary reference documents length constraints. A maximum of forty makes large arrays possible within this test, but does not require each run to produce one. Add explicit cases for the exact limits you must always exercise. Use a separate property for larger collections when that risk justifies its cost.

Generation limits also limit the claim. This test does not examine sparse arrays, object identity within elements, or arrays containing special numeric values. Add those domains deliberately if the application accepts them. Test invalid positions at the validation boundary that owns their rejection, rather than silently filtering them out of every suite.

Preserve the counterexample

If an implementation substitutes cut || values.length for the position, zero acquires the wrong meaning. A nonempty list split at zero violates the left length assertion. Keep that explicit example alongside the property after correcting the implementation.

On a real failure, save the reported counterexample, seed, and path. The test report guide shows replay through runner options. Copy the actual reported values into fc.assert(splitProperty, { seed, path, endOnFailure: true });. Those variable names stand for captured values, not invented replay coordinates. The option stops at failure without another shrinking attempt.

Record the dependency version, source revision, generator definitions, and runner configuration with the report. Preserve the runtime version when it can affect behavior. A path describes choices made under that setup. After changing the generator or upgrading the library, a concrete regression fixture is more durable than assuming an old path still identifies the same input.

Design generators around the real domain

Construct relationships before filtering

For a valid time interval, generate a start and a nonnegative duration, then derive the end. Bound both components so the result stays within the supported timestamp range. This produces the relationship by construction and makes the assumptions reviewable.

Use .filter for constraints that are awkward to construct, and inspect how much it rejects. Returning early from the predicate for most inputs can create an apparently successful test that barely exercises the target rule. A fast check fc.pre precondition discards an unsuitable case; it is not an assertion of application behavior.

The arbitrary combiners reference documents mapping, filtering, and dependent generation with .chain. It also warns that chained arbitraries can limit shrinking. Choose the simplest construction that describes the domain, then inspect its failures. A clever generator that produces unreadable counterexamples can cost more debugging time than it saves.

Inspect the distribution you actually generate

Use fc.sample(valuesArb, 20) while developing a generator. For structured domains, classify samples into cases such as empty collections and collections containing duplicates. The runner reference describes both sampling and fc.statistics for inspecting generated values.

These observations describe your generator under the sampled configuration. They do not establish coverage of production traffic. Maintain separate properties or explicit examples for consequential input classes. If only administrators can trigger a state transition, construct that state deliberately instead of hoping unrelated generated fields happen to create it.

Read shrinking as a debugging aid

Distinguish a small failure from a complete explanation

Shrinking tries simpler candidates while retaining a failure. It can remove list elements or reduce values, depending on the arbitrary. The resulting counterexample is relative to the generator and shrink process. Treat it as a useful witness, without claiming it is the globally smallest failing input.

First check whether the witness satisfies the intended domain. An invalid interval can expose a generator defect. A valid witness can expose an incorrect property or an implementation bug. Read the assertion error and reproduce the exact input before changing application code.

Keep failure explanations attached to requirements. For the splitting example, a single element with a zero position already explains the contract violation. If shrinking repeatedly ends in an unrelated timeout, stabilize execution before drawing conclusions about the input. Preserve the original report when a reduction changes which failure you observe.

Make CI failures reproducible and affordable

Budget accepted cases and execution time

Begin with one inexpensive property in the ordinary suite. The example's three hundred runs are a configuration choice, not a recommended universal threshold. Increase the budget after checking the generator's reach and the cost of each execution. Repeatedly exercising a narrow domain does not compensate for an omitted class of inputs.

The runner parameters reference defines controls including numRuns, seed, and interruption options. Review their exact behavior before treating a time limit as success. Configure interruptions that leave required exploration incomplete to fail the job. A synchronous infinite loop also needs an outer process deadline.

Use ordinary generated seeds for continuing exploration and retain failing seeds in CI artifacts. A fixed seed helps investigation but repeatedly explores the same generated sequence under unchanged conditions. Keep explicit regressions and run broader exploration on a schedule that fits the repository's budget.

Control effects outside the generator

A replay seed controls generated choices. It does not freeze a database, wall clock, or remote service. Start each property execution with independent fixtures, including executions during shrinking. Inject a clock and deterministic identifiers where the contract permits it. Avoid additional randomness inside the predicate that the property runner cannot reproduce.

For promises, use fc.asyncProperty and await fc.assert. Await every operation and assertion inside the predicate too. Use disposable test resources for external I/O, with cleanup that also handles failures. A timeout can leave work running unless the application supports cancellation, so ensure late writes cannot affect subsequent cases.

Extend into stateful workflows when needed

Generate actions against a simpler model

When correctness depends on history, generate a sequence of commands. A reservation flow might reserve capacity, cancel, and attempt another reservation. Keep a simple model of available capacity and compare observable outcomes after each permitted action.

The model based testing guide describes command checks and execution, including synchronous and asynchronous runners. Its command machinery supports shrinking failing scenarios. Command replay can additionally require the reported replayPath on fc.commands. Follow those instructions for the installed version rather than applying the pure property replay recipe unchanged.

Awaiting commands in sequence checks sequential histories. It does not explore every concurrent interleaving. For timing defects, define the operations whose scheduling matters and use a dedicated controlled scheduling test. Keep a smaller sequential model first so a failure has an understandable policy behind it.

Connect generated checks with release feedback

Use the contract testing guide when generated payloads need a shared consumer expectation. The API rate limit testing guide develops state and timing boundaries worth expressing as separate properties. Keep service integration checks for persistence and browser journeys for the visible outcome.

Create a BetaRoast feedback round on a safe hosted build when you need people to assess whether that outcome makes sense. Ask whether an empty result or rejected action explains what happened. BetaRoast collects human feedback. It does not generate property tests or establish mathematical correctness. Turn a reproducible observation into a requirement before choosing its regression property.

Frequently asked questions

Does property based testing replace example tests?

Keep both. Examples document known decisions and guarantee execution of important boundaries. Properties explore additional combinations within an explicitly generated domain. Preserve a discovered defect as a readable regression case alongside continued exploration.

Does a passing property prove the code is correct?

No. It establishes that the checked assertions held for the executions completed under that configuration. Missing input classes and an incorrect oracle can leave defects undetected. Review the property and generator as test code with their own assumptions.

Why does a saved seed sometimes fail to reproduce a bug?

The generator, dependency version, or execution environment may have changed. External state and uncontrolled timing can also change the outcome. Keep the path and concrete counterexample with the seed, then reproduce using the recorded source and dependency versions.

What should the first property cover?

Choose a small deterministic function with a useful independent invariant. A collection partition or bounded transformation is easier to diagnose than a workflow involving remote services. State the supported domain first and include explicit cases that challenge the invariant's completeness.

THE BOTTOM LINE

Define the supported domain and an independent invariant, inspect the generated cases, and preserve each failure with its counterexample and replay configuration. Keep explicit regressions beside generated checks and isolate external state before expanding into workflows.

PUT IT INTO PRACTICE

Make your next beta count.

Define the mission, recruit matching testers, and turn feedback from real devices into a better release.

Start a Roast

Technical guidance can change. Verify current official documentation for your platform and tools before release.