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