Test
Enhanced test utilities for table-driven testing with Vitest.
Provides builder API and type-safe utilities for parameterized tests with built-in support for todo, skip, and only cases.
Import
import { Test } from '@wollybeard/kit'import * as Test from '@wollybeard/kit/test'Property Testing
[F] property
<Ts extends [unknown, ...unknown[]]>(...args ?: [description: string, ...arbitraries: { [K in keyof Ts]: Arbitrary<Ts[K]>; }, predicate: (...args: Ts) => boolean | void]): voidParameters:
args- Test arguments in order: - description: The test description - arbitraries: Fast-check arbitraries for generating test values - predicate: Function that should hold true for all generated values
Create a property-based test using fast-check within vitest.
Ts
- Tuple type of the arbitrary values.
Examples:
// test that array reverse twice returns original
Test.property(
'reversing array twice returns original',
fc.array(fc.integer()),
(arr) => {
const reversed = arr.slice().reverse()
const reversedTwice = reversed.slice().reverse()
expect(reversedTwice).toEqual(arr)
},
)
// test with multiple arbitraries
Test.property(
'addition is commutative',
fc.integer(),
fc.integer(),
(a, b) => {
expect(a + b).toBe(b + a)
},
)Test Builders
[F] on
<$fn extends Fn.AnyAny>($fn: $fn): TestBuilder
// Chainable methods:
.only(): TestBuilder
.skip(reason ?: string | undefined): TestBuilder
.skipIf(condition: () => boolean): TestBuilder
.concurrent(): TestBuilder
.tags(tags: string[]): TestBuilder
.name(template: string): TestBuilder
.onlyMatching(matcher: string): TestBuilder
.inputType<I>(): TestBuilder
.contextType < Ctx extends { } = { }> (): TestBuilder
.matrix<$values extends Rec.AnyReadonlyKeyTo<Arr.Any>>(values: $values): TestBuilder
.snapshotSerializer(serializer: (output: any, context: { i: State["input"]; n: string; o: State["output"]; } & State["context"]) => string): TestBuilder
.snapshotSchemas(schemas: any[]): TestBuilder
.on<Fn extends Fn.AnyAny>(fn: Fn): TestBuilder
.cases < Cases extends readonly any[] = readonly[] > (...cases ?: State["fn"] extends undefined ? GenericCase < State["input"], State["output"], State["context"] > [] : State["fn"] extends AnyAny ? (FunctionCase<EffectiveInput<State>, EffectiveOutput<State>, State["context"]> | ((ctx: State["context"]) => FunctionCase<EffectiveInput<State>, EffectiveOutput<State>, State["context"]>))[] : GenericCase < State["input"], State["output"], State["context"] > []): TestBuilder
.case(...args ?: State["fn"] extends undefined ? GenericCaseSingleParams<State["input"], State["output"], State["context"]> : State["fn"] extends AnyAny ? CaseSingleParams<EffectiveInput<State>, EffectiveOutput<State>> : never): TestBuilder
.case(name: string, runner: (context: State["context"]) => any): TestBuilder
.case$(caseObj: GenericCase<State["input"], State["output"], State["context"]>): TestBuilder
.casesInput(...inputs ?: UnwrappedInput < State > []): TestBuilder
.describeInputs(name: string, inputs: readonly UnwrappedInput < State > []): TestBuilder
.describe(name: string, cases: readonly(State["fn"] extends undefined ? GenericCase<State["input"], State["output"], State["context"]> : State["fn"] extends AnyAny ? FunctionCase<EffectiveInput<State>, EffectiveOutput<State>, State["context"]> : GenericCase<State["input"], State["output"], State["context"]>)[]): TestBuilder
.describe < ChildContext extends object = {}, ChildI = State['input'], ChildO = State['output'] > (name: string, callback: (builder: TestBuilder<State>) => TestBuilder<{ context: ChildContext; input: ChildI; output: ChildO; fn: State["fn"]; matrix: State["matrix"]; }>): TestBuilder
.onSetup<Ctx extends object>(factory: () => Ctx): TestBuilder
// Terminal methods:
.outputType<O>(): State["fn"] extends undefined ? TestBuilder<UpdateState<State, { output: O; }>> : never
.outputDefault<R>(provider: State["output"] extends undefined ? (context: State["context"]) => R : (context: State["context"]) => State["output"]): State["output"] extends undefined ? TestBuilder<UpdateState<State, { output: R; }>> : TestBuilder<State>
.onOutput<MappedInput>(mapper: State["fn"] extends undefined ? never : State["fn"] extends AnyAny ? (output: MappedInput, context: { i: EffectiveInput<State>; n: string; o: MappedInput; } & State["context"]) => EffectiveOutput<State> : never): State["fn"] extends undefined ? never : State["fn"] extends AnyAny ? TestBuilder<UpdateState<State, { output: MappedInput; }>> : never
.test(): void
.test(fn: State["fn"] extends undefined ? GenericTestFn<State> : State["fn"] extends AnyAny ? FunctionTestFn<State> : GenericTestFn<State>): void
// Transform methods:
.layer<R>(layer: Layer<R, never, never>): TestBuilderWithLayers
.layerEach<R>(factory: State["fn"] extends undefined ? (testCase: { i: State["input"]; o: State["output"]; } & State["context"]) => Layer<R, never, never> : State["fn"] extends AnyAny ? (testCase: { i: EffectiveInput<State>; o?: EffectiveOutput<State>; }) => Layer<R, never, never> : (testCase: { i: State["input"]; o: State["output"]; } & State["context"]) => Layer<R, never, never>): TestBuilderWithLayersCreates a test table builder for testing a specific function.
This is a shorthand for describe().on(fn) when you don't need a describe block. Types are automatically inferred from the function signature, making it ideal for quick function testing with minimal boilerplate.
Case Formats
Test cases can be specified in multiple formats:
Tuple Format (most common):
[[arg1, arg2], expected]- Test with expected output
[[arg1, arg2], expected, { comment: 'name' }]- Named test case (context is 3rd element)
[[arg1, arg2]]- Snapshot test (no expected value)
Object Format (more verbose but clearer):
{ input: [arg1, arg2], output: expected }{ input: [arg1, arg2], output: expected, skip: true, comment: 'name' }{ todo: 'Not implemented yet', comment: 'name' }
Examples:
Snapshot Mode with Error Handling
// Basic function testing
Test.on(add)
.cases(
[[2, 3], 5], // add(2, 3) === 5
[[0, 0], 0], // add(0, 0) === 0
[[-1, 1], 0], // add(-1, 1) === 0
)
.test()
// Using different case formats
Test.on(multiply)
.cases(
[[2, 3], 6], // Tuple format
[[5, 0], 0, { comment: 'zero case' }], // Named tuple with context
{ input: [-2, 3], output: -6 }, // Object format
{ input: [100, 100], output: 10000, comment: 'large numbers' },
)
.test()
// Custom assertions
Test.on(divide)
.cases([[10, 2], 5], [[10, 0], Infinity])
.test(({ result, output }) => {
if (output === Infinity) {
expect(result).toBe(Infinity)
} else {
expect(result).toBeCloseTo(output, 2)
}
})
// Output transformation - build full expectations from partials
Test.on(createUser)
.onOutput((partial, context) => ({
...defaultUser,
name: context.input[0],
...partial,
}))
.cases(
[['Alice'], { role: 'admin' }], // Only specify differences
[['Bob'], { role: 'user', age: 30 }],
)
.test()Promise Auto-Awaiting
// Mix successful and error cases - errors are captured automatically
Test.on(parseInt)
.cases(
['42'], // Returns: 42
['hello'], // Returns: NaN
)
.test()
// Validation functions - errors documented in snapshots
Test.on(Positive.from)
.cases(
[1],
[10],
[100], // THEN RETURNS the value
[0],
[-1],
[-10], // THEN THROWS "Value must be positive"
)
.test()Snapshot Format
╔══════════════════════════════════════════════════╗ GIVEN ARGUMENTS
hello
╠══════════════════════════════════════════════════╣ THEN RETURNS PROMISE RESOLVING TO STRING
HELLO
╚══════════════════════════════════════════════════╝[F] describe
(description: string, cases: any[]): TestBuilder
// Chainable methods:
.only(): TestBuilder
.skip(reason?: string | undefined): TestBuilder
.skipIf(condition: () => boolean): TestBuilder
.concurrent(): TestBuilder
.tags(tags: string[]): TestBuilder
.name(template: string): TestBuilder
.onlyMatching(matcher: string): TestBuilder
.inputType<I>(): TestBuilder
.contextType<Ctx extends {} = { }> (): TestBuilder
.matrix<$values extends Rec.AnyReadonlyKeyTo<Arr.Any>>(values: $values): TestBuilder
.snapshotSerializer(serializer: (output: any, context: { i: State["input"]; n: string; o: State["output"]; } & State["context"]) => string): TestBuilder
.snapshotSchemas(schemas: any[]): TestBuilder
.on<Fn extends Fn.AnyAny>(fn: Fn): TestBuilder
.cases < Cases extends readonly any[] = readonly[] > (...cases ?: State["fn"] extends undefined ? GenericCase < State["input"], State["output"], State["context"] > [] : State["fn"] extends AnyAny ? (FunctionCase<EffectiveInput<State>, EffectiveOutput<State>, State["context"]> | ((ctx: State["context"]) => FunctionCase<EffectiveInput<State>, EffectiveOutput<State>, State["context"]>))[] : GenericCase < State["input"], State["output"], State["context"] > []): TestBuilder
.case(...args ?: State["fn"] extends undefined ? GenericCaseSingleParams<State["input"], State["output"], State["context"]> : State["fn"] extends AnyAny ? CaseSingleParams<EffectiveInput<State>, EffectiveOutput<State>> : never): TestBuilder
.case(name: string, runner: (context: State["context"]) => any): TestBuilder
.case$(caseObj: GenericCase<State["input"], State["output"], State["context"]>): TestBuilder
.casesInput(...inputs ?: UnwrappedInput < State > []): TestBuilder
.describeInputs(name: string, inputs: readonly UnwrappedInput < State > []): TestBuilder
.describe(name: string, cases: readonly(State["fn"] extends undefined ? GenericCase<State["input"], State["output"], State["context"]> : State["fn"] extends AnyAny ? FunctionCase<EffectiveInput<State>, EffectiveOutput<State>, State["context"]> : GenericCase<State["input"], State["output"], State["context"]>)[]): TestBuilder
.describe < ChildContext extends object = {}, ChildI = State['input'], ChildO = State['output'] > (name: string, callback: (builder: TestBuilder<State>) => TestBuilder<{ context: ChildContext; input: ChildI; output: ChildO; fn: State["fn"]; matrix: State["matrix"]; }>): TestBuilder
.onSetup<Ctx extends object>(factory: () => Ctx): TestBuilder
// Terminal methods:
.outputType<O>(): State["fn"] extends undefined ? TestBuilder<UpdateState<State, { output: O; }>> : never
.outputDefault<R>(provider: State["output"] extends undefined ? (context: State["context"]) => R : (context: State["context"]) => State["output"]): State["output"] extends undefined ? TestBuilder<UpdateState<State, { output: R; }>> : TestBuilder<State>
.onOutput<MappedInput>(mapper: State["fn"] extends undefined ? never : State["fn"] extends AnyAny ? (output: MappedInput, context: { i: EffectiveInput<State>; n: string; o: MappedInput; } & State["context"]) => EffectiveOutput<State> : never): State["fn"] extends undefined ? never : State["fn"] extends AnyAny ? TestBuilder<UpdateState<State, { output: MappedInput; }>> : never
.test(): void
.test(fn: State["fn"] extends undefined ? GenericTestFn<State> : State["fn"] extends AnyAny ? FunctionTestFn<State> : GenericTestFn<State>): void
// Transform methods:
.layer<R>(layer: Layer<R, never, never>): TestBuilderWithLayers
.layerEach<R>(factory: State["fn"] extends undefined ? (testCase: { i: State["input"]; o: State["output"]; } & State["context"]) => Layer<R, never, never> : State["fn"] extends AnyAny ? (testCase: { i: EffectiveInput<State>; o?: EffectiveOutput<State>; }) => Layer<R, never, never> : (testCase: { i: State["input"]; o: State["output"]; } & State["context"]) => Layer<R, never, never>): TestBuilderWithLayersCreates a test table builder for property-based and example-based testing.
CRITICAL: The builder supports chaining multiple .describe(name, cases) calls to organize related test groups. Each .describe() adds a new test group and returns the builder for continued chaining. The chain must end with .test() to execute all groups.
Test tables allow you to define multiple test cases with inputs and expected outputs, reducing boilerplate and making tests more maintainable. The builder supports two modes:
Modes
Function Mode
- Test a specific function with
.on(fn): - Types are automatically inferred from the function signature
- Test cases specify function arguments and expected return values
- Default assertion compares actual vs expected using Effect's equality
Generic Mode
- Define custom types with
.i<T>and.o<T>: - Explicitly specify input and output types
- Provide custom test logic to validate cases
- Useful for testing complex behaviors beyond simple function calls
Features
Nested Describes
- Use
>separator to create nested describe blocks: Test.describe('Parent > Child')createsdescribe('Parent', () => describe('Child', ...))- Chain multiple
.describe()calls: each adds a test group under its specified path - Supports any depth:
'API > Users > Create'creates three levels
Matrix Testing
- Use
.matrix()to run cases across parameter combinations: - Generates cartesian product of all matrix value arrays
- Each test case runs once for each combination
- Matrix values available as
matrixin test context - Combines with nested describes for organized test suites
Examples:
// ✅ CORRECT - Chain .describe() calls to add multiple test groups
Test
.describe('decodeSync > basic', [
[['1.2.3']],
[['invalid']],
])
.describe('decodeSync > union', [
[['1.2.3-beta']],
[['1.2.3+build']],
])
.test()
// Alternative: Single describe with all cases
Test.describe('decodeSync > basic')
.on(decodeSync)
.cases([['1.2.3']], [['invalid']])
.test()
// Function mode - testing a math function
Test.describe('addition')
.on(add)
.cases(
[[2, 3], 5], // add(2, 3) should return 5
[[-1, -2], -3, { comment: 'negative' }], // Named test case with context
[[0, 0], 0], // Edge case
)
.test() // Uses default assertion (Effect's Equal.equals)
// Generic mode - custom validation logic
Test.describe('email validation')
.inputType<string>()
.outputType<boolean>()
.cases(
['user@example.com', true],
['invalid.com', false],
['', false],
)
.test(({ input, output }) => {
const result = isValidEmail(input)
expect(result).toBe(output)
})
// Nested describe blocks with ' > ' separator - chained
Test
.describe('Transform > String', [
['hello', 'HELLO'],
])
.describe('Transform > Number', [
[42, 42],
])
.test(({ input, output }) => {
// Custom test logic for both groups
if (typeof input === 'string') {
expect(input.toUpperCase()).toBe(output)
} else {
expect(input).toBe(output)
}
})
// Matrix testing - runs each case for all parameter combinations
Test.describe('string transform')
.inputType<string>()
.outputType<string>()
.matrix({
uppercase: [true, false],
prefix: ['', 'pre_'],
})
.cases(
['hello', 'hello'],
['world', 'world'],
)
.test(({ input, output, matrix }) => {
// Runs 4 times (2 cases × 2 uppercase × 2 prefix = 8 tests)
let result = input
if (matrix.prefix) result = matrix.prefix + result
if (matrix.uppercase) result = result.toUpperCase()
let expected = output
if (matrix.prefix) expected = matrix.prefix + expected
if (matrix.uppercase) expected = expected.toUpperCase()
expect(result).toBe(expected)
})