From 4ded5743dde4c1d20d971b54048547a1b4c0f662 Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Wed, 8 May 2024 09:12:32 -0400 Subject: [PATCH 1/6] feat(debug): add debug utility --- README.md | 61 ++++++++++++++- package.json | 3 + pnpm-lock.yaml | 10 +-- src/behaviors.ts | 104 +++++++++++++++++--------- src/debug.ts | 155 +++++++++++++++++++++++++++++++++++++++ src/stubs.ts | 63 +++++++++++----- src/vitest-when.ts | 19 +++++ test/vitest-when.test.ts | 85 +++++++++++++++++++++ 8 files changed, 437 insertions(+), 63 deletions(-) create mode 100644 src/debug.ts diff --git a/README.md b/README.md index cb40041..a426781 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,58 @@ when(spy) expect(spy('hello')).toEqual('world') expect(spy('hello')).toEqual('solar system') ``` + +### `debug(spy: TFunc, options?: DebugsOptions): 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: +// - `coolFunc(1, 2, 4)` -> `return 123`, called 0 times. +// - `coolFunc(4, 5, 6)` -> `throw Error('oh no)`, called 0 times. +// +// 1 unmatched call: +// - `coolFunc(1, 2, 4)` +``` + +#### Options + +```ts +import type { DebugOptions } from 'vitest-when' +``` + +| option | default | type | description | +| ------ | ------- | ------- | -------------------------------------- | +| `log` | `true` | boolean | Whether the call to `debug` should log | + +#### Result + +```ts +import type { DebugResult, DebugStubbing, DebugBehavior } from 'vitest-when' +``` + +| fields | type | description | +| ---------------------------- | -------------------------------------------- | ---------------------------------------------------------- | +| `name` | `string` | The name of the mock, if set by [`mockName`][mockName] | +| `stubbings` | `DebugStubbing[]` | The list of configured stub behaviors | +| `stubbings[].args` | `unknown[]` | The stubbing's arguments to match | +| `stubbings[].behavior` | `DebugBehavior` | 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 behahior, 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/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..10fb77a 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,107 @@ 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 type Behavior = + | { type: 'return'; value: unknown } + | { type: 'resolve'; value: unknown } + | { type: 'throw'; error: unknown } + | { type: 'reject'; error: unknown } + | { type: '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: 'return' as const, 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: 'resolve' as const, 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: 'throw' as const, 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: 'reject' as const, 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: 'do' as const, callback: value }, + calls: [], + }), + ), ) }, }), @@ -114,14 +145,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..51811e4 --- /dev/null +++ b/src/debug.ts @@ -0,0 +1,155 @@ +import { + format as prettyFormat, + plugins as prettyFormatPlugins, +} from 'pretty-format' + +import { validateSpy, getBehaviorStack } from './stubs' +import type { AnyFunction } from './types' +import type { Behavior } from './behaviors' + +export interface DebugResult { + name: 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, + })) ?? [] + + return { name, stubbings, unmatchedCalls } +} + +export const formatDebug = (debug: DebugResult): 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:`, + '', + `${stubbingCount} ${plural( + 'stubbing', + stubbingCount, + )} with ${callCount} calls`, + ...stubbings.map((stubbing) => `- ${formatStubbing(name, stubbing)}`), + '', + `${unmatchedCallsCount} unmatched ${plural('call', unmatchedCallsCount)}`, + ...unmatchedCalls.map((args) => `- \`${formatCall(name, args)}\``), + ].join('\n') +} + +const formatStubbing = ( + name: string, + { args, behavior, calls }: Stubbing, +): string => { + return `\`${formatCall(name, args)} ${formatBehavior(behavior)}\`, called ${ + calls.length + } times` +} + +const formatCall = (name: string, args: readonly unknown[]): string => { + return `${name}(${args.map((a) => stringify(a)).join(', ')})` +} + +const formatBehavior = (behavior: Behavior): string => { + switch (behavior.type) { + case 'return': { + return `=> ${stringify(behavior.value)}` + } + + case 'resolve': { + return `=> Promise.resolve(${stringify(behavior.value)})` + } + + case 'throw': { + return `=> { throw ${stringify(behavior.error)} }` + } + + case 'reject': { + return `=> Promise.reject(${stringify(behavior.error)})` + } + + case 'do': { + return `=> ${stringify(behavior.callback)}()` + } + } +} + +const plural = (thing: string, count: number) => + `${thing}${count === 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..750f748 100644 --- a/src/stubs.ts +++ b/src/stubs.ts @@ -1,4 +1,4 @@ -import type { Mock as Spy } from 'vitest' +import { type Mock as Spy } from 'vitest' import { createBehaviorStack, type BehaviorStack } from './behaviors.ts' import { NotAMockFunctionError } from './errors.ts' import type { AnyFunction } from './types.ts' @@ -14,33 +14,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: 'return', + value: undefined, } - if (behavior?.rejectError) { - return Promise.reject(behavior.rejectError) - } + switch (behavior.type) { + case 'return': { + return behavior.value + } - if (behavior?.doCallback) { - return behavior.doCallback(...args) - } + case 'resolve': { + return Promise.resolve(behavior.value) + } + + case 'throw': { + throw behavior.error + } + + case 'reject': { + return Promise.reject(behavior.error) + } - return behavior?.returnValue + case 'do': { + return behavior.callback(...args) + } + } } spy.mockImplementation( @@ -50,7 +58,7 @@ export const configureStub = ( return behaviors } -const validateSpy = ( +export const validateSpy = ( maybeSpy: unknown, ): Spy, unknown> => { if ( @@ -58,10 +66,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..ffda3ed 100644 --- a/src/vitest-when.ts +++ b/src/vitest-when.ts @@ -1,6 +1,7 @@ import { configureStub } from './stubs.ts' import type { WhenOptions } from './behaviors.ts' import type { AnyFunction } from './types.ts' +import { getDebug, formatDebug, type DebugResult } from './debug.ts' export type { WhenOptions } from './behaviors.ts' export * from './errors.ts' @@ -39,3 +40,21 @@ export const when = ( }, } } + +export interface DebugOptions { + log?: boolean +} + +export const debug = ( + spy: TFunc, + options: DebugOptions = {}, +): DebugResult => { + const result = getDebug(spy) + + if (options.log !== false) { + const description = formatDebug(result) + console.debug(description) + } + + return result +} diff --git a/test/vitest-when.test.ts b/test/vitest-when.test.ts index 4f3ae86..cf1cd60 100644 --- a/test/vitest-when.test.ts +++ b/test/vitest-when.test.ts @@ -264,4 +264,89 @@ describe('vitest-when', () => { // intentionally do not call the spy expect(true).toBe(true) }) + + it('should debug a non-stubbed spy', () => { + const spy = vi.fn() + + const result = subject.debug(spy, { log: false }) + + expect(result).toEqual({ + name: 'spy', + stubbings: [], + unmatchedCalls: [], + }) + }) + + it('debugs the name of a spy', () => { + const spy = vi.fn().mockName('harry') + + const result = subject.debug(spy, { log: false }) + + expect(result).toEqual({ + name: 'harry', + stubbings: [], + unmatchedCalls: [], + }) + }) + + it('debugs unmatched calls', () => { + const spy = vi.fn() + + spy('hello', 'world') + spy('fizz', 'buzz') + + const result = subject.debug(spy, { log: false }) + + expect(result).toEqual({ + name: 'spy', + stubbings: [], + unmatchedCalls: [ + ['hello', 'world'], + ['fizz', 'buzz'], + ], + }) + }) + + it('debugs uncalled stubbings', () => { + const spy = vi.fn() + + subject.when(spy).calledWith('hello', 'world').thenReturn(42) + + const result = subject.debug(spy, { log: false }) + + expect(result).toEqual({ + name: 'spy', + stubbings: [ + { + args: ['hello', 'world'], + behavior: { type: 'return', value: 42 }, + calls: [], + }, + ], + unmatchedCalls: [], + }) + }) + + 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, { log: true }) + + expect(result).toEqual({ + name: 'spy', + stubbings: [ + { + args: [expect.any(String)], + behavior: { type: 'return', value: 42 }, + calls: [['hello'], ['world']], + }, + ], + unmatchedCalls: [], + }) + }) }) From 881e864d11871a9dde454ed20174f2df4dcc6b4a Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Wed, 8 May 2024 09:18:20 -0400 Subject: [PATCH 2/6] fixup: typos and tweaks --- README.md | 12 ++++++------ src/debug.ts | 8 ++++---- test/vitest-when.test.ts | 11 +++++++++++ 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a426781..02771f1 100644 --- a/README.md +++ b/README.md @@ -466,7 +466,7 @@ expect(spy('hello')).toEqual('world') expect(spy('hello')).toEqual('solar system') ``` -### `debug(spy: TFunc, options?: DebugsOptions): DebugInfo` +### `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. @@ -481,13 +481,13 @@ when(coolFunc).calledWith(4, 5, 6).thenThrow(new Error('oh no')) const result = coolFunc(1, 2, 4) debug(coolFunc) -// `coolFunc` has: +// `coolFunc()` has: // -// 2 stubbings with 0 calls: -// - `coolFunc(1, 2, 4)` -> `return 123`, called 0 times. -// - `coolFunc(4, 5, 6)` -> `throw Error('oh no)`, called 0 times. +// 2 stubbings with 0 calls +// - 0 calls: `coolFunc(4, 5, 6) => { throw [Error: oh no] }` +// - 0 calls: `coolFunc(1, 2, 3) => 123` // -// 1 unmatched call: +// 1 unmatched call // - `coolFunc(1, 2, 4)` ``` diff --git a/src/debug.ts b/src/debug.ts index 51811e4..4581259 100644 --- a/src/debug.ts +++ b/src/debug.ts @@ -46,7 +46,7 @@ export const formatDebug = (debug: DebugResult): string => { const unmatchedCallsCount = unmatchedCalls.length return [ - `\`${name}\` has:`, + `\`${name}()\` has:`, '', `${stubbingCount} ${plural( 'stubbing', @@ -63,9 +63,9 @@ const formatStubbing = ( name: string, { args, behavior, calls }: Stubbing, ): string => { - return `\`${formatCall(name, args)} ${formatBehavior(behavior)}\`, called ${ - calls.length - } times` + return `${calls.length} calls: \`${formatCall(name, args)} ${formatBehavior( + behavior, + )}\`` } const formatCall = (name: string, args: readonly unknown[]): string => { diff --git a/test/vitest-when.test.ts b/test/vitest-when.test.ts index cf1cd60..51022a1 100644 --- a/test/vitest-when.test.ts +++ b/test/vitest-when.test.ts @@ -349,4 +349,15 @@ describe('vitest-when', () => { unmatchedCalls: [], }) }) + + it('logs debug description', () => { + const coolFunction = vi.fn().mockName('coolFunc') + + subject.when(coolFunction).calledWith(1, 2, 3).thenReturn(123) + subject.when(coolFunction).calledWith(4, 5, 6).thenThrow(new Error('oh no')) + + coolFunction(1, 2, 4) + + subject.debug(coolFunction) + }) }) From b8c4b52d004e942bf2c65745895dd6966fa740c8 Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Wed, 8 May 2024 09:23:41 -0400 Subject: [PATCH 3/6] fixup: more typos and tweaks --- README.md | 2 +- src/debug.ts | 4 +++- test/vitest-when.test.ts | 11 ----------- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 02771f1..b29fca0 100644 --- a/README.md +++ b/README.md @@ -484,8 +484,8 @@ debug(coolFunc) // `coolFunc()` has: // // 2 stubbings with 0 calls -// - 0 calls: `coolFunc(4, 5, 6) => { throw [Error: oh no] }` // - 0 calls: `coolFunc(1, 2, 3) => 123` +// - 0 calls: `coolFunc(4, 5, 6) => { throw [Error: oh no] }` // // 1 unmatched call // - `coolFunc(1, 2, 4)` diff --git a/src/debug.ts b/src/debug.ts index 4581259..fa407c0 100644 --- a/src/debug.ts +++ b/src/debug.ts @@ -52,7 +52,9 @@ export const formatDebug = (debug: DebugResult): string => { 'stubbing', stubbingCount, )} with ${callCount} calls`, - ...stubbings.map((stubbing) => `- ${formatStubbing(name, stubbing)}`), + ...stubbings + .map((stubbing) => `- ${formatStubbing(name, stubbing)}`) + .reverse(), '', `${unmatchedCallsCount} unmatched ${plural('call', unmatchedCallsCount)}`, ...unmatchedCalls.map((args) => `- \`${formatCall(name, args)}\``), diff --git a/test/vitest-when.test.ts b/test/vitest-when.test.ts index 51022a1..cf1cd60 100644 --- a/test/vitest-when.test.ts +++ b/test/vitest-when.test.ts @@ -349,15 +349,4 @@ describe('vitest-when', () => { unmatchedCalls: [], }) }) - - it('logs debug description', () => { - const coolFunction = vi.fn().mockName('coolFunc') - - subject.when(coolFunction).calledWith(1, 2, 3).thenReturn(123) - subject.when(coolFunction).calledWith(4, 5, 6).thenThrow(new Error('oh no')) - - coolFunction(1, 2, 4) - - subject.debug(coolFunction) - }) }) From 21c44997a8fab58e4f8af67c7cb8b72bcf3d6308 Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Sat, 11 May 2024 11:37:15 -0400 Subject: [PATCH 4/6] fixup: improve debug logging and testing --- README.md | 27 ++--- example/meaning-of-life.test.ts | 5 +- src/debug.ts | 41 ++++--- src/vitest-when.ts | 5 +- test/debug.test.ts | 182 ++++++++++++++++++++++++++++++++ test/vitest-when.test.ts | 85 --------------- 6 files changed, 220 insertions(+), 125 deletions(-) create mode 100644 test/debug.test.ts diff --git a/README.md b/README.md index b29fca0..c00fa63 100644 --- a/README.md +++ b/README.md @@ -491,7 +491,7 @@ debug(coolFunc) // - `coolFunc(1, 2, 4)` ``` -#### Options +#### `DebugOptions` ```ts import type { DebugOptions } from 'vitest-when' @@ -501,22 +501,23 @@ import type { DebugOptions } from 'vitest-when' | ------ | ------- | ------- | -------------------------------------- | | `log` | `true` | boolean | Whether the call to `debug` should log | -#### Result +#### `DebugResult` ```ts import type { DebugResult, DebugStubbing, DebugBehavior } from 'vitest-when' ``` -| fields | type | description | -| ---------------------------- | -------------------------------------------- | ---------------------------------------------------------- | -| `name` | `string` | The name of the mock, if set by [`mockName`][mockName] | -| `stubbings` | `DebugStubbing[]` | The list of configured stub behaviors | -| `stubbings[].args` | `unknown[]` | The stubbing's arguments to match | -| `stubbings[].behavior` | `DebugBehavior` | 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 behahior, 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 | +| 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` | `DebugStubbing[]` | The list of configured stub behaviors | +| `stubbings[].args` | `unknown[]` | The stubbing's arguments to match | +| `stubbings[].behavior` | `DebugBehavior` | 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/src/debug.ts b/src/debug.ts index fa407c0..1b25815 100644 --- a/src/debug.ts +++ b/src/debug.ts @@ -9,6 +9,7 @@ import type { Behavior } from './behaviors' export interface DebugResult { name: string + description: string stubbings: readonly Stubbing[] unmatchedCalls: readonly unknown[][] } @@ -33,10 +34,13 @@ export const getDebug = ( calls: entry.calls, })) ?? [] - return { name, stubbings, unmatchedCalls } + const result = { name, stubbings, unmatchedCalls } + const description = formatDebug(result) + + return { ...result, description } } -export const formatDebug = (debug: DebugResult): string => { +const formatDebug = (debug: Omit): string => { const { name, stubbings, unmatchedCalls } = debug const callCount = stubbings.reduce( (result, { calls }) => result + calls.length, @@ -47,31 +51,22 @@ export const formatDebug = (debug: DebugResult): string => { 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)}\``), '', - `${stubbingCount} ${plural( - 'stubbing', - stubbingCount, - )} with ${callCount} calls`, - ...stubbings - .map((stubbing) => `- ${formatStubbing(name, stubbing)}`) - .reverse(), - '', - `${unmatchedCallsCount} unmatched ${plural('call', unmatchedCallsCount)}`, - ...unmatchedCalls.map((args) => `- \`${formatCall(name, args)}\``), ].join('\n') } -const formatStubbing = ( - name: string, - { args, behavior, calls }: Stubbing, -): string => { - return `${calls.length} calls: \`${formatCall(name, args)} ${formatBehavior( - behavior, - )}\`` +const formatStubbing = ({ args, behavior, calls }: Stubbing): string => { + return `Called ${count(calls.length, 'time')}: \`${formatCall( + args, + )} ${formatBehavior(behavior)}\`` } -const formatCall = (name: string, args: readonly unknown[]): string => { - return `${name}(${args.map((a) => stringify(a)).join(', ')})` +const formatCall = (args: readonly unknown[]): string => { + return `(${args.map((a) => stringify(a)).join(', ')})` } const formatBehavior = (behavior: Behavior): string => { @@ -98,8 +93,8 @@ const formatBehavior = (behavior: Behavior): string => { } } -const plural = (thing: string, count: number) => - `${thing}${count === 1 ? '' : 's'}` +const count = (amount: number, thing: string) => + `${amount} ${thing}${amount === 1 ? '' : 's'}` const { AsymmetricMatcher, diff --git a/src/vitest-when.ts b/src/vitest-when.ts index ffda3ed..f562254 100644 --- a/src/vitest-when.ts +++ b/src/vitest-when.ts @@ -1,7 +1,7 @@ import { configureStub } from './stubs.ts' import type { WhenOptions } from './behaviors.ts' import type { AnyFunction } from './types.ts' -import { getDebug, formatDebug, type DebugResult } from './debug.ts' +import { getDebug, type DebugResult } from './debug.ts' export type { WhenOptions } from './behaviors.ts' export * from './errors.ts' @@ -52,8 +52,7 @@ export const debug = ( const result = getDebug(spy) if (options.log !== false) { - const description = formatDebug(result) - console.debug(description) + 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) + }) +}) diff --git a/test/vitest-when.test.ts b/test/vitest-when.test.ts index cf1cd60..4f3ae86 100644 --- a/test/vitest-when.test.ts +++ b/test/vitest-when.test.ts @@ -264,89 +264,4 @@ describe('vitest-when', () => { // intentionally do not call the spy expect(true).toBe(true) }) - - it('should debug a non-stubbed spy', () => { - const spy = vi.fn() - - const result = subject.debug(spy, { log: false }) - - expect(result).toEqual({ - name: 'spy', - stubbings: [], - unmatchedCalls: [], - }) - }) - - it('debugs the name of a spy', () => { - const spy = vi.fn().mockName('harry') - - const result = subject.debug(spy, { log: false }) - - expect(result).toEqual({ - name: 'harry', - stubbings: [], - unmatchedCalls: [], - }) - }) - - it('debugs unmatched calls', () => { - const spy = vi.fn() - - spy('hello', 'world') - spy('fizz', 'buzz') - - const result = subject.debug(spy, { log: false }) - - expect(result).toEqual({ - name: 'spy', - stubbings: [], - unmatchedCalls: [ - ['hello', 'world'], - ['fizz', 'buzz'], - ], - }) - }) - - it('debugs uncalled stubbings', () => { - const spy = vi.fn() - - subject.when(spy).calledWith('hello', 'world').thenReturn(42) - - const result = subject.debug(spy, { log: false }) - - expect(result).toEqual({ - name: 'spy', - stubbings: [ - { - args: ['hello', 'world'], - behavior: { type: 'return', value: 42 }, - calls: [], - }, - ], - unmatchedCalls: [], - }) - }) - - 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, { log: true }) - - expect(result).toEqual({ - name: 'spy', - stubbings: [ - { - args: [expect.any(String)], - behavior: { type: 'return', value: 42 }, - calls: [['hello'], ['world']], - }, - ], - unmatchedCalls: [], - }) - }) }) From 6fd155f8342b1cd4800c6528dcc36f729925c0d4 Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Sat, 11 May 2024 11:39:16 -0400 Subject: [PATCH 5/6] fixup: update README --- README.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c00fa63..41aec98 100644 --- a/README.md +++ b/README.md @@ -482,13 +482,11 @@ const result = coolFunc(1, 2, 4) debug(coolFunc) // `coolFunc()` has: -// -// 2 stubbings with 0 calls -// - 0 calls: `coolFunc(1, 2, 3) => 123` -// - 0 calls: `coolFunc(4, 5, 6) => { throw [Error: oh no] }` -// -// 1 unmatched call -// - `coolFunc(1, 2, 4)` +// * 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` From e5194d64e452a31f05454d0535407f3a12c10671 Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Sat, 11 May 2024 11:57:19 -0400 Subject: [PATCH 6/6] fixup: ensure public types are exported --- README.md | 6 +++--- src/behaviors.ts | 28 ++++++++++++++++++---------- src/debug.ts | 12 ++++++------ src/stubs.ts | 18 +++++++++++------- src/vitest-when.ts | 6 ++++-- 5 files changed, 42 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 41aec98..3888ace 100644 --- a/README.md +++ b/README.md @@ -502,16 +502,16 @@ import type { DebugOptions } from 'vitest-when' #### `DebugResult` ```ts -import type { DebugResult, DebugStubbing, DebugBehavior } from 'vitest-when' +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` | `DebugStubbing[]` | The list of configured stub behaviors | +| `stubbings` | `Stubbing[]` | The list of configured stub behaviors | | `stubbings[].args` | `unknown[]` | The stubbing's arguments to match | -| `stubbings[].behavior` | `DebugBehavior` | The configured behavior of the stubbing | +| `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` | diff --git a/src/behaviors.ts b/src/behaviors.ts index 10fb77a..96cf7df 100644 --- a/src/behaviors.ts +++ b/src/behaviors.ts @@ -33,12 +33,20 @@ export interface BehaviorEntry { maxCallCount?: number | undefined } +export const BehaviorType = { + RETURN: 'return', + RESOLVE: 'resolve', + THROW: 'throw', + REJECT: 'reject', + DO: 'do', +} as const + export type Behavior = - | { type: 'return'; value: unknown } - | { type: 'resolve'; value: unknown } - | { type: 'throw'; error: unknown } - | { type: 'reject'; error: unknown } - | { type: 'do'; callback: AnyFunction } + | { 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 @@ -77,7 +85,7 @@ export const createBehaviorStack = < ({ value, maxCallCount }) => ({ args, maxCallCount, - behavior: { type: 'return' as const, value }, + behavior: { type: BehaviorType.RETURN, value }, calls: [], }), ), @@ -89,7 +97,7 @@ export const createBehaviorStack = < ({ value, maxCallCount }) => ({ args, maxCallCount, - behavior: { type: 'resolve' as const, value }, + behavior: { type: BehaviorType.RESOLVE, value }, calls: [], }), ), @@ -101,7 +109,7 @@ export const createBehaviorStack = < ({ value, maxCallCount }) => ({ args, maxCallCount, - behavior: { type: 'throw' as const, error: value }, + behavior: { type: BehaviorType.THROW, error: value }, calls: [], }), ), @@ -113,7 +121,7 @@ export const createBehaviorStack = < ({ value, maxCallCount }) => ({ args, maxCallCount, - behavior: { type: 'reject' as const, error: value }, + behavior: { type: BehaviorType.REJECT, error: value }, calls: [], }), ), @@ -125,7 +133,7 @@ export const createBehaviorStack = < ({ value, maxCallCount }) => ({ args, maxCallCount, - behavior: { type: 'do' as const, callback: value }, + behavior: { type: BehaviorType.DO, callback: value }, calls: [], }), ), diff --git a/src/debug.ts b/src/debug.ts index 1b25815..1d1fb60 100644 --- a/src/debug.ts +++ b/src/debug.ts @@ -5,7 +5,7 @@ import { import { validateSpy, getBehaviorStack } from './stubs' import type { AnyFunction } from './types' -import type { Behavior } from './behaviors' +import { type Behavior, BehaviorType } from './behaviors' export interface DebugResult { name: string @@ -71,23 +71,23 @@ const formatCall = (args: readonly unknown[]): string => { const formatBehavior = (behavior: Behavior): string => { switch (behavior.type) { - case 'return': { + case BehaviorType.RETURN: { return `=> ${stringify(behavior.value)}` } - case 'resolve': { + case BehaviorType.RESOLVE: { return `=> Promise.resolve(${stringify(behavior.value)})` } - case 'throw': { + case BehaviorType.THROW: { return `=> { throw ${stringify(behavior.error)} }` } - case 'reject': { + case BehaviorType.REJECT: { return `=> Promise.reject(${stringify(behavior.error)})` } - case 'do': { + case BehaviorType.DO: { return `=> ${stringify(behavior.callback)}()` } } diff --git a/src/stubs.ts b/src/stubs.ts index 750f748..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 { + createBehaviorStack, + type BehaviorStack, + BehaviorType, +} from './behaviors.ts' import { NotAMockFunctionError } from './errors.ts' import type { AnyFunction } from './types.ts' @@ -24,28 +28,28 @@ export const configureStub = ( const implementation = (...args: Parameters): unknown => { const behavior = behaviors.use(args)?.behavior ?? { - type: 'return', + type: BehaviorType.RETURN, value: undefined, } switch (behavior.type) { - case 'return': { + case BehaviorType.RETURN: { return behavior.value } - case 'resolve': { + case BehaviorType.RESOLVE: { return Promise.resolve(behavior.value) } - case 'throw': { + case BehaviorType.THROW: { throw behavior.error } - case 'reject': { + case BehaviorType.REJECT: { return Promise.reject(behavior.error) } - case 'do': { + case BehaviorType.DO: { return behavior.callback(...args) } } diff --git a/src/vitest-when.ts b/src/vitest-when.ts index f562254..f582e7f 100644 --- a/src/vitest-when.ts +++ b/src/vitest-when.ts @@ -3,7 +3,8 @@ 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 { @@ -49,9 +50,10 @@ export const debug = ( spy: TFunc, options: DebugOptions = {}, ): DebugResult => { + const log = options.log ?? true const result = getDebug(spy) - if (options.log !== false) { + if (log) { console.debug(result.description) }