diff --git a/README.md b/README.md index cb40041..3888ace 100644 --- a/README.md +++ b/README.md @@ -205,9 +205,9 @@ expect(spy()).toBe(undefined) import type { WhenOptions } from 'vitest-when' ``` -| option | required | type | description | -| ------- | -------- | ------- | -------------------------------------------------- | -| `times` | no | integer | Only trigger configured behavior a number of times | +| option | default | type | description | +| ------- | ------- | ------- | -------------------------------------------------- | +| `times` | N/A | integer | Only trigger configured behavior a number of times | ### `.calledWith(...args: TArgs): Stub` @@ -465,3 +465,57 @@ when(spy) expect(spy('hello')).toEqual('world') expect(spy('hello')).toEqual('solar system') ``` + +### `debug(spy: TFunc, options?: DebugOptions): DebugInfo` + +Logs and returns information about a mock's stubbing and usage. Useful if a test with mocks is failing and you can't figure out why. + +```ts +import { when, debug } from 'vitest-when' + +const coolFunc = vi.fn().mockName('coolFunc') + +when(coolFunc).calledWith(1, 2, 3).thenReturn(123) +when(coolFunc).calledWith(4, 5, 6).thenThrow(new Error('oh no')) + +const result = coolFunc(1, 2, 4) + +debug(coolFunc) +// `coolFunc()` has: +// * 2 stubbings with 0 calls +// * Called 0 times: `(1, 2, 3) => 123` +// * Called 0 times: `(4, 5, 6) => { throw [Error: oh no] }` +// * 1 unmatched call +// * `(1, 2, 4)` +``` + +#### `DebugOptions` + +```ts +import type { DebugOptions } from 'vitest-when' +``` + +| option | default | type | description | +| ------ | ------- | ------- | -------------------------------------- | +| `log` | `true` | boolean | Whether the call to `debug` should log | + +#### `DebugResult` + +```ts +import type { DebugResult, Stubbing, Behavior } from 'vitest-when' +``` + +| fields | type | description | +| ---------------------------- | -------------------------------------------- | ----------------------------------------------------------- | +| `description` | `string` | A human-readable description of the stub, logged by default | +| `name` | `string` | The name of the mock, if set by [`mockName`][mockName] | +| `stubbings` | `Stubbing[]` | The list of configured stub behaviors | +| `stubbings[].args` | `unknown[]` | The stubbing's arguments to match | +| `stubbings[].behavior` | `Behavior` | The configured behavior of the stubbing | +| `stubbings[].behavior.type` | `return`, `throw`, `resolve`, `reject`, `do` | Result type of the stubbing | +| `stubbings[].behavior.value` | `unknown` | Value for the behavior, if `type` is `return` or `resolve` | +| `stubbings[].behavior.error` | `unknown` | Error for the behavior, it `type` is `throw` or `reject` | +| `stubbings[].matchedCalls` | `unknown[][]` | Actual calls that matched the stubbing, if any | +| `unmatchedCalls` | `unknown[][]` | Actual calls that did not match a stubbing | + +[mockName]: https://vitest.dev/api/mock.html#mockname diff --git a/example/meaning-of-life.test.ts b/example/meaning-of-life.test.ts index 1a65d5c..ff44b0b 100644 --- a/example/meaning-of-life.test.ts +++ b/example/meaning-of-life.test.ts @@ -1,5 +1,5 @@ import { vi, describe, afterEach, it, expect } from 'vitest' -import { when } from 'vitest-when' +import { when, debug } from 'vitest-when' import * as deepThought from './deep-thought.ts' import * as earth from './earth.ts' @@ -19,6 +19,9 @@ describe('get the meaning of life', () => { const result = await subject.createMeaning() + debug(deepThought.calculateAnswer) + debug(earth.calculateQuestion) + expect(result).toEqual({ question: "What's 6 by 9?", answer: 42 }) }) }) diff --git a/package.json b/package.json index c172d65..d3d4481 100644 --- a/package.json +++ b/package.json @@ -81,5 +81,8 @@ "publishConfig": { "access": "public", "provenance": true + }, + "dependencies": { + "pretty-format": "^29.7.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71631a6..317b48e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +dependencies: + pretty-format: + specifier: ^29.7.0 + version: 29.7.0 + devDependencies: '@mcous/eslint-config': specifier: 0.4.3 @@ -742,7 +747,6 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@sinclair/typebox': 0.27.8 - dev: true /@jridgewell/gen-mapping@0.3.3: resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==} @@ -1051,7 +1055,6 @@ packages: /@sinclair/typebox@0.27.8: resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} - dev: true /@types/estree@1.0.5: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} @@ -1313,7 +1316,6 @@ packages: /ansi-styles@5.2.0: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} - dev: true /any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -2717,7 +2719,6 @@ packages: '@jest/schemas': 29.6.3 ansi-styles: 5.2.0 react-is: 18.2.0 - dev: true /punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} @@ -2730,7 +2731,6 @@ packages: /react-is@18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} - dev: true /read-pkg-up@7.0.1: resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} diff --git a/src/behaviors.ts b/src/behaviors.ts index cc15c8a..96cf7df 100644 --- a/src/behaviors.ts +++ b/src/behaviors.ts @@ -8,6 +8,10 @@ export interface WhenOptions { export interface BehaviorStack { use: (args: Parameters) => BehaviorEntry> | undefined + getAll: () => readonly BehaviorEntry>[] + + getUnmatchedCalls: () => readonly Parameters[] + bindArgs: >( args: TArgs, options: WhenOptions, @@ -24,80 +28,115 @@ export interface BoundBehaviorStack { export interface BehaviorEntry { args: TArgs - returnValue?: unknown - rejectError?: unknown - throwError?: unknown - doCallback?: AnyFunction | undefined - times?: number | undefined + behavior: Behavior + calls: TArgs[] + maxCallCount?: number | undefined } +export const BehaviorType = { + RETURN: 'return', + RESOLVE: 'resolve', + THROW: 'throw', + REJECT: 'reject', + DO: 'do', +} as const + +export type Behavior = + | { type: typeof BehaviorType.RETURN; value: unknown } + | { type: typeof BehaviorType.RESOLVE; value: unknown } + | { type: typeof BehaviorType.THROW; error: unknown } + | { type: typeof BehaviorType.REJECT; error: unknown } + | { type: typeof BehaviorType.DO; callback: AnyFunction } + export interface BehaviorOptions { value: TValue - times: number | undefined + maxCallCount: number | undefined } export const createBehaviorStack = < TFunc extends AnyFunction, >(): BehaviorStack => { const behaviors: BehaviorEntry>[] = [] + const unmatchedCalls: Parameters[] = [] return { + getAll: () => behaviors, + + getUnmatchedCalls: () => unmatchedCalls, + use: (args) => { const behavior = behaviors .filter((b) => behaviorAvailable(b)) .find(behaviorMatches(args)) - if (behavior?.times !== undefined) { - behavior.times -= 1 + if (!behavior) { + unmatchedCalls.push(args) + return undefined } + behavior.calls.push(args) return behavior }, bindArgs: (args, options) => ({ addReturn: (values) => { behaviors.unshift( - ...getBehaviorOptions(values, options).map(({ value, times }) => ({ - args, - times, - returnValue: value, - })), + ...getBehaviorOptions(values, options).map( + ({ value, maxCallCount }) => ({ + args, + maxCallCount, + behavior: { type: BehaviorType.RETURN, value }, + calls: [], + }), + ), ) }, addResolve: (values) => { behaviors.unshift( - ...getBehaviorOptions(values, options).map(({ value, times }) => ({ - args, - times, - returnValue: Promise.resolve(value), - })), + ...getBehaviorOptions(values, options).map( + ({ value, maxCallCount }) => ({ + args, + maxCallCount, + behavior: { type: BehaviorType.RESOLVE, value }, + calls: [], + }), + ), ) }, addThrow: (values) => { behaviors.unshift( - ...getBehaviorOptions(values, options).map(({ value, times }) => ({ - args, - times, - throwError: value, - })), + ...getBehaviorOptions(values, options).map( + ({ value, maxCallCount }) => ({ + args, + maxCallCount, + behavior: { type: BehaviorType.THROW, error: value }, + calls: [], + }), + ), ) }, addReject: (values) => { behaviors.unshift( - ...getBehaviorOptions(values, options).map(({ value, times }) => ({ - args, - times, - rejectError: value, - })), + ...getBehaviorOptions(values, options).map( + ({ value, maxCallCount }) => ({ + args, + maxCallCount, + behavior: { type: BehaviorType.REJECT, error: value }, + calls: [], + }), + ), ) }, addDo: (values) => { behaviors.unshift( - ...getBehaviorOptions(values, options).map(({ value, times }) => ({ - args, - times, - doCallback: value, - })), + ...getBehaviorOptions(values, options).map( + ({ value, maxCallCount }) => ({ + args, + maxCallCount, + behavior: { type: BehaviorType.DO, callback: value }, + calls: [], + }), + ), ) }, }), @@ -114,14 +153,17 @@ const getBehaviorOptions = ( return values.map((value, index) => ({ value, - times: times ?? (index < values.length - 1 ? 1 : undefined), + maxCallCount: times ?? (index < values.length - 1 ? 1 : undefined), })) } const behaviorAvailable = ( behavior: BehaviorEntry, ): boolean => { - return behavior.times === undefined || behavior.times > 0 + return ( + behavior.maxCallCount === undefined || + behavior.calls.length < behavior.maxCallCount + ) } const behaviorMatches = (args: TArgs) => { diff --git a/src/debug.ts b/src/debug.ts new file mode 100644 index 0000000..1d1fb60 --- /dev/null +++ b/src/debug.ts @@ -0,0 +1,152 @@ +import { + format as prettyFormat, + plugins as prettyFormatPlugins, +} from 'pretty-format' + +import { validateSpy, getBehaviorStack } from './stubs' +import type { AnyFunction } from './types' +import { type Behavior, BehaviorType } from './behaviors' + +export interface DebugResult { + name: string + description: string + stubbings: readonly Stubbing[] + unmatchedCalls: readonly unknown[][] +} + +export interface Stubbing { + args: readonly unknown[] + behavior: Behavior + calls: readonly unknown[][] +} + +export const getDebug = ( + spy: TFunc, +): DebugResult => { + const target = validateSpy(spy) + const name = target.getMockName() + const behaviors = getBehaviorStack(target) + const unmatchedCalls = behaviors?.getUnmatchedCalls() ?? target.mock.calls + const stubbings = + behaviors?.getAll().map((entry) => ({ + args: entry.args, + behavior: entry.behavior, + calls: entry.calls, + })) ?? [] + + const result = { name, stubbings, unmatchedCalls } + const description = formatDebug(result) + + return { ...result, description } +} + +const formatDebug = (debug: Omit): string => { + const { name, stubbings, unmatchedCalls } = debug + const callCount = stubbings.reduce( + (result, { calls }) => result + calls.length, + 0, + ) + const stubbingCount = stubbings.length + const unmatchedCallsCount = unmatchedCalls.length + + return [ + `\`${name}()\` has:`, + `* ${count(stubbingCount, 'stubbing')} with ${count(callCount, 'call')}`, + ...stubbings.map((stubbing) => ` * ${formatStubbing(stubbing)}`).reverse(), + `* ${count(unmatchedCallsCount, 'unmatched call')}`, + ...unmatchedCalls.map((args) => ` * \`${formatCall(args)}\``), + '', + ].join('\n') +} + +const formatStubbing = ({ args, behavior, calls }: Stubbing): string => { + return `Called ${count(calls.length, 'time')}: \`${formatCall( + args, + )} ${formatBehavior(behavior)}\`` +} + +const formatCall = (args: readonly unknown[]): string => { + return `(${args.map((a) => stringify(a)).join(', ')})` +} + +const formatBehavior = (behavior: Behavior): string => { + switch (behavior.type) { + case BehaviorType.RETURN: { + return `=> ${stringify(behavior.value)}` + } + + case BehaviorType.RESOLVE: { + return `=> Promise.resolve(${stringify(behavior.value)})` + } + + case BehaviorType.THROW: { + return `=> { throw ${stringify(behavior.error)} }` + } + + case BehaviorType.REJECT: { + return `=> Promise.reject(${stringify(behavior.error)})` + } + + case BehaviorType.DO: { + return `=> ${stringify(behavior.callback)}()` + } + } +} + +const count = (amount: number, thing: string) => + `${amount} ${thing}${amount === 1 ? '' : 's'}` + +const { + AsymmetricMatcher, + DOMCollection, + DOMElement, + Immutable, + ReactElement, + ReactTestComponent, +} = prettyFormatPlugins + +const FORMAT_PLUGINS = [ + ReactTestComponent, + ReactElement, + DOMElement, + DOMCollection, + Immutable, + AsymmetricMatcher, +] + +const FORMAT_MAX_LENGTH = 10_000 + +/** + * Stringify a value. + * + * Copied from `jest-matcher-utils` + * https://github.com/jestjs/jest/blob/654dbd6f6b3d94c604221e1afd70fcfb66f9478e/packages/jest-matcher-utils/src/index.ts#L96 + */ +const stringify = (object: unknown, maxDepth = 10, maxWidth = 10): string => { + let result + + try { + result = prettyFormat(object, { + maxDepth, + maxWidth, + min: true, + plugins: FORMAT_PLUGINS, + }) + } catch { + result = prettyFormat(object, { + callToJSON: false, + maxDepth, + maxWidth, + min: true, + plugins: FORMAT_PLUGINS, + }) + } + + if (result.length >= FORMAT_MAX_LENGTH && maxDepth > 1) { + return stringify(object, Math.floor(maxDepth / 2), maxWidth) + } else if (result.length >= FORMAT_MAX_LENGTH && maxWidth > 1) { + return stringify(object, maxDepth, Math.floor(maxWidth / 2)) + } else { + return result + } +} diff --git a/src/stubs.ts b/src/stubs.ts index dd30995..05adad0 100644 --- a/src/stubs.ts +++ b/src/stubs.ts @@ -1,5 +1,9 @@ -import type { Mock as Spy } from 'vitest' -import { createBehaviorStack, type BehaviorStack } from './behaviors.ts' +import { type Mock as Spy } from 'vitest' +import { + createBehaviorStack, + type BehaviorStack, + BehaviorType, +} from './behaviors.ts' import { NotAMockFunctionError } from './errors.ts' import type { AnyFunction } from './types.ts' @@ -14,33 +18,41 @@ export const configureStub = ( maybeSpy: unknown, ): BehaviorStack => { const spy = validateSpy(maybeSpy) - const existingImplementation = spy.getMockImplementation() as - | WhenStubImplementation - | TFunc - | undefined + const existingBehaviors = getBehaviorStack(spy) - if (existingImplementation && BEHAVIORS_KEY in existingImplementation) { - return existingImplementation[BEHAVIORS_KEY] + if (existingBehaviors) { + return existingBehaviors } const behaviors = createBehaviorStack() const implementation = (...args: Parameters): unknown => { - const behavior = behaviors.use(args) - - if (behavior?.throwError) { - throw behavior.throwError as Error + const behavior = behaviors.use(args)?.behavior ?? { + type: BehaviorType.RETURN, + value: undefined, } - if (behavior?.rejectError) { - return Promise.reject(behavior.rejectError) - } + switch (behavior.type) { + case BehaviorType.RETURN: { + return behavior.value + } - if (behavior?.doCallback) { - return behavior.doCallback(...args) - } + case BehaviorType.RESOLVE: { + return Promise.resolve(behavior.value) + } + + case BehaviorType.THROW: { + throw behavior.error + } + + case BehaviorType.REJECT: { + return Promise.reject(behavior.error) + } - return behavior?.returnValue + case BehaviorType.DO: { + return behavior.callback(...args) + } + } } spy.mockImplementation( @@ -50,7 +62,7 @@ export const configureStub = ( return behaviors } -const validateSpy = ( +export const validateSpy = ( maybeSpy: unknown, ): Spy, unknown> => { if ( @@ -58,10 +70,25 @@ const validateSpy = ( 'mockImplementation' in maybeSpy && typeof maybeSpy.mockImplementation === 'function' && 'getMockImplementation' in maybeSpy && - typeof maybeSpy.getMockImplementation === 'function' + typeof maybeSpy.getMockImplementation === 'function' && + 'getMockName' in maybeSpy && + typeof maybeSpy.getMockName === 'function' ) { return maybeSpy as Spy, unknown> } throw new NotAMockFunctionError(maybeSpy) } + +export const getBehaviorStack = ( + spy: Spy, +): BehaviorStack | undefined => { + const existingImplementation = spy.getMockImplementation() as + | WhenStubImplementation + | TFunc + | undefined + + return existingImplementation && BEHAVIORS_KEY in existingImplementation + ? existingImplementation[BEHAVIORS_KEY] + : undefined +} diff --git a/src/vitest-when.ts b/src/vitest-when.ts index a54a7db..f582e7f 100644 --- a/src/vitest-when.ts +++ b/src/vitest-when.ts @@ -1,8 +1,10 @@ import { configureStub } from './stubs.ts' import type { WhenOptions } from './behaviors.ts' import type { AnyFunction } from './types.ts' +import { getDebug, type DebugResult } from './debug.ts' -export type { WhenOptions } from './behaviors.ts' +export { type WhenOptions, type Behavior, BehaviorType } from './behaviors.ts' +export type { DebugResult, Stubbing } from './debug.ts' export * from './errors.ts' export interface StubWrapper { @@ -39,3 +41,21 @@ export const when = ( }, } } + +export interface DebugOptions { + log?: boolean +} + +export const debug = ( + spy: TFunc, + options: DebugOptions = {}, +): DebugResult => { + const log = options.log ?? true + const result = getDebug(spy) + + if (log) { + console.debug(result.description) + } + + return result +} diff --git a/test/debug.test.ts b/test/debug.test.ts new file mode 100644 index 0000000..4e8ab2a --- /dev/null +++ b/test/debug.test.ts @@ -0,0 +1,182 @@ +import { vi, describe, expect, it } from 'vitest' + +import * as subject from '../src/vitest-when.ts' + +const DEBUG_OPTIONS = { log: false } + +describe('vitest-when debug', () => { + it('debugs a non-stubbed spy', () => { + const spy = vi.fn() + + const result = subject.debug(spy, DEBUG_OPTIONS) + + expect(result).toEqual({ + name: 'spy', + stubbings: [], + unmatchedCalls: [], + description: expect.stringContaining( + '0 stubbings with 0 calls', + ) as string, + }) + }) + + it('debugs uncalled stubbings', () => { + const spy = vi.fn() + + subject.when(spy).calledWith('hello', 'world').thenReturn(42) + + const result = subject.debug(spy, DEBUG_OPTIONS) + + expect(result).toEqual({ + name: 'spy', + stubbings: [ + { + args: ['hello', 'world'], + behavior: { type: 'return', value: 42 }, + calls: [], + }, + ], + unmatchedCalls: [], + description: expect.stringContaining('1 stubbing with 0 calls') as string, + }) + }) + + it('debugs called stubbings', () => { + const spy = vi.fn() + + subject.when(spy).calledWith(expect.any(String)).thenReturn(42) + + spy('hello') + spy('world') + + const result = subject.debug(spy, DEBUG_OPTIONS) + + expect(result).toMatchObject({ + name: 'spy', + stubbings: [ + { + args: [expect.any(String)], + behavior: { type: 'return', value: 42 }, + calls: [['hello'], ['world']], + }, + ], + unmatchedCalls: [], + description: expect.stringContaining('1 stubbing with 2 calls') as string, + }) + }) + + it('debugs unmatched calls', () => { + const spy = vi.fn() + + subject.when(spy).calledWith(expect.any(String)).thenReturn(42) + + spy(1234) + + const result = subject.debug(spy, DEBUG_OPTIONS) + + expect(result).toMatchObject({ + name: 'spy', + stubbings: [ + { + args: [expect.any(String)], + behavior: { type: 'return', value: 42 }, + calls: [], + }, + ], + unmatchedCalls: [[1234]], + description: expect.stringContaining('1 unmatched call') as string, + }) + }) + + it('describes thenReturn stubbings', () => { + const spy = vi.fn() + + subject.when(spy).calledWith('hello', 'world').thenReturn(42) + + const result = subject.debug(spy, DEBUG_OPTIONS) + + expect(result.description).toMatch('("hello", "world") => 42') + }) + + it('describes thenResolve stubbings', () => { + const spy = vi.fn() + + subject.when(spy).calledWith('hello', 'world').thenResolve(42) + + const result = subject.debug(spy, DEBUG_OPTIONS) + + expect(result.description).toMatch( + '("hello", "world") => Promise.resolve(42)', + ) + }) + + it('describes thenThrow stubbings', () => { + const spy = vi.fn() + + subject.when(spy).calledWith('hello', 'world').thenThrow(new Error('oh no')) + + const result = subject.debug(spy, DEBUG_OPTIONS) + + expect(result.description).toMatch( + '("hello", "world") => { throw [Error: oh no] }', + ) + }) + + it('describes thenReject stubbings', () => { + const spy = vi.fn() + + subject + .when(spy) + .calledWith('hello', 'world') + .thenReject(new Error('oh no')) + + const result = subject.debug(spy, DEBUG_OPTIONS) + + expect(result.description).toMatch( + '("hello", "world") => Promise.reject([Error: oh no])', + ) + }) + + it('describes thenDo stubbings', () => { + const spy = vi.fn() + + subject + .when(spy) + .calledWith('hello', 'world') + .thenDo(() => 42) + + const result = subject.debug(spy, DEBUG_OPTIONS) + + expect(result.description).toMatch( + '("hello", "world") => [Function anonymous]()', + ) + }) + + it('describes calls with non-JSONifiable objects', () => { + const spy = vi.fn() + const value = { + toJSON() { + throw new Error('oh no') + }, + } + + spy(value) + + const result = subject.debug(spy, DEBUG_OPTIONS) + + expect(result.description).toMatch('({"toJSON": [Function toJSON]})') + }) + + it('describes calls with long values', () => { + const spy = vi.fn() + const longString = Array.from({ length: 1001 }).join('x') + const value = Array.from({ length: 100 }) + value.fill(longString) + + spy(value) + + const result = subject.debug(spy, DEBUG_OPTIONS) + + expect(result.description).toMatch(/\(\["x.+, …\]\)/u) + }) +})