BUPKIS
    Preparing search index...

    Creating reliable assertions is only possible through the ancient and glorious art of software testing.

    This guide shows you how to test your custom assertions using property-based testing with fast-check.

    Property-based testing is particularly well-suited for testing assertions because:

    • Exhaustive Coverage: Tests your assertion with a wide range of inputs automatically
    • Edge Case Discovery: Finds corner cases you might not think to test manually
    • Confidence: Hundreds or thousands of test cases run automatically
    • Documentation: Properties serve as executable specifications

    BUPKIS recommends fast-check for property-based testing due to its flexibility, popularity, and the sad fact that the author has no idea about any other property-based testing libraries.

    This guide will use the built-in Node.js test runner node:test, but you can use any test framework you prefer.

    Let's create and test a simple custom assertion that checks if a number is even:

    // even-assertion.ts
    import { createAssertion, z, use } from 'bupkis';

    export const evenAssertion = createAssertion(
    [z.number(), 'to be even'],
    (n) => n % 2 === 0,
    );

    export const { expect } = use([evenAssertion]);
    // even-assertion.test.ts
    import { describe, it } from 'node:test';
    import fc from 'fast-check';
    import { expect, evenAssertion } from './even-assertion.js';

    describe('evenAssertion', () => {
    it('should pass for all even numbers', () => {
    fc.assert(
    fc.property(fc.integer(), (n) => {
    // Only test even numbers
    fc.pre(n % 2 === 0);

    // This should not throw
    expect(n, 'to be even');
    }),
    );
    });

    it('should fail for all odd numbers', () => {
    fc.assert(
    fc.property(fc.integer(), (n) => {
    // Only test odd numbers
    fc.pre(n % 2 === 1);

    // This should throw an AssertionError
    try {
    expect(n, 'to be even');
    return false; // Should not reach here
    } catch (error) {
    return error.name === 'AssertionError';
    }
    }),
    );
    });

    it('should reject non-numbers', () => {
    fc.assert(
    fc.property(
    fc.anything().filter((x) => typeof x !== 'number'),
    (value) => {
    try {
    expect(value, 'to be even');
    return false; // Should not reach here
    } catch (error) {
    // Should throw a TypeError for invalid arguments
    return error.name === 'TypeError';
    }
    },
    ),
    );
    });
    });

    Let's test a more complex assertion that validates a number is within a range:

    // range-assertion.ts
    import { createAssertion, z, use } from 'bupkis';

    export const rangeAssertion = createAssertion(
    [z.number(), 'to be between', z.number(), 'and', z.number()],
    (value, min, max) => {
    if (min > max) {
    return {
    actual: { min, max },
    expected: 'min <= max',
    message: `Range is invalid: min (${min}) must be <= max (${max})`,
    };
    }

    return value >= min && value <= max;
    },
    );

    export const { expect } = use([rangeAssertion]);
    // range-assertion.test.ts
    import { describe, it } from 'node:test';
    import * as fc from 'fast-check';
    import { expect, rangeAssertion } from './range-assertion.js';

    describe('rangeAssertion', () => {
    it('should pass for values within valid ranges', () => {
    fc.assert(
    fc.property(
    fc.integer({ min: -100, max: 100 }), // min
    fc.integer({ min: -100, max: 100 }), // max
    (min, max) => {
    // Ensure min <= max
    const [actualMin, actualMax] = min <= max ? [min, max] : [max, min];

    // Generate a value within the range
    const value = fc.sample(
    fc.integer({ min: actualMin, max: actualMax }),
    1,
    )[0];

    // This should not throw
    expect(value, 'to be between', actualMin, 'and', actualMax);
    },
    ),
    );
    });

    it('should fail for values outside valid ranges', () => {
    fc.assert(
    fc.property(
    fc.integer({ min: 10, max: 20 }), // min
    fc.integer({ min: 30, max: 40 }), // max
    fc.integer({ min: -100, max: 5 }), // value outside range
    (min, max, value) => {
    try {
    expect(value, 'to be between', min, 'and', max);
    return false; // Should not reach here
    } catch (error) {
    return error.name === 'AssertionError';
    }
    },
    ),
    );
    });

    it('should handle invalid ranges gracefully', () => {
    fc.assert(
    fc.property(
    fc.integer({ min: 10, max: 20 }), // min
    fc.integer({ min: 0, max: 5 }), // max < min
    fc.integer(), // any value
    (min, max, value) => {
    try {
    expect(value, 'to be between', min, 'and', max);
    return false; // Should not reach here
    } catch (error) {
    return (
    error.name === 'AssertionError' &&
    error.message.includes('Range is invalid')
    );
    }
    },
    ),
    );
    });
    });

    For async assertions, use fc.asyncProperty():

    // async-assertion.test.ts
    import { describe, it } from 'node:test';
    import * as fc from 'fast-check';
    import { createAsyncAssertion, z, use } from 'bupkis';

    // Create an async assertion
    const resolveToAssertion = createAsyncAssertion(
    ['to resolve to', z.unknown()],
    async (promise, expected) => {
    const result = await promise;
    return result === expected;
    },
    );

    const { expectAsync } = use([resolveToAssertion]);

    describe('resolveToAssertion', () => {
    it('should pass when promise resolves to expected value', async () => {
    await fc.assert(
    fc.asyncProperty(fc.anything(), async (value) => {
    const promise = Promise.resolve(value);
    await expectAsync(promise, 'to resolve to', value);
    }),
    );
    });

    it('should fail when promise resolves to different value', async () => {
    await fc.assert(
    fc.asyncProperty(fc.anything(), fc.anything(), async (value1, value2) => {
    // Only test when values are different
    fc.pre(value1 !== value2);

    const promise = Promise.resolve(value1);
    try {
    await expectAsync(promise, 'to resolve to', value2);
    return false; // Should not reach here
    } catch (error) {
    return error.name === 'AssertionError';
    }
    }),
    );
    });
    });

    Always test that your assertion passes when it should and fails when it should:

    // Test success
    expect(validInput, 'your assertion phrase');

    // Test failure
    try {
    expect(invalidInput, 'your assertion phrase');
    // Should not reach here
    } catch (error) {
    // Verify it's the right kind of error
    }

    Use fc.pre() to filter inputs to specific test cases:

    fc.property(fc.integer(), (n) => {
    fc.pre(n > 0); // Only test positive numbers
    expect(n, 'to be positive');
    });

    Verify that your assertion provides helpful error messages:

    try {
    expect(42, 'to be a string');
    } catch (error) {
    assert(error.message.includes('expected string'));
    assert(error.actual === 42);
    }

    Verify that your assertion rejects invalid input types:

    fc.property(fc.string(), (str) => {
    try {
    expect(str, 'to be greater than', 5); // Should fail
    return false;
    } catch (error) {
    return error.name === 'TypeError';
    }
    });

    Add fast-check to your development dependencies:

    npm install fast-check -D
    

    Run your tests with your preferred test runner:

    # Using Node.js built-in test runner
    node --test --import tsx test/**/*.test.ts

    # Using npm script
    npm test

    If you're building a library of assertions and need to test them systematically, the @bupkis/property-testing package provides a structured harness that handles the boilerplate for you.

    npm install @bupkis/property-testing fast-check -D
    

    Every assertion can be tested in four ways:

    Variant Description
    valid Input that should pass the assertion
    invalid Input that should fail the assertion
    validNegated Input that should pass not <phrase>
    invalidNegated Input that should fail not <phrase>

    For most assertions, negation simply inverts the logic—so validNegated defaults to your invalid config and vice versa. You only need to specify them explicitly when the negated behavior differs.

    // my-assertions.test.ts
    import {
    createPropertyTestHarness,
    extractPhrases,
    filteredAnything,
    getVariants,
    type PropertyTestConfig,
    } from '@bupkis/property-testing';
    import fc from 'fast-check';
    import { describe, it } from 'node:test';

    import { expect, expectAsync } from './my-assertions.js';
    import { evenAssertion, positiveAssertion } from './assertions.js';

    // Create the harness with your expect functions
    const { runVariant } = createPropertyTestHarness({ expect, expectAsync });

    // Define configs for each assertion
    const testConfigs = new Map([
    [
    evenAssertion,
    {
    valid: {
    generators: [
    fc.integer().map((n) => n * 2), // always even
    fc.constantFrom(...extractPhrases(evenAssertion)),
    ],
    },
    invalid: {
    generators: [
    fc.integer().map((n) => n * 2 + 1), // always odd
    fc.constantFrom(...extractPhrases(evenAssertion)),
    ],
    },
    } satisfies PropertyTestConfig,
    ],
    [
    positiveAssertion,
    {
    valid: {
    generators: [
    fc.integer({ min: 1 }),
    fc.constantFrom(...extractPhrases(positiveAssertion)),
    ],
    },
    invalid: {
    generators: [
    fc.integer({ max: 0 }),
    fc.constantFrom(...extractPhrases(positiveAssertion)),
    ],
    },
    } satisfies PropertyTestConfig,
    ],
    ]);

    // Run all tests
    for (const [assertion, testConfig] of testConfigs) {
    const phrases = extractPhrases(assertion);
    const { variants, params } = getVariants(testConfig);

    describe(`assertion: "${phrases[0]}"`, () => {
    for (const [variantName, variant] of variants) {
    it(`should handle ${variantName} inputs`, async () => {
    await runVariant(variant, {}, params, variantName, assertion);
    });
    }
    });
    }

    For assertions with parameters, include generators for each parameter:

    import { rangeAssertion } from './range-assertion.js';

    const rangeConfig: PropertyTestConfig = {
    valid: {
    generators: fc
    .tuple(
    fc.integer({ min: -100, max: 100 }),
    fc.integer({ min: -100, max: 100 }),
    )
    .chain(([a, b]) => {
    const [min, max] = a <= b ? [a, b] : [b, a];
    return fc.tuple(
    fc.integer({ min, max }), // value in range
    fc.constantFrom(...extractPhrases(rangeAssertion)),
    fc.constant(min),
    fc.constant(max),
    );
    }),
    },
    invalid: {
    generators: fc
    .tuple(fc.integer({ min: 0, max: 50 }), fc.integer({ min: 51, max: 100 }))
    .chain(([min, max]) =>
    fc.tuple(
    fc.integer({ max: min - 1 }), // value below range
    fc.constantFrom(...extractPhrases(rangeAssertion)),
    fc.constant(min),
    fc.constant(max),
    ),
    ),
    },
    };

    Mark your variant as async:

    const asyncConfig: PropertyTestConfig = {
    valid: {
    async: true,
    generators: [
    fc.anything().map((v) => Promise.resolve(v)),
    fc.constantFrom('to resolve'),
    ],
    },
    invalid: {
    async: true,
    generators: [
    fc.anything().map((v) => Promise.reject(new Error(`${v}`))),
    fc.constantFrom('to resolve'),
    ],
    },
    };

    Sometimes you want to verify that your generated inputs actually work with the target assertion—not just that some assertion passes. The expectUsing and expectUsingAsync functions execute an assertion directly, bypassing phrase matching:

    import {
    expectUsing,
    expectUsingAsync,
    PropertyTestGeneratorError,
    } from '@bupkis/property-testing';
    import { myAssertion, myAsyncAssertion } from './my-assertions.js';

    // Execute the assertion directly with arguments
    expectUsing(myAssertion, [42, 'to be even']);

    // Test negated behavior
    expectUsing(myAssertion, [43, 'to be even'], { negated: true });

    // Async assertions
    await expectUsingAsync(myAsyncAssertion, [promise, 'to resolve']);

    If your generator produces invalid inputs (arguments that don't parse for the assertion), expectUsing throws a PropertyTestGeneratorError—catching bugs in your test setup rather than false positives.

    The package provides several helpers for common testing scenarios:

    import {
    filteredAnything, // fc.anything() minus problematic objects
    filteredObject, // fc.object() minus problematic objects
    objectFilter, // the filter function itself
    hasKeyDeep, // recursively search for a key
    hasValueDeep, // recursively search for a value
    safeRegexStringFilter, // remove regex metacharacters
    calculateNumRuns, // environment-aware run count
    expectUsing, // direct assertion execution (sync)
    expectUsingAsync, // direct assertion execution (async)
    PropertyTestGeneratorError, // generator bug error
    WrongAssertionError, // wrong assertion matched error
    } from '@bupkis/property-testing';

    // Filter out objects that break Zod validation
    const safeValue = filteredAnything.filter((v) => v !== null);

    // Check if generated object has problematic keys
    const isSafe = objectFilter(generatedObject);

    // Search nested structures
    hasKeyDeep({ a: { b: { c: 1 } } }, 'c'); // true
    hasValueDeep({ a: [{ b: 42 }] }, 42); // true

    For testing compositional assertions (like 'and' chains), the package provides an applicability registry that maps runtime values to assertions that would pass or fail for them. This enables data-first generation: generate a diverse value, then query which assertions apply.

    import {
    getApplicabilityRegistry,
    getApplicableAssertions,
    getInapplicableAssertions,
    diverseValueArbitrary,
    validChainArbitrary,
    invalidChainArbitrary,
    } from '@bupkis/property-testing';

    // Get the default registry (lazy-loaded)
    const registry = await getApplicabilityRegistry();

    // Query which assertions pass/fail for a value
    const value = 42;
    const applicable = getApplicableAssertions(value, registry);
    // e.g., [numberAssertion, integerAssertion, positiveAssertion, ...]

    const inapplicable = getInapplicableAssertions(value, registry);
    // e.g., [stringAssertion, booleanAssertion, nullAssertion, ...]

    For testing 'and' chains, use the built-in chain arbitraries:

    // Generate diverse values covering many type categories
    const valueGen = diverseValueArbitrary();

    // Generate valid 'and' chains (all assertions in chain pass)
    const validChainGen = validChainArbitrary(registry, { maxChainLength: 4 });

    // Generate invalid 'and' chains (at least one assertion fails)
    const invalidChainGen = invalidChainArbitrary(registry);

    // Each returns ChainArgs: { args, chainLength, subject }
    fc.assert(
    fc.property(validChainGen, ({ args }) => {
    expect(...args); // Should pass
    }),
    );

    There are also validNegatedChainArbitrary and invalidNegatedChainArbitrary for testing negated chains.

    The runSize option adjusts the number of property test iterations:

    const config: PropertyTestConfig = {
    runSize: 'small', // 50 runs (vs 250 for 'medium', 500 for 'large')
    valid: {
    /* ... */
    },
    invalid: {
    /* ... */
    },
    };

    Run counts automatically scale down in CI (÷5) and Wallaby (÷10) for faster feedback.