UNPKG

jsproptest

Version:

Javascript Property-based Testing

518 lines (477 loc) 22.4 kB
import { Generator } from '../Generator' import { Random } from '../Random' import { Shrinkable } from '../Shrinkable' import { shrinkableArray } from '../shrinker/array' import { Try, TryResult } from '../Try' import { GenerationError } from '../util/error' import { JSONStringify } from '../util/JSON' import { ActionGenFactory, SimpleActionGenFactory } from './actionof' import { Action, EmptyModel } from './statefulbase' /** * Represents the result of a shrinking attempt, indicating whether shrinking * successfully found a smaller failing case and holding the relevant data. */ class ShrinkResult { readonly isSuccessful: boolean constructor(readonly initialObj: unknown, readonly actions: unknown[], readonly error?: Error) { this.isSuccessful = typeof error !== 'undefined' } } /** * Holds the state of a specific test execution, including the steps to reproduce * the initial state, the sequence of actions (potentially shrunk), the random seeds * used for generation, and the error encountered. Used during the shrinking process. */ class TestResult { constructor( readonly initialSteps: number[], readonly actions: Shrinkable<unknown>[], readonly randoms: Random[], readonly error: Error ) {} } /** * Orchestrates stateful property-based testing. * It generates an initial state, then a sequence of actions based on the current state, * executes these actions against both the real object and a model, and checks for discrepancies * or errors. If a failure occurs, it attempts to shrink the sequence of actions and the initial * state to find a minimal failing test case. * * @template ObjectType The type of the system under test. * @template ModelType The type of the model used for checking correctness. */ export class StatefulProperty<ObjectType, ModelType> { private seed: string = '' private numRuns = 100 private minActions = 1 private maxActions = 100 private maxAllowedConsecutiveGenerationFailures = 20 private verbose = false private onStartup?: () => void private onCleanup?: () => void private postCheck?: (obj: ObjectType, mdl: ModelType) => void constructor( readonly initialGen: Generator<ObjectType>, readonly modelFactory: (_: ObjectType) => ModelType, readonly actionGenFactory: ActionGenFactory<ObjectType, ModelType> ) {} setSeed(seed: string) { this.seed = seed return this } setNumRuns(numRuns: number) { this.numRuns = numRuns return this } setMinActions(num: number) { this.minActions = num return this } setMaxActions(num: number) { this.maxActions = num return this } setOnStartup(onStartup: () => void) { this.onStartup = onStartup return this } setOnCleanup(onCleanup: () => void) { this.onCleanup = onCleanup return this } setPostCheck(postCheck: (obj: ObjectType, mdl: ModelType) => void) { this.postCheck = postCheck return this } setPostCheckWithoutModel(postCheck: (obj: ObjectType) => void) { this.postCheck = (obj: ObjectType, _: ModelType) => postCheck(obj) return this } setVerbosity(verbose: boolean) { this.verbose = verbose return this } /** * Executes the stateful property test suite. * Runs `numRuns` test iterations. In each iteration: * 1. Generates an initial object and model. * 2. Generates a sequence of actions based on the evolving state. * 3. Executes each action, updating the object and model. * 4. If an action fails (throws an error), initiates the shrinking process. * 5. Performs a final check (if `postCheck` is set) after all actions complete. * 6. Calls cleanup hooks (`onCleanup`). */ go() { if (this.minActions <= 0 || this.minActions > this.maxActions) throw new Error('invalid minSize or maxSize: ' + this.minActions + ', ' + this.maxActions) const rand = this.seed === '' ? new Random() : new Random(this.seed) for (let i = 0; i < this.numRuns; i++) { const savedRand = rand.clone() if (this.onStartup) this.onStartup() // generate initial object and model const { obj, model } = this.generateInitial(rand) .map(shr => { return { obj: shr.value.obj, model: shr.value.model } }) .getOrThrow() // actions executed so far const actionShrArr: Shrinkable<Action<ObjectType, ModelType>>[] = [] const randomArr: Random[] = [] const numActions = rand.interval(this.minActions, this.maxActions) let numConsecutiveFailures = 0 for (let j = 0; j < numActions; ) { // Phase 1: Generate an action // one by one, generate action by calling actionGenFactory with current state let actionShr: Shrinkable<Action<ObjectType, ModelType>> try { const randCopy = rand.clone() actionShr = this.actionGenFactory(obj, model).generate(rand) // save the action and random generator state for shrinking on successful generation randomArr.push(randCopy) actionShrArr.push(actionShr) } catch (e) { // tolerate generation failures // exception while generating action can happen: ignore and retry unless limit is reached if (this.verbose) console.info('discarded action: ' + (e as Error).message) numConsecutiveFailures++ if (numConsecutiveFailures >= this.maxAllowedConsecutiveGenerationFailures) { console.warn( 'could not generate a suitable action. Tried ' + this.maxAllowedConsecutiveGenerationFailures + ' failures' ) break } continue } numConsecutiveFailures = 0 // Phase 2: Execute the action - execute the action to update obj and model try { const action = actionShr.value action.call(obj, model) if (this.verbose) console.info('action generated. run: ', i, 'action: ', j) j++ } catch (e) { // shrink based on actionShrArr const originalActions = actionShrArr.map(actionShr => actionShr.value) const shrinkResult = this.shrink(originalActions, savedRand, randomArr, e as Error) throw this.processFailureAsError(e as Error, originalActions, shrinkResult) } } if (this.postCheck) this.postCheck(obj, model) if (this.onCleanup) this.onCleanup() } } /** * Orchestrates the shrinking process when a test failure occurs. * It attempts to find a smaller or simpler failing test case by: * 1. Shrinking the sequence of random seeds used (`shrinkRandomwise`). * 2. Shrinking the initial state generated (`shrinkInitialObject`). * * @param originalActions The sequence of actions that led to the failure. * @param initialRand The initial random generator state for the failed run. * @param randomArr The array of random generator states used for each action generation. * @param originalError The error that triggered the shrinking. * @returns A `ShrinkResult` containing the shrunk test case or the original failure if shrinking was unsuccessful. */ private shrink( originalActions: Action<ObjectType, ModelType>[], initialRand: Random, randomArr: Random[], originalError: Error ): ShrinkResult { if (originalActions.length !== randomArr.length) throw new Error('action and random arrays have different sizes') // phase 1: shrink random wise /* eslint-disable-next-line prefer-const */ let { shrunk, result } = this.shrinkActionsRandomWise(initialRand, randomArr) // phase 2: shrink initial object if (shrunk) { result = this.shrinkInitialObject(initialRand, result!) } else { result = this.shrinkInitialObject( initialRand, new TestResult( [], originalActions.map(action => new Shrinkable(action)), randomArr, originalError ) ) } // phase 3: return the shrink result (rebuild the initial object from the shrink result) // (initial object is recreated in each test run) const initialObj = this.initialGen.generate(initialRand.clone()) // shrinking done if (shrunk) { const testResult: TestResult = result as TestResult return new ShrinkResult( initialObj.retrieve(testResult.initialSteps), testResult.actions, testResult.error as Error ) } // unable to shrink -> return originally failed combination else return new ShrinkResult(initialObj.value, originalActions) } /** * Attempts to shrink the failing test case by simplifying the sequence of random numbers * used to generate the actions. It iteratively tries smaller sets of random seeds derived * from the original sequence. * * @param initialRand The initial random generator state for the test run. * @param randomArr The original sequence of random generator states for each action. * @returns An object indicating if shrinking was successful and the resulting `TestResult` if it was. */ private shrinkActionsRandomWise(initialRand: Random, randomArr: Random[]): { shrunk: boolean; result?: TestResult } { const randomShrinkables: Shrinkable<Random>[] = randomArr.map(rand => new Shrinkable(rand)) let nextArrayShr = shrinkableArray(randomShrinkables, 0) let shrinks = nextArrayShr.shrinks() let shrunk = false let result: TestResult | undefined while (!shrinks.isEmpty()) { const iter = shrinks.iterator() let shrinkFound = false while (iter.hasNext()) { nextArrayShr = iter.next() const testResult: boolean | TestResult = this.testWithRandoms(initialRand.clone(), nextArrayShr.value) // found a shrink (a non-generation error occurred) if (testResult instanceof TestResult) { result = testResult shrinks = nextArrayShr.shrinks() shrinkFound = true break } } if (shrinkFound) shrunk = true else break } return { shrunk, result } } /** * Attempts to shrink the failing test case by simplifying the initial generated object. * It iteratively tries simpler versions of the initial object derived from its shrink tree. * * @param initialRand The initial random generator state for the test run. * @param prevTestResult The best failing test result found so far (potentially after random-wise shrinking). * @returns The TestResult corresponding to the smallest initial object found that still causes a failure. */ private shrinkInitialObject(initialRand: Random, prevTestResult: TestResult): TestResult { let shr = this.generateInitial(initialRand.clone()).getOrThrow() let shrinks = shr.shrinks() let result: TestResult = prevTestResult const steps: number[] = [] while (!shrinks.isEmpty()) { const iter = shrinks.iterator() let step = 0 let shrinkFound = false while (iter.hasNext()) { shr = iter.next() const testSteps = steps.concat() testSteps.push(step) const testResult: boolean | TestResult = this.testWithInitial( initialRand.clone(), testSteps, prevTestResult.randoms ) // found a shrink (a non-generation error occurred) if (testResult instanceof TestResult) { result = testResult shrinks = shr.shrinks() steps.push(step) shrinkFound = true break } step++ } if (!shrinkFound) break } return result } /** * Runs a test sequence using the default initial state generation and * a given sequence of random generators for action generation. Used during random-wise shrinking. * * @param initialRand The random generator for the initial state. * @param randoms The sequence of random generators to use for action generation. * @returns `true` if the test passes, `false` if a generation error occurs, or a `TestResult` if a non-generation error occurs. */ private testWithRandoms(initialRand: Random, randoms: Random[]): boolean | TestResult { const actionShrs: Shrinkable<Action<ObjectType, ModelType>>[] = [] try { if (this.onStartup) this.onStartup() const { obj, model } = this.generateInitial(initialRand.clone()) .map(shr => { return { obj: shr.value.obj, model: shr.value.model } }) .getOrThrow() for (const rand of randoms) { let action: Action<ObjectType, ModelType> try { const actionShr = this.actionGenFactory(obj, model).generate(rand) actionShrs.push(actionShr) action = actionShr.value } catch (e) { if (this.verbose) console.info('failure in action generation during shrinking: ' + (e as Error).toString()) throw new GenerationError('failure in action generation during shrinking') } action.call(obj, model) } if (this.postCheck) this.postCheck(obj, model) if (this.onCleanup) this.onCleanup() return true } catch (e) { if (e instanceof GenerationError) return false return new TestResult([], actionShrs, randoms, e as Error) } } /** * Runs a test sequence using a specific initial state derived from shrink steps * and a fixed sequence of random generators for action generation. Used during initial object shrinking. * * @param initialRand The base random generator for generating the initial state. * @param steps The sequence of shrink steps to apply to the initial state generator. * @param randoms The fixed sequence of random generators to use for action generation. * @returns `true` if the test passes, `false` if a generation error occurs, or a `TestResult` if a non-generation error occurs. */ private testWithInitial(initialRand: Random, steps: number[], randoms: Random[]): boolean | TestResult { const actionShrs: Shrinkable<Action<ObjectType, ModelType>>[] = [] try { if (this.onStartup) this.onStartup() // Phase 1: Generate the initial object and model // retrieve initial state by shrink steps chosen const { obj, model } = this.generateInitial(initialRand.clone()) .map(shr => shr.retrieve(steps)) .map(shr => { return { obj: shr.value.obj, model: shr.value.model } }) .getOrThrow() // Phase 2: Generate actions with the initial object and model + stored randoms for (const rand of randoms) { let action: Action<ObjectType, ModelType> try { const actionShr = this.actionGenFactory(obj, model).generate(rand) actionShrs.push(actionShr) action = actionShr.value } catch (e) { if (this.verbose) console.info('failure in action generation during shrinking: ' + (e as Error).toString()) throw new GenerationError('failure in action generation during shrinking') } action.call(obj, model) } if (this.postCheck) this.postCheck(obj, model) if (this.onCleanup) this.onCleanup() return true } catch (e) { if (e instanceof GenerationError) return false return new TestResult(steps, actionShrs, randoms, e as Error) } } /** * Generates the initial object and its corresponding model using the provided * initial generator and model factory. Wraps the generation in a `Try` to handle * potential generation errors gracefully. * * @param initialRand The random generator to use for initial state generation. * @returns A `TryResult` containing the shrinkable initial object/model pair or a `GenerationError`. */ private generateInitial(initialRand: Random): TryResult<Shrinkable<{ obj: ObjectType; model: ModelType }>> { return Try<Shrinkable<{ obj: ObjectType; model: ModelType }>>(() => { const obj = this.initialGen.generate(initialRand.clone()) return obj.map(o => { return { obj: o, model: this.modelFactory(o) } }) }).mapError(e => { if (this.verbose) console.info('failure in initialization: ' + (e as Error).toString()) return new GenerationError('failure in initialization: ' + (e as Error).toString()) }) } /** * Formats and enhances the error message after a failure, incorporating details * from both the original failure and the shrunk failure (if shrinking was successful). * Provides context about the initial object and action sequence that caused the error. * * @param originalError The error initially caught during the test run. * @param originalActions The original sequence of actions that failed. * @param shrinkResult The result of the shrinking process. * @returns A new Error object with a detailed message. */ private processFailureAsError( originalError: Error, originalActions: Action<ObjectType, ModelType>[], shrinkResult: ShrinkResult ) { // shrink if (shrinkResult.isSuccessful) { const newError = new Error( 'stateful property failed (args found by shrinking): ' + JSONStringify(shrinkResult.initialObj) + ', ' + JSONStringify(shrinkResult.actions.map(shr => (shr as Shrinkable<{ name: string }>).value.name)) ) const shrinkError = shrinkResult.error as Error newError.message += '\n shrink error: ' + shrinkError.message newError.stack = '\n' + shrinkError.stack newError.message += '\n original args: ' + JSONStringify(shrinkResult.initialObj) + ', ' + JSONStringify(originalActions.map(action => action.name)) newError.message += '\n original error: ' + originalError.message newError.message += '\n original error stack: ' + originalError.stack return newError } // not shrunk else { const newError = new Error( 'stateful property failed (args found): ' + JSONStringify(shrinkResult.initialObj) + ', ' + JSONStringify(shrinkResult.actions) ) newError.message += '\n error: ' + originalError.message newError.stack = '\n' + originalError.stack return newError } } } /** * Factory function to create a `StatefulProperty` instance. * * @template ObjectType The type of the system under test. * @template ModelType The type of the model used for checking correctness. * @param initialGen Generator for the initial state of the object under test. * @param modelFactory Function to create the initial model state based on the initial object state. * @param actionGenFactory Factory to generate actions based on the current object and model state. * @returns A new `StatefulProperty` instance configured with the provided generators and factories. */ export function statefulProperty<ObjectType, ModelType>( initialGen: Generator<ObjectType>, modelFactory: (_: ObjectType) => ModelType, actionGenFactory: ActionGenFactory<ObjectType, ModelType> ) { return new StatefulProperty(initialGen, modelFactory, actionGenFactory) } /** * Factory function to create a `StatefulProperty` instance for cases where * no explicit model is needed (using an `EmptyModel`). Simplifies setup when * checks only involve the object under test or invariants. * * @template ObjectType The type of the system under test. * @param initialGen Generator for the initial state of the object under test. * @param simpleActionGenFactory Factory to generate actions based only on the current object state. * @returns A new `StatefulProperty` instance configured for model-less stateful testing. */ export function simpleStatefulProperty<ObjectType>( initialGen: Generator<ObjectType>, simpleActionGenFactory: SimpleActionGenFactory<ObjectType> ) { const actionGenFactory = (obj: ObjectType, _: EmptyModel) => { return simpleActionGenFactory(obj).map(action => Action.fromSimpleAction<ObjectType, EmptyModel>(action)) } const emptyModel: EmptyModel = {} const modelFactory = (_: ObjectType) => emptyModel return new StatefulProperty(initialGen, modelFactory, actionGenFactory) }