diff --git a/src/index.ts b/src/index.ts index 8563321..8053027 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,19 +1,36 @@ -import { Arbitrary, check, property, RunDetails, Parameters } from 'fast-check'; +import { Arbitrary, check, Parameters, property, RunDetails } from 'fast-check'; -export type Check = (this: Mocha.Context, val: T) => boolean; +export type Check = (this: Mocha.Context, val: T) => boolean | never | void; export type WrappedIt = (name: string, check: Check) => void; export type Suite = (it: WrappedIt) => void; -export function over(name: string, strategy: Arbitrary, suite: Suite, parameters?: Parameters<[T]>): void { +export type ErrorReporter = (details: RunDetails) => string | undefined; +export interface ErrorParameters extends Parameters { + errorReporter?: ErrorReporter; +} + +export function over(name: string, strategy: Arbitrary, suite: Suite, parameters?: ErrorParameters): void { describe(name, () => { suite((name, test) => { it(name, function (this: Mocha.Context): Promise { const ctx = this; + // something about check's type signature requires examples to be tuples, + // which leads to triple-wrapping examples for tuple properties. help remove one layer + const examples: Array<[T]> = parameters?.examples?.map((it) => [it]) || []; + const checkParameters: Parameters<[T]> = { + ...parameters, + // handle result formatting here + asyncReporter: undefined, + reporter: undefined, + examples, + }; + const reporter = (parameters?.errorReporter || briefReporter) as ErrorReporter<[T]>; return new Promise((res, rej) => { - const result = check(property(strategy, (val) => test.call(ctx, val)), parameters); + // wrap the strategy arb in a one-shot property checking the test fn + const result = check(property(strategy, (val) => test.call(ctx, val)), checkParameters); if (result.failed) { - rej(new Error(formatDetails(result))); + rej(new Error(reporter(result))); } else { res(); } @@ -23,10 +40,19 @@ export function over(name: string, strategy: Arbitrary, suite: Suite, p }); } -export function formatDetails(details: RunDetails<[T]>): string { +export function briefReporter(details: RunDetails<[T]>): string { const prefix = formatPrefix(details); const counts = `${prefix} after ${details.numRuns} runs and ${details.numShrinks} shrinks`; + const examples = formatExamples(details); + if (isErrorRun(details)) { + return `${counts}, ${examples}\n${details.error}`; + } else { + return `${counts}, ${examples}`; + } +} + +export function formatExamples(details: RunDetails<[T]>): string { if (details.counterexample !== null) { const examples = details.counterexample.map((val) => { if (isString(val)) { @@ -35,15 +61,16 @@ export function formatDetails(details: RunDetails<[T]>): string { return val; } }).join(','); - return `${counts}, failing on: ${examples}`; + return `failing on: ${examples}`; } else { - return `${counts}, without counterexamples`; + return `without counterexamples`; } + } export function formatPrefix(details: RunDetails<[T]>): string { if (isString(details.error)) { - if (details.error.startsWith('Error:')) { + if (isErrorRun(details)) { return 'Property failed by throwing an error'; } @@ -56,3 +83,7 @@ export function formatPrefix(details: RunDetails<[T]>): string { export function isString(val: unknown): val is string { return typeof val === 'string'; } + +export function isErrorRun(details: RunDetails): boolean { + return details.error?.startsWith('Error:') || false; +} \ No newline at end of file diff --git a/test/TestOver.ts b/test/TestOver.ts index 5ea9845..08c5613 100644 --- a/test/TestOver.ts +++ b/test/TestOver.ts @@ -1,20 +1,22 @@ -import { integer, lorem, tuple, uuid } from 'fast-check'; +import { expect } from 'chai'; +import { array, defaultReportMessage, integer, lorem, oneof, tuple, uuid } from 'fast-check'; import { over } from '../src/index'; -describe('some foo', () => { - over('the bars', integer(), (it) => { - const large = Math.floor(Math.random() * 1_000_000); - it('should be a small number', (bar: number) => { - return bar < large; +const LARGE_VALUE = Math.floor(Math.random() * 1_000_000_000); + +describe('example properties', () => { + over('some numbers', integer(), (it) => { + it('should be a small number', (n: number) => { + return n < LARGE_VALUE; }); - it('should be even', (bar: number) => { - return bar % 2 === 0; + it('should be even', (n: number) => { + return n % 2 === 0; }); - it('should not throw', (t: number) => { - if (t.toString()[3] === '9') { + it('should not throw', (n: number) => { + if (n.toString()[3] === '9') { throw new Error('not a real number!'); } @@ -23,6 +25,7 @@ describe('some foo', () => { }); over('some IDs', uuid(), (it) => { + // beforeEach hooks work normally, since the wrapped it calls through to real it beforeEach(() => { console.log('before each ID test'); }); @@ -35,7 +38,8 @@ describe('some foo', () => { return id.length > 2; }); }, { - examples: [['a']], + // fast-check parameters are supported, like examples + examples: ['a', 'b'], numRuns: 1_000_000_000, }); @@ -49,5 +53,29 @@ describe('some foo', () => { it('should have content', (text: string) => { return text.length > 0; }); + }, { + // error formatting can be overridden with a custom handler, or fast-check's default + errorReporter: defaultReportMessage, + }); + + over('tuples', tuple(integer(), integer()), (it) => { + // tuple properties are passed as a single parameter + it('should not be equal', ([a, b]) => { + return a === b; + }); + + it('should be uneven', ([a, b]) => { + return a < b; + }); + }, { + examples: [[1, 2]] + }); + + over('arrays', array(integer()), (it) => { + it('should have items', (t: Array) => { + expect(t).to.have.length.lessThan(5_000); + }); + }, { + numRuns: 1_000, }); });