From b027d1617eac9da698255a0a7ae3e1fe3a54e872 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sun, 3 Apr 2022 18:19:16 +0300 Subject: [PATCH] feat: show all spy calls on assertion error (#1091) --- .../src/integrations/chai/jest-expect.ts | 94 +++++++++++++------ .../integrations/chai/jest-matcher-utils.ts | 5 +- packages/vitest/src/node/diff.ts | 37 +++++--- packages/vitest/src/node/error.ts | 2 +- 4 files changed, 95 insertions(+), 43 deletions(-) diff --git a/packages/vitest/src/integrations/chai/jest-expect.ts b/packages/vitest/src/integrations/chai/jest-expect.ts index cd74bbee08e5..7d4ea5fa5fa6 100644 --- a/packages/vitest/src/integrations/chai/jest-expect.ts +++ b/packages/vitest/src/integrations/chai/jest-expect.ts @@ -1,11 +1,14 @@ +import c from 'picocolors' import type { EnhancedSpy } from '../spy' import { isMockFunction } from '../spy' import { addSerializer } from '../snapshot/port/plugins' import type { Constructable } from '../../types' import { assertTypes } from '../../utils' +import { unifiedDiff } from '../../node/diff' import type { ChaiPlugin, MatcherState } from './types' import { arrayBufferEquality, iterableEquality, equals as jestEquals, sparseArrayEquality, subsetEquality, typeEquality } from './jest-utils' import type { AsymmetricMatcher } from './jest-asymmetric-matchers' +import { stringify } from './jest-matcher-utils' const MATCHERS_OBJECT = Symbol.for('matchers-object') @@ -285,6 +288,35 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { assertIsMock(assertion) return assertion._obj as EnhancedSpy } + const ordinalOf = (i: number) => { + const j = i % 10 + const k = i % 100 + + if (j === 1 && k !== 11) + return `${i}st` + + if (j === 2 && k !== 12) + return `${i}nd` + + if (j === 3 && k !== 13) + return `${i}rd` + + return `${i}th` + } + const formatCalls = (spy: EnhancedSpy, msg: string, actualCall?: any) => { + msg += c.gray(`\n\nReceived: \n${spy.mock.calls.map((callArg, i) => { + let methodCall = c.bold(` ${ordinalOf(i + 1)} ${spy.getMockName()} call:\n\n`) + if (actualCall) + methodCall += unifiedDiff(stringify(callArg), stringify(actualCall), { showLegend: false }) + else + methodCall += stringify(callArg).split('\n').map(line => ` ${line}`).join('\n') + + methodCall += '\n' + return methodCall + }).join('\n')}`) + msg += c.gray(`\n\nNumber of calls: ${c.bold(spy.mock.calls.length)}\n`) + return msg + } def(['toHaveBeenCalledTimes', 'toBeCalledTimes'], function(number: number) { const spy = getSpy(this) const spyName = spy.getMockName() @@ -313,41 +345,49 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { const spy = getSpy(this) const spyName = spy.getMockName() const called = spy.mock.calls.length > 0 - return this.assert( - called, - `expected "${spyName}" to be called at least once`, - `expected "${spyName}" to not be called at all`, - true, - called, + const isNot = utils.flag(this, 'negate') as boolean + let msg = utils.getMessage( + this, + [ + called, + `expected "${spyName}" to be called at least once`, + `expected "${spyName}" to not be called at all`, + true, + called, + ], ) + if (called && isNot) + msg += formatCalls(spy, msg) + + if ((called && isNot) || (!called && !isNot)) { + const err = new Error(msg) + err.name = 'AssertionError' + throw err + } }) def(['toHaveBeenCalledWith', 'toBeCalledWith'], function(...args) { const spy = getSpy(this) const spyName = spy.getMockName() const pass = spy.mock.calls.some(callArg => jestEquals(callArg, args, [iterableEquality])) - return this.assert( - pass, - `expected "${spyName}" to be called with arguments: #{exp}`, - `expected "${spyName}" to not be called with arguments: #{exp}`, - args, - spy.mock.calls, - ) - }) - const ordinalOf = (i: number) => { - const j = i % 10 - const k = i % 100 - - if (j === 1 && k !== 11) - return `${i}st` - - if (j === 2 && k !== 12) - return `${i}nd` + const isNot = utils.flag(this, 'negate') as boolean - if (j === 3 && k !== 13) - return `${i}rd` + let msg = utils.getMessage( + this, + [ + pass, + `expected "${spyName}" to be called with arguments: #{exp}`, + `expected "${spyName}" to not be called with arguments: #{exp}`, + args, + ], + ) - return `${i}th` - } + if ((pass && isNot) || (!pass && !isNot)) { + msg += formatCalls(spy, msg, args) + const err = new Error(msg) + err.name = 'AssertionError' + throw err + } + }) def(['toHaveBeenNthCalledWith', 'nthCalledWith'], function(times: number, ...args: any[]) { const spy = getSpy(this) const spyName = spy.getMockName() diff --git a/packages/vitest/src/integrations/chai/jest-matcher-utils.ts b/packages/vitest/src/integrations/chai/jest-matcher-utils.ts index 8cab91d99940..6431ebec45bd 100644 --- a/packages/vitest/src/integrations/chai/jest-matcher-utils.ts +++ b/packages/vitest/src/integrations/chai/jest-matcher-utils.ts @@ -3,6 +3,7 @@ import c from 'picocolors' import type { Formatter } from 'picocolors/types' +import type { PrettyFormatOptions } from 'pretty-format' import { format as prettyFormat, plugins as prettyFormatPlugins } from 'pretty-format' import { unifiedDiff } from '../../node/diff' @@ -112,7 +113,7 @@ const SPACE_SYMBOL = '\u{00B7}' // middle dot const replaceTrailingSpaces = (text: string): string => text.replace(/\s+$/gm, spaces => SPACE_SYMBOL.repeat(spaces.length)) -export const stringify = (object: unknown, maxDepth = 10): string => { +export const stringify = (object: unknown, maxDepth = 10, options?: PrettyFormatOptions): string => { const MAX_LENGTH = 10000 let result @@ -121,6 +122,7 @@ export const stringify = (object: unknown, maxDepth = 10): string => { maxDepth, // min: true, plugins: PLUGINS, + ...options, }) } catch { @@ -129,6 +131,7 @@ export const stringify = (object: unknown, maxDepth = 10): string => { maxDepth, // min: true, plugins: PLUGINS, + ...options, }) } diff --git a/packages/vitest/src/node/diff.ts b/packages/vitest/src/node/diff.ts index b1d325d8b897..39b7ce485d85 100644 --- a/packages/vitest/src/node/diff.ts +++ b/packages/vitest/src/node/diff.ts @@ -6,6 +6,11 @@ export function formatLine(line: string, outputTruncateLength?: number) { return cliTruncate(line, (outputTruncateLength ?? (process.stdout.columns || 80)) - 4) } +export interface DiffOptions { + outputTruncateLength?: number + showLegend?: boolean +} + /** * Returns unified diff between two strings with coloured ANSI output. * @@ -15,10 +20,12 @@ export function formatLine(line: string, outputTruncateLength?: number) { * @return {string} The diff. */ -export function unifiedDiff(actual: string, expected: string, outputTruncateLength?: number) { +export function unifiedDiff(actual: string, expected: string, options: DiffOptions = {}) { if (actual === expected) return '' + const { outputTruncateLength, showLegend = true } = options + const indent = ' ' const diffLimit = 15 @@ -70,19 +77,21 @@ export function unifiedDiff(actual: string, expected: string, outputTruncateLeng return ` ${line}` }) - // Compact mode - if (isCompact) { - formatted = [ - `${c.green('- Expected')} ${formatted[0]}`, - `${c.red('+ Received')} ${formatted[1]}`, - ] - } - else { - formatted.unshift( - c.green(`- Expected - ${counts['-']}`), - c.red(`+ Received + ${counts['+']}`), - '', - ) + if (showLegend) { + // Compact mode + if (isCompact) { + formatted = [ + `${c.green('- Expected')} ${formatted[0]}`, + `${c.red('+ Received')} ${formatted[1]}`, + ] + } + else { + formatted.unshift( + c.green(`- Expected - ${counts['-']}`), + c.red(`+ Received + ${counts['+']}`), + '', + ) + } } return formatted.map(i => indent + i).join('\n') diff --git a/packages/vitest/src/node/error.ts b/packages/vitest/src/node/error.ts index e8df9d1cfa36..4bfd464fea2a 100644 --- a/packages/vitest/src/node/error.ts +++ b/packages/vitest/src/node/error.ts @@ -86,7 +86,7 @@ function handleImportOutsideModuleError(stack: string, ctx: Vitest) { } function displayDiff(actual: string, expected: string, console: Console, outputTruncateLength?: number) { - console.error(c.gray(unifiedDiff(actual, expected, outputTruncateLength)) + '\n') + console.error(c.gray(unifiedDiff(actual, expected, { outputTruncateLength })) + '\n') } function printErrorMessage(error: ErrorWithDiff, console: Console) {