Conventional testing methodologies rely on Example-Based Testing. This paradigm suffers inherently from Confirmation Bias: engineers routinely verify only the specific boundary conditions they anticipated while authoring the original implementation. Consequently, anomalous edge cases—empty arrays, Unicode surrogate pairs, negative zero (-0), NaN, floating-point precision collapse, and control characters—silently slip into production.
Property-Based Testing (PBT) shifts this paradigm. Rather than testing fixed points, engineers define:
fc.string(), fc.integer(), fc.record()).reverse(reverse(list)) === list).\0 or -101).+-------------------------------------------------------------------------+
| Property-Based Testing Loop (fast-check) |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------+
| Arbitrary Generator (100+ runs) |
| fc.record({ id: fc.uuid(), ... }) |
+-------------------------------------+
|
v
+-------------------------------------+
| Execute Invariant Test |
| assert(isInvariantHold(x)) |
+-------------------------------------+
|
+---------------+---------------+
| |
[All iterations pass] [Iteration fails]
| |
v v
+-----------------+ +-----------------+
| PASSED (100) | | SHRINKING LOOP |
+-----------------+ | Simplify Input |
+-----------------+
|
v
+-----------------+
| Min Counter-ex: |
| e.g. input: "" |
+-----------------+parse(serialize(x)) === x.clean(clean(x)) === clean(x).0 <= applyDiscount(price, rate) <= price.The Roundtrip invariant dictates that encoding followed by decoding must restore the initial state without data loss.
import { describe, it, expect } from 'vitest';
import fc from 'fast-check';
export interface QueryState {
filter: string;
page: number;
isActive: boolean;
}
export function serializeQuery(state: QueryState): string {
const params = new URLSearchParams();
params.set('filter', state.filter);
params.set('page', state.page.toString());
params.set('isActive', state.isActive ? '1' : '0');
return params.toString();
}
export function parseQuery(queryString: string): QueryState {
const params = new URLSearchParams(queryString);
const filter = params.get('filter') ?? '';
const pageRaw = parseInt(params.get('page') ?? '1', 10);
const page = Number.isNaN(pageRaw) ? 1 : pageRaw;
const isActive = params.get('isActive') === '1';
return { filter, page, isActive };
}
describe('Roundtrip: serializeQuery <-> parseQuery', () => {
it('guarantees lossless state restoration across arbitrary inputs', () => {
// Arbitrary modeling valid QueryState instances
const queryArbitrary = fc.record<QueryState>({
// Unbounded strings containing whitespace, emojis, and URI characters
filter: fc.string(),
// Natural pagination bounds from 1 to 1,000,000
page: fc.integer({ min: 1, max: 1_000_000 }),
isActive: fc.boolean(),
});
fc.assert(
fc.property(queryArbitrary, (originalState) => {
const serialized = serializeQuery(originalState);
const parsed = parseQuery(serialized);
// Invariant: parsed object is strictly identical to source state
expect(parsed).toEqual(originalState);
}),
{ numRuns: 200 } // Evaluate 200 randomized input configurations
);
});
});A normalization operation (such as space trimming and Unicode canonicalization) must be idempotent: applying it a second time produces identical output.
import { describe, it, expect } from 'vitest';
import fc from 'fast-check';
export function normalizeSearchQuery(input: string): string {
return input
.normalize('NFKC')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
}
describe('Idempotence: normalizeSearchQuery', () => {
it('preserves stability upon repeated application f(f(x)) === f(x)', () => {
fc.assert(
fc.property(fc.fullUnicodeString(), (rawInput) => {
const firstPass = normalizeSearchQuery(rawInput);
const secondPass = normalizeSearchQuery(firstPass);
// Idempotence Invariant
expect(secondPass).toBe(firstPass);
})
);
});
});Suppose a currency formatter harbors an edge-case defect: for negative values, a double hyphen is erroneously injected whenever the magnitude is strictly less than -100.
import { describe, it, expect } from 'vitest';
import fc from 'fast-check';
// Implementation harboring a subtle boundary bug
export function formatCurrencyCents(cents: number): string {
const isNegative = cents < 0;
const absCents = Math.abs(cents);
const dollars = Math.floor(absCents / 100);
const remainder = absCents % 100;
const formattedNumber = `${dollars}.${remainder.toString().padStart(2, '0')}`;
// Intentional defect: double minus injected when cents < -100
if (cents < -100) {
return `--$${formattedNumber}`;
}
return isNegative ? `-$${formattedNumber}` : `$${formattedNumber}`;
}
describe('formatCurrencyCents (Demonstrating Shrinking)', () => {
it('never outputs more than a single minus sign', () => {
const property = fc.property(fc.integer(), (cents) => {
const formatted = formatCurrencyCents(cents);
const minusCount = (formatted.match(/-/g) ?? []).length;
expect(minusCount).toBeLessThanOrEqual(1);
});
// fast-check captures failure and shrinks to the exact boundary
expect(() => fc.assert(property)).toThrowError();
});
});When executing this suite, fast-check might initially trigger the defect on -89437283. It then initiates Shrinking:
-44718641 (fails).-1000 (fails).-101 (fails).-100 (passes).The test runner outputs an actionable diagnostic report:
Property failed after 14 tests
{ seed: 1892847293, path: "13:0:2:1", endOnFailure: true }
Counterexample: [-101]
Shrunk 28 time(s)The root cause is immediately obvious: the anomaly occurs precisely at boundary -101.
Re-implementing the function's internal algorithm inside fc.property asserts that two identical functions agree with each other rather than verifying an invariant.
// ❌ Anti-pattern: test duplicates implementation logic
fc.assert(
fc.property(fc.integer(), fc.integer(), (a, b) => {
expect(add(a, b)).toBe(a + b); // Meaningless duplication of add()
})
);
// ✅ Correct pattern: verify mathematical properties (commutativity, identity)
fc.assert(
fc.property(fc.integer(), fc.integer(), (a, b) => {
expect(add(a, b)).toBe(add(b, a)); // Commutativity: a + b === b + a
expect(add(a, 0)).toBe(a); // Identity element: a + 0 === a
})
);Unconstrained generation generates irrelevant data types that fail validation guards, preventing execution of the actual domain logic.
// ❌ Anti-pattern: injecting random objects into an email validator
fc.assert(fc.property(fc.anything(), (input) => { ... }));
// ✅ Correct pattern: bounded arbitraries matching domain constraints
const emailArbitrary = fc.emailAddress();
fc.assert(fc.property(emailArbitrary, (email) => { ... }));When a test fails non-deterministically in CI, reproducing it locally requires fixing the exact pseudo-random seed that provoked the failure.
// ✅ Pinning the seed for deterministic failure reproduction from CI logs
fc.assert(property, {
seed: 1892847293, // Copy seed directly from CI error report
});When testing functions returning promises, standard fc.property exits before promises settle, ignoring assertion failures.
// ❌ Anti-pattern: synchronous property fails to await asynchronous reactions
fc.assert(
fc.property(fc.string(), async (str) => {
const res = await asyncParser(str);
expect(res).toBeDefined(); // Executes after test runner exits!
})
);
// ✅ Correct pattern: employ fc.asyncProperty
fc.assert(
fc.asyncProperty(fc.string(), async (str) => {
const res = await asyncParser(str);
expect(res).toBeDefined();
})
);Classify behavior under Roundtrip (reversibility), Idempotence (stability upon repetition), or Mathematical Invariants (size bounds).
Combine primitive generators (fc.string, fc.integer, fc.boolean) into typed domain records via fc.record.
Execute fc.assert(fc.property(...)) over 100-500 randomized runs inside a standard Vitest suite.
Upon failure, inspect the minimal shrunk counterexample and formalize it into permanent regression suites.