From ba4f6b5df5845fccce78373a509be73fbb9d8aca Mon Sep 17 00:00:00 2001 From: Andrei Picus Date: Tue, 13 Jun 2023 10:37:33 +0200 Subject: [PATCH] feat: Pretty print argument diffs in UnexpectedCall error messages --- package.json | 1 + pnpm-lock.yaml | 71 +++++++++------------------ src/errors.spec.ts | 53 +++++++++++++------- src/errors.ts | 27 +++++++++-- src/expectation/it.ts | 18 +++++-- src/expectation/matcher.spec.ts | 22 +++++++++ src/expectation/matcher.ts | 31 +++++++++++- src/print.spec.ts | 85 ++++++++++++++++++++++++++++++++- src/print.ts | 59 +++++++++++++++++++++-- 9 files changed, 285 insertions(+), 82 deletions(-) diff --git a/package.json b/package.json index 2b05498b..fa9c2ca6 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "test": "jest --coverage --config tests/jest.config.js" }, "dependencies": { + "jest-diff": "~29.4.3", "jest-matcher-utils": "~29.7.0", "lodash": "~4.17.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9f2ea26..a491c157 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false dependencies: + jest-diff: + specifier: ~29.4.3 + version: 29.4.3 jest-matcher-utils: specifier: ~29.7.0 version: 29.7.0 @@ -1526,14 +1529,14 @@ packages: resolution: {integrity: sha512-fmKzsidoXQT2KwnrwE0SQq3uj8Z763vzR8LnLBwC2qYWEFpjX8daRsk6rHUM1QvNlEW/UJXNXm59ztmJJWs2Mg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - jest-get-type: 29.4.3 + jest-get-type: 29.6.3 dev: true /@jest/expect-utils@29.6.0: resolution: {integrity: sha512-LLSQQN7oypMSETKoPWpsWYVKJd9LQWmSDDAc4hUQ4JocVC7LAMy9R3ZMhlnLwbcFvQORZnZR7HM893Px6cJhvA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - jest-get-type: 29.4.3 + jest-get-type: 29.6.3 dev: true /@jest/expect-utils@29.7.0: @@ -1660,13 +1663,6 @@ packages: - supports-color dev: true - /@jest/schemas@29.4.3: - resolution: {integrity: sha512-VLYKXQmtmuEz6IxJsrZwzG9NvtkQsWNnWMsKxqWNu3+CnfzJQhp0WDDKWLVV9hLKr0l3SLLFRqcYHjhtyuDVxg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@sinclair/typebox': 0.25.21 - dev: true - /@jest/schemas@29.6.0: resolution: {integrity: sha512-rxLjXyJBTL4LQeJW3aKo0M/+GkCOXsO+8i9Iu7eDb6KwtP65ayoDsitrdPBtujxQ88k4wI2FNYfa6TOGwSn6cQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1824,7 +1820,7 @@ packages: resolution: {integrity: sha512-8XCgL9JhqbJTFnMRjEAO+TuW251+MoMd5BSzLiE3vvzpQ8RlBxy8NoyNkDhs3K3OL3HeVinlOl9or5p7GTeOLg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jest/schemas': 29.6.0 + '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.4 '@types/istanbul-reports': 3.0.1 '@types/node': 18.17.0 @@ -2024,10 +2020,6 @@ packages: resolution: {integrity: sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==} dev: true - /@sinclair/typebox@0.25.21: - resolution: {integrity: sha512-gFukHN4t8K4+wVC+ECqeqwzBDeFeTzBXroBTqE6vcWrQGbEUpHO7LYdG0f4xnvYq4VOEwITSlHlp0JBAIFMS/g==} - dev: true - /@sinclair/typebox@0.27.8: resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -3524,11 +3516,6 @@ packages: engines: {node: '>= 10.14.2'} dev: true - /diff-sequences@29.4.3: - resolution: {integrity: sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dev: true - /diff-sequences@29.6.3: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -4117,7 +4104,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/expect-utils': 29.5.0 - jest-get-type: 29.4.3 + jest-get-type: 29.6.3 jest-matcher-utils: 29.7.0 jest-message-util: 29.5.0 jest-util: 29.5.0 @@ -4129,7 +4116,7 @@ packages: dependencies: '@jest/expect-utils': 29.6.0 '@types/node': 18.17.0 - jest-get-type: 29.4.3 + jest-get-type: 29.6.3 jest-matcher-utils: 29.7.0 jest-message-util: 29.6.0 jest-util: 29.6.0 @@ -5213,15 +5200,15 @@ packages: pretty-format: 26.6.2 dev: true - /jest-diff@29.6.0: - resolution: {integrity: sha512-ZRm7cd2m9YyZ0N3iMyuo1iUiprxQ/MFpYWXzEEj7hjzL3WnDffKW8192XBDcrAI8j7hnrM1wed3bL/oEnYF/8w==} + /jest-diff@29.4.3: + resolution: {integrity: sha512-YB+ocenx7FZ3T5O9lMVMeLYV4265socJKtkwgk/6YUz/VsEzYDkiMuMhWzZmxm3wDRQvayJu/PjkjjSkjoHsCA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: chalk: 4.1.2 - diff-sequences: 29.4.3 - jest-get-type: 29.4.3 - pretty-format: 29.6.0 - dev: true + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + dev: false /jest-diff@29.7.0: resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} @@ -5290,11 +5277,6 @@ packages: engines: {node: '>= 10.14.2'} dev: true - /jest-get-type@29.4.3: - resolution: {integrity: sha512-J5Xez4nRRMjk8emnTpWrlkyb9pfRQQanDrvWHhsR1+VUfbwxi30eVcZFlcdGInRibU4G5LwHXpI7IRHU0CY+gg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dev: true - /jest-get-type@29.6.3: resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -5374,7 +5356,7 @@ packages: chalk: 4.1.2 graceful-fs: 4.2.9 micromatch: 4.0.4 - pretty-format: 29.6.0 + pretty-format: 29.7.0 slash: 3.0.0 stack-utils: 2.0.5 dev: true @@ -5389,7 +5371,7 @@ packages: chalk: 4.1.2 graceful-fs: 4.2.9 micromatch: 4.0.4 - pretty-format: 29.6.0 + pretty-format: 29.7.0 slash: 3.0.0 stack-utils: 2.0.5 dev: true @@ -5616,13 +5598,13 @@ packages: chalk: 4.1.2 expect: 29.6.0 graceful-fs: 4.2.9 - jest-diff: 29.6.0 - jest-get-type: 29.4.3 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 jest-matcher-utils: 29.7.0 jest-message-util: 29.6.0 jest-util: 29.6.0 natural-compare: 1.4.0 - pretty-format: 29.6.0 + pretty-format: 29.7.0 semver: 7.5.3 transitivePeerDependencies: - supports-color @@ -5699,9 +5681,9 @@ packages: '@jest/types': 29.6.0 camelcase: 6.3.0 chalk: 4.1.2 - jest-get-type: 29.4.3 + jest-get-type: 29.6.3 leven: 3.1.0 - pretty-format: 29.6.0 + pretty-format: 29.7.0 dev: true /jest-validate@29.7.0: @@ -7235,16 +7217,7 @@ packages: resolution: {integrity: sha512-V2mGkI31qdttvTFX7Mt4efOqHXqJWMu4/r66Xh3Z3BwZaPfPJgp6/gbwoujRpPUtfEF6AUUWx3Jim3GCw5g/Qw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jest/schemas': 29.4.3 - ansi-styles: 5.2.0 - react-is: 18.2.0 - dev: true - - /pretty-format@29.6.0: - resolution: {integrity: sha512-XH+D4n7Ey0iSR6PdAnBs99cWMZdGsdKrR33iUHQNr79w1szKTCIZDVdXuccAsHVwDBp0XeWPfNEoaxP9EZgRmQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/schemas': 29.6.0 + '@jest/schemas': 29.6.3 ansi-styles: 5.2.0 react-is: 18.2.0 dev: true diff --git a/src/errors.spec.ts b/src/errors.spec.ts index 43e751c6..551042bf 100644 --- a/src/errors.spec.ts +++ b/src/errors.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable class-methods-use-this */ import { expectAnsilessContain, expectAnsilessEqual } from '../tests/ansiless'; import { SM } from '../tests/old'; import { @@ -14,7 +13,9 @@ import { spyExpectationFactory, SpyPendingExpectation, } from './expectation/expectation.mocks'; +import { It } from './expectation/it'; import type { CallMap } from './expectation/repository/expectation-repository'; +import { StrongExpectation } from './expectation/strong-expectation'; import type { ConcreteMatcher } from './mock/options'; import { PendingExpectationWithFactory } from './when/pending-expectation'; @@ -117,29 +118,45 @@ foobar` }); describe('UnexpectedCall', () => { - it('should print the property and the existing expectations', () => { - const e1 = SM.mock(); - const e2 = SM.mock(); - SM.when(e1.toJSON()).thenReturn('e1'); - SM.when(e2.toJSON()).thenReturn('e2'); - - const error = new UnexpectedCall( - 'bar', - [1, 2, 3], - [SM.instance(e1), SM.instance(e2)] - ); + it('should print the call', () => { + const error = new UnexpectedCall('bar', [1, 2, 3], []); expectAnsilessContain( error.message, `Didn't expect mock.bar(1, 2, 3) to be called.` ); + }); - expectAnsilessContain( - error.message, - `Remaining unmet expectations: - - e1 - - e2` - ); + it('should print the diff', () => { + const matcher = It.matches(() => false, { + getDiff: (actual) => ({ actual, expected: 'foo' }), + }); + + const expectation = new StrongExpectation('bar', [matcher], { + value: ':irrelevant:', + }); + + const error = new UnexpectedCall('bar', [1, 2, 3], [expectation]); + + expectAnsilessContain(error.message, `Expected`); + }); + + it('should print the diff only for expectations for the same property', () => { + const matcher = It.matches(() => false, { + getDiff: (actual) => ({ actual, expected: 'foo' }), + }); + + const e1 = new StrongExpectation('foo', [matcher], { + value: ':irrelevant:', + }); + const e2 = new StrongExpectation('bar', [matcher], { + value: ':irrelevant:', + }); + + const error = new UnexpectedCall('foo', [1, 2, 3], [e1, e2]); + + // Yeah, funky way to do a negated ansiless contains. + expect(() => expectAnsilessContain(error.message, `bar`)).toThrow(); }); }); diff --git a/src/errors.ts b/src/errors.ts index f5ac2904..835c0453 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,7 +1,12 @@ import { EXPECTED_COLOR } from 'jest-matcher-utils'; import type { Expectation } from './expectation/expectation'; import type { CallMap } from './expectation/repository/expectation-repository'; -import { printCall, printProperty, printRemainingExpectations } from './print'; +import { + printCall, + printDiffForAllExpectations, + printProperty, + printRemainingExpectations, +} from './print'; import type { Property } from './proxy'; import type { PendingExpectation } from './when/pending-expectation'; @@ -43,11 +48,23 @@ export class UnexpectedCall extends Error { args: unknown[], expectations: Expectation[] ) { - super(`Didn't expect ${EXPECTED_COLOR( - `mock${printCall(property, args)}` - )} to be called. + const header = `Didn't expect mock${printCall( + property, + args + )} to be called.`; -${printRemainingExpectations(expectations)}`); + const propertyExpectations = expectations.filter( + (e) => e.property === property + ); + + if (propertyExpectations.length) { + super(`${header} + +Remaining expectations: +${printDiffForAllExpectations(propertyExpectations, args)}`); + } else { + super(header); + } } } diff --git a/src/expectation/it.ts b/src/expectation/it.ts index 53427bda..75c8b1de 100644 --- a/src/expectation/it.ts +++ b/src/expectation/it.ts @@ -17,6 +17,11 @@ import { isMatcher, MATCHER_SYMBOL } from './matcher'; * @param toJSON An optional function that should return a string that will be * used when the matcher needs to be printed in an error message. By default, * it stringifies `cb`. + * @param getDiff An optional function that will be called when printing the + * diff between a matcher from an expectation and the received arguments. You + * can format both the received and the expected values according to your + * matcher's logic. By default, the `toJSON` method will be used to format + * the expected value, while the received value will be returned as-is. * * @example * const fn = mock<(x: number) => number>(); @@ -27,12 +32,16 @@ import { isMatcher, MATCHER_SYMBOL } from './matcher'; */ const matches = ( cb: (actual: T) => boolean, - { toJSON = () => `matches(${cb.toString()})` }: { toJSON?: () => string } = {} + { + toJSON = () => `matches(${cb.toString()})`, + getDiff = (actual) => ({ actual, expected: toJSON() }), + }: Partial> = {} ): TypeMatcher => { const matcher: Matcher = { [MATCHER_SYMBOL]: true, - matches: (arg: T) => cb(arg), + matches: (actual: T) => cb(actual), toJSON, + getDiff, }; return matcher as any; @@ -73,7 +82,10 @@ const deepEquals = ( return isEqual(removeUndefined(actual), removeUndefined(expected)); }, - { toJSON: () => printArg(expected) } + { + toJSON: () => printArg(expected), + getDiff: (actual) => ({ actual, expected }), + } ); /** diff --git a/src/expectation/matcher.spec.ts b/src/expectation/matcher.spec.ts index 95dae6dc..9d312db6 100644 --- a/src/expectation/matcher.spec.ts +++ b/src/expectation/matcher.spec.ts @@ -509,6 +509,28 @@ describe('It', () => { It.matches(() => true, { toJSON: () => 'foobar' }).toJSON() ).toEqual('foobar'); }); + + it('should call getDiff if the matcher fails', () => { + const matcher = It.matches(() => false, { + getDiff: () => ({ actual: 'a', expected: 'e' }), + }); + + expect(matcher.getDiff(42)).toEqual({ actual: 'a', expected: 'e' }); + }); + + it('should call getDiff if the matcher succeeds', () => { + const matcher = It.matches(() => true, { + getDiff: () => ({ actual: 'a', expected: 'e' }), + }); + + expect(matcher.getDiff(42)).toEqual({ actual: 'a', expected: 'e' }); + }); + + it('should use toJSON as the default getDiff', () => { + const matcher = It.matches(() => false, { toJSON: () => 'foobar' }); + + expect(matcher.getDiff(42)).toEqual({ actual: 42, expected: 'foobar' }); + }); }); describe('isObject', () => { diff --git a/src/expectation/matcher.ts b/src/expectation/matcher.ts index c704422f..c8bc5cfa 100644 --- a/src/expectation/matcher.ts +++ b/src/expectation/matcher.ts @@ -1,13 +1,42 @@ export const MATCHER_SYMBOL = Symbol('matcher'); +/** + * You should use {@link It.matches} to create this type. + */ export type Matcher = { /** * Will be called with a value to match against. */ - matches: (arg: any) => boolean; + matches: (actual: any) => boolean; [MATCHER_SYMBOL]: boolean; + /** + * Will be called when printing the diff between an expectation and the + * (mismatching) received arguments. + * + * With this function you can pretty print the `actual` and `expected` values + * according to your matcher's logic. + * + * @param actual The actual value received by this matcher, same as the one + * in `matches`. + * + * @returns an {actual, expected} pair that will be diffed visually in the + * error message. + * + * @example + * const neverMatcher = It.matches(() => false, { + * getDiff: () => ({ actual: 'something, expected: 'never' }) + * }); + * // Will end up printing: + * - Expected + * + Received + * + * - 'something' + * + 'never + */ + getDiff: (actual: any) => { actual: any; expected: any }; + /** * Used by `pretty-format`. */ diff --git a/src/print.spec.ts b/src/print.spec.ts index e498d7ae..39c4389b 100644 --- a/src/print.spec.ts +++ b/src/print.spec.ts @@ -1,8 +1,15 @@ /* eslint-disable class-methods-use-this */ +import { expectAnsilessContain, expectAnsilessEqual } from '../tests/ansiless'; import { ApplyProp } from './expectation/expectation'; import { It } from './expectation/it'; -import { printCall, printProperty, printReturns } from './print'; -import { expectAnsilessContain, expectAnsilessEqual } from '../tests/ansiless'; +import { StrongExpectation } from './expectation/strong-expectation'; +import { + printCall, + printDiffForAllExpectations, + printExpectationDiff, + printProperty, + printReturns, +} from './print'; describe('print', () => { describe('printProperty', () => { @@ -105,4 +112,78 @@ describe('print', () => { ); }); }); + + describe('printExpectationDiff', () => { + it('should print the diff when we have single expectation', () => { + const matcher = It.matches(() => false, { + getDiff: (actual) => ({ actual, expected: 'foo' }), + }); + + const expectation = new StrongExpectation(':irrelevant:', [matcher], { + value: ':irrelevant:', + }); + + const args = ['bar']; + + expectAnsilessEqual( + printExpectationDiff(expectation, args), + `- "foo", ++ "bar"` + ); + }); + it('should print the diff for an expectation with no received args', () => { + const matcher = It.matches(() => false, { + getDiff: (actual) => ({ actual, expected: 'foo' }), + }); + + const expectation = new StrongExpectation(':irrelevant:', [matcher], { + value: ':irrelevant:', + }); + + expectAnsilessEqual( + printExpectationDiff(expectation, []), + `- "foo", ++ undefined` + ); + }); + + it('should not print the diff for an expectation with no expected args', () => { + const expectation = new StrongExpectation(':irrelevant:', [], { + value: ':irrelevant:', + }); + + expectAnsilessEqual(printExpectationDiff(expectation, [1, 2]), ''); + }); + }); + + describe('printDiffForAllExpectations', () => { + it('should print the diff when we have multiple expectations', () => { + const matcher = It.matches(() => false, { + getDiff: (actual) => ({ actual, expected: 'foo' }), + }); + + const expectation = new StrongExpectation(':irrelevant:', [matcher], { + value: ':irrelevant:', + }); + + const args = ['bar']; + + expectAnsilessEqual( + printDiffForAllExpectations([expectation, expectation], args), + `when(() => mock.:irrelevant:(matches(() => false))).thenReturn(":irrelevant:").between(1, 1) +- Expected ++ Received + +- "foo", ++ "bar" + +when(() => mock.:irrelevant:(matches(() => false))).thenReturn(":irrelevant:").between(1, 1) +- Expected ++ Received + +- "foo", ++ "bar"` + ); + }); + }); }); diff --git a/src/print.ts b/src/print.ts index 3c66374e..5deeb459 100644 --- a/src/print.ts +++ b/src/print.ts @@ -1,4 +1,10 @@ -import { EXPECTED_COLOR, printExpected } from 'jest-matcher-utils'; +import { diff as printDiff } from 'jest-diff'; +import { + EXPECTED_COLOR, + printExpected, + printReceived, + RECEIVED_COLOR, +} from 'jest-matcher-utils'; import type { Expectation } from './expectation/expectation'; import { ApplyProp } from './expectation/expectation'; import { isMatcher } from './expectation/matcher'; @@ -19,7 +25,7 @@ export const printProperty = (property: Property) => { export const printArg = (arg: unknown): string => // Call toJSON on matchers directly to avoid wrapping them in quotes. - isMatcher(arg) ? arg.toJSON() : printExpected(arg); + isMatcher(arg) ? arg.toJSON() : printReceived(arg); export const printCall = (property: Property, args: any[]) => { const prettyArgs = args.map((arg) => printArg(arg)).join(', '); @@ -52,10 +58,10 @@ export const printReturns = ( export const printWhen = (property: Property, args: any[] | undefined) => { if (args) { - return `when(() => ${EXPECTED_COLOR(`mock${printCall(property, args)}`)})`; + return `when(() => mock${printCall(property, args)})`; } - return `when(() => ${EXPECTED_COLOR(`mock${printProperty(property)}`)})`; + return `when(() => mock${printProperty(property)})`; }; export const printExpectation = ( @@ -71,3 +77,48 @@ export const printRemainingExpectations = (expectations: Expectation[]) => ? `Remaining unmet expectations: - ${expectations.map((e) => e.toJSON()).join('\n - ')}` : 'There are no remaining unmet expectations.'; + +export const printExpectationDiff = (e: Expectation, args: any[]) => { + if (!e.args?.length) { + return ''; + } + + const matcherDiffs = e.args?.map((matcher, j) => matcher.getDiff(args[j])); + + const diff = printDiff( + matcherDiffs?.map((d) => d.expected), + matcherDiffs?.map((d) => d.actual), + { omitAnnotationLines: true } + ); + + if (!diff) { + return ''; + } + + const diffLines = diff.split('\n').slice(1, -1); + + return `${diffLines.slice(0, -1).join('\n')}\n${diffLines[ + diffLines.length - 1 + ].slice(0, -6)}`; +}; + +export const printDiffForAllExpectations = ( + expectations: Expectation[], + actual: any[] +) => + expectations + .map((e) => { + const diff = printExpectationDiff(e, actual); + + if (diff) { + return `${e.toJSON()} +${EXPECTED_COLOR('- Expected')} +${RECEIVED_COLOR('+ Received')} + +${diff}`; + } + + return undefined; + }) + .filter((x) => x) + .join('\n\n');