Skip to content

Commit ba4f6b5

Browse files
committedSep 24, 2023
feat: Pretty print argument diffs in UnexpectedCall error messages
1 parent 49a833e commit ba4f6b5

9 files changed

+285
-82
lines changed
 

‎package.json

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"test": "jest --coverage --config tests/jest.config.js"
3232
},
3333
"dependencies": {
34+
"jest-diff": "~29.4.3",
3435
"jest-matcher-utils": "~29.7.0",
3536
"lodash": "~4.17.0"
3637
},

‎pnpm-lock.yaml

+22-49
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/errors.spec.ts

+35-18
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/* eslint-disable class-methods-use-this */
21
import { expectAnsilessContain, expectAnsilessEqual } from '../tests/ansiless';
32
import { SM } from '../tests/old';
43
import {
@@ -14,7 +13,9 @@ import {
1413
spyExpectationFactory,
1514
SpyPendingExpectation,
1615
} from './expectation/expectation.mocks';
16+
import { It } from './expectation/it';
1717
import type { CallMap } from './expectation/repository/expectation-repository';
18+
import { StrongExpectation } from './expectation/strong-expectation';
1819
import type { ConcreteMatcher } from './mock/options';
1920
import { PendingExpectationWithFactory } from './when/pending-expectation';
2021

@@ -117,29 +118,45 @@ foobar`
117118
});
118119

119120
describe('UnexpectedCall', () => {
120-
it('should print the property and the existing expectations', () => {
121-
const e1 = SM.mock<Expectation>();
122-
const e2 = SM.mock<Expectation>();
123-
SM.when(e1.toJSON()).thenReturn('e1');
124-
SM.when(e2.toJSON()).thenReturn('e2');
125-
126-
const error = new UnexpectedCall(
127-
'bar',
128-
[1, 2, 3],
129-
[SM.instance(e1), SM.instance(e2)]
130-
);
121+
it('should print the call', () => {
122+
const error = new UnexpectedCall('bar', [1, 2, 3], []);
131123

132124
expectAnsilessContain(
133125
error.message,
134126
`Didn't expect mock.bar(1, 2, 3) to be called.`
135127
);
128+
});
136129

137-
expectAnsilessContain(
138-
error.message,
139-
`Remaining unmet expectations:
140-
- e1
141-
- e2`
142-
);
130+
it('should print the diff', () => {
131+
const matcher = It.matches(() => false, {
132+
getDiff: (actual) => ({ actual, expected: 'foo' }),
133+
});
134+
135+
const expectation = new StrongExpectation('bar', [matcher], {
136+
value: ':irrelevant:',
137+
});
138+
139+
const error = new UnexpectedCall('bar', [1, 2, 3], [expectation]);
140+
141+
expectAnsilessContain(error.message, `Expected`);
142+
});
143+
144+
it('should print the diff only for expectations for the same property', () => {
145+
const matcher = It.matches(() => false, {
146+
getDiff: (actual) => ({ actual, expected: 'foo' }),
147+
});
148+
149+
const e1 = new StrongExpectation('foo', [matcher], {
150+
value: ':irrelevant:',
151+
});
152+
const e2 = new StrongExpectation('bar', [matcher], {
153+
value: ':irrelevant:',
154+
});
155+
156+
const error = new UnexpectedCall('foo', [1, 2, 3], [e1, e2]);
157+
158+
// Yeah, funky way to do a negated ansiless contains.
159+
expect(() => expectAnsilessContain(error.message, `bar`)).toThrow();
143160
});
144161
});
145162

‎src/errors.ts

+22-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { EXPECTED_COLOR } from 'jest-matcher-utils';
22
import type { Expectation } from './expectation/expectation';
33
import type { CallMap } from './expectation/repository/expectation-repository';
4-
import { printCall, printProperty, printRemainingExpectations } from './print';
4+
import {
5+
printCall,
6+
printDiffForAllExpectations,
7+
printProperty,
8+
printRemainingExpectations,
9+
} from './print';
510
import type { Property } from './proxy';
611
import type { PendingExpectation } from './when/pending-expectation';
712

@@ -43,11 +48,23 @@ export class UnexpectedCall extends Error {
4348
args: unknown[],
4449
expectations: Expectation[]
4550
) {
46-
super(`Didn't expect ${EXPECTED_COLOR(
47-
`mock${printCall(property, args)}`
48-
)} to be called.
51+
const header = `Didn't expect mock${printCall(
52+
property,
53+
args
54+
)} to be called.`;
4955

50-
${printRemainingExpectations(expectations)}`);
56+
const propertyExpectations = expectations.filter(
57+
(e) => e.property === property
58+
);
59+
60+
if (propertyExpectations.length) {
61+
super(`${header}
62+
63+
Remaining expectations:
64+
${printDiffForAllExpectations(propertyExpectations, args)}`);
65+
} else {
66+
super(header);
67+
}
5168
}
5269
}
5370

‎src/expectation/it.ts

+15-3
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ import { isMatcher, MATCHER_SYMBOL } from './matcher';
1717
* @param toJSON An optional function that should return a string that will be
1818
* used when the matcher needs to be printed in an error message. By default,
1919
* it stringifies `cb`.
20+
* @param getDiff An optional function that will be called when printing the
21+
* diff between a matcher from an expectation and the received arguments. You
22+
* can format both the received and the expected values according to your
23+
* matcher's logic. By default, the `toJSON` method will be used to format
24+
* the expected value, while the received value will be returned as-is.
2025
*
2126
* @example
2227
* const fn = mock<(x: number) => number>();
@@ -27,12 +32,16 @@ import { isMatcher, MATCHER_SYMBOL } from './matcher';
2732
*/
2833
const matches = <T>(
2934
cb: (actual: T) => boolean,
30-
{ toJSON = () => `matches(${cb.toString()})` }: { toJSON?: () => string } = {}
35+
{
36+
toJSON = () => `matches(${cb.toString()})`,
37+
getDiff = (actual) => ({ actual, expected: toJSON() }),
38+
}: Partial<Pick<Matcher, 'toJSON' | 'getDiff'>> = {}
3139
): TypeMatcher<T> => {
3240
const matcher: Matcher = {
3341
[MATCHER_SYMBOL]: true,
34-
matches: (arg: T) => cb(arg),
42+
matches: (actual: T) => cb(actual),
3543
toJSON,
44+
getDiff,
3645
};
3746

3847
return matcher as any;
@@ -73,7 +82,10 @@ const deepEquals = <T>(
7382

7483
return isEqual(removeUndefined(actual), removeUndefined(expected));
7584
},
76-
{ toJSON: () => printArg(expected) }
85+
{
86+
toJSON: () => printArg(expected),
87+
getDiff: (actual) => ({ actual, expected }),
88+
}
7789
);
7890

7991
/**

‎src/expectation/matcher.spec.ts

+22
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,28 @@ describe('It', () => {
509509
It.matches(() => true, { toJSON: () => 'foobar' }).toJSON()
510510
).toEqual('foobar');
511511
});
512+
513+
it('should call getDiff if the matcher fails', () => {
514+
const matcher = It.matches(() => false, {
515+
getDiff: () => ({ actual: 'a', expected: 'e' }),
516+
});
517+
518+
expect(matcher.getDiff(42)).toEqual({ actual: 'a', expected: 'e' });
519+
});
520+
521+
it('should call getDiff if the matcher succeeds', () => {
522+
const matcher = It.matches(() => true, {
523+
getDiff: () => ({ actual: 'a', expected: 'e' }),
524+
});
525+
526+
expect(matcher.getDiff(42)).toEqual({ actual: 'a', expected: 'e' });
527+
});
528+
529+
it('should use toJSON as the default getDiff', () => {
530+
const matcher = It.matches(() => false, { toJSON: () => 'foobar' });
531+
532+
expect(matcher.getDiff(42)).toEqual({ actual: 42, expected: 'foobar' });
533+
});
512534
});
513535

514536
describe('isObject', () => {

‎src/expectation/matcher.ts

+30-1
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,42 @@
11
export const MATCHER_SYMBOL = Symbol('matcher');
22

3+
/**
4+
* You should use {@link It.matches} to create this type.
5+
*/
36
export type Matcher = {
47
/**
58
* Will be called with a value to match against.
69
*/
7-
matches: (arg: any) => boolean;
10+
matches: (actual: any) => boolean;
811

912
[MATCHER_SYMBOL]: boolean;
1013

14+
/**
15+
* Will be called when printing the diff between an expectation and the
16+
* (mismatching) received arguments.
17+
*
18+
* With this function you can pretty print the `actual` and `expected` values
19+
* according to your matcher's logic.
20+
*
21+
* @param actual The actual value received by this matcher, same as the one
22+
* in `matches`.
23+
*
24+
* @returns an {actual, expected} pair that will be diffed visually in the
25+
* error message.
26+
*
27+
* @example
28+
* const neverMatcher = It.matches(() => false, {
29+
* getDiff: () => ({ actual: 'something, expected: 'never' })
30+
* });
31+
* // Will end up printing:
32+
* - Expected
33+
* + Received
34+
*
35+
* - 'something'
36+
* + 'never
37+
*/
38+
getDiff: (actual: any) => { actual: any; expected: any };
39+
1140
/**
1241
* Used by `pretty-format`.
1342
*/

‎src/print.spec.ts

+83-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
/* eslint-disable class-methods-use-this */
2+
import { expectAnsilessContain, expectAnsilessEqual } from '../tests/ansiless';
23
import { ApplyProp } from './expectation/expectation';
34
import { It } from './expectation/it';
4-
import { printCall, printProperty, printReturns } from './print';
5-
import { expectAnsilessContain, expectAnsilessEqual } from '../tests/ansiless';
5+
import { StrongExpectation } from './expectation/strong-expectation';
6+
import {
7+
printCall,
8+
printDiffForAllExpectations,
9+
printExpectationDiff,
10+
printProperty,
11+
printReturns,
12+
} from './print';
613

714
describe('print', () => {
815
describe('printProperty', () => {
@@ -105,4 +112,78 @@ describe('print', () => {
105112
);
106113
});
107114
});
115+
116+
describe('printExpectationDiff', () => {
117+
it('should print the diff when we have single expectation', () => {
118+
const matcher = It.matches(() => false, {
119+
getDiff: (actual) => ({ actual, expected: 'foo' }),
120+
});
121+
122+
const expectation = new StrongExpectation(':irrelevant:', [matcher], {
123+
value: ':irrelevant:',
124+
});
125+
126+
const args = ['bar'];
127+
128+
expectAnsilessEqual(
129+
printExpectationDiff(expectation, args),
130+
`- "foo",
131+
+ "bar"`
132+
);
133+
});
134+
it('should print the diff for an expectation with no received args', () => {
135+
const matcher = It.matches(() => false, {
136+
getDiff: (actual) => ({ actual, expected: 'foo' }),
137+
});
138+
139+
const expectation = new StrongExpectation(':irrelevant:', [matcher], {
140+
value: ':irrelevant:',
141+
});
142+
143+
expectAnsilessEqual(
144+
printExpectationDiff(expectation, []),
145+
`- "foo",
146+
+ undefined`
147+
);
148+
});
149+
150+
it('should not print the diff for an expectation with no expected args', () => {
151+
const expectation = new StrongExpectation(':irrelevant:', [], {
152+
value: ':irrelevant:',
153+
});
154+
155+
expectAnsilessEqual(printExpectationDiff(expectation, [1, 2]), '');
156+
});
157+
});
158+
159+
describe('printDiffForAllExpectations', () => {
160+
it('should print the diff when we have multiple expectations', () => {
161+
const matcher = It.matches(() => false, {
162+
getDiff: (actual) => ({ actual, expected: 'foo' }),
163+
});
164+
165+
const expectation = new StrongExpectation(':irrelevant:', [matcher], {
166+
value: ':irrelevant:',
167+
});
168+
169+
const args = ['bar'];
170+
171+
expectAnsilessEqual(
172+
printDiffForAllExpectations([expectation, expectation], args),
173+
`when(() => mock.:irrelevant:(matches(() => false))).thenReturn(":irrelevant:").between(1, 1)
174+
- Expected
175+
+ Received
176+
177+
- "foo",
178+
+ "bar"
179+
180+
when(() => mock.:irrelevant:(matches(() => false))).thenReturn(":irrelevant:").between(1, 1)
181+
- Expected
182+
+ Received
183+
184+
- "foo",
185+
+ "bar"`
186+
);
187+
});
188+
});
108189
});

‎src/print.ts

+55-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { EXPECTED_COLOR, printExpected } from 'jest-matcher-utils';
1+
import { diff as printDiff } from 'jest-diff';
2+
import {
3+
EXPECTED_COLOR,
4+
printExpected,
5+
printReceived,
6+
RECEIVED_COLOR,
7+
} from 'jest-matcher-utils';
28
import type { Expectation } from './expectation/expectation';
39
import { ApplyProp } from './expectation/expectation';
410
import { isMatcher } from './expectation/matcher';
@@ -19,7 +25,7 @@ export const printProperty = (property: Property) => {
1925

2026
export const printArg = (arg: unknown): string =>
2127
// Call toJSON on matchers directly to avoid wrapping them in quotes.
22-
isMatcher(arg) ? arg.toJSON() : printExpected(arg);
28+
isMatcher(arg) ? arg.toJSON() : printReceived(arg);
2329

2430
export const printCall = (property: Property, args: any[]) => {
2531
const prettyArgs = args.map((arg) => printArg(arg)).join(', ');
@@ -52,10 +58,10 @@ export const printReturns = (
5258

5359
export const printWhen = (property: Property, args: any[] | undefined) => {
5460
if (args) {
55-
return `when(() => ${EXPECTED_COLOR(`mock${printCall(property, args)}`)})`;
61+
return `when(() => mock${printCall(property, args)})`;
5662
}
5763

58-
return `when(() => ${EXPECTED_COLOR(`mock${printProperty(property)}`)})`;
64+
return `when(() => mock${printProperty(property)})`;
5965
};
6066

6167
export const printExpectation = (
@@ -71,3 +77,48 @@ export const printRemainingExpectations = (expectations: Expectation[]) =>
7177
? `Remaining unmet expectations:
7278
- ${expectations.map((e) => e.toJSON()).join('\n - ')}`
7379
: 'There are no remaining unmet expectations.';
80+
81+
export const printExpectationDiff = (e: Expectation, args: any[]) => {
82+
if (!e.args?.length) {
83+
return '';
84+
}
85+
86+
const matcherDiffs = e.args?.map((matcher, j) => matcher.getDiff(args[j]));
87+
88+
const diff = printDiff(
89+
matcherDiffs?.map((d) => d.expected),
90+
matcherDiffs?.map((d) => d.actual),
91+
{ omitAnnotationLines: true }
92+
);
93+
94+
if (!diff) {
95+
return '';
96+
}
97+
98+
const diffLines = diff.split('\n').slice(1, -1);
99+
100+
return `${diffLines.slice(0, -1).join('\n')}\n${diffLines[
101+
diffLines.length - 1
102+
].slice(0, -6)}`;
103+
};
104+
105+
export const printDiffForAllExpectations = (
106+
expectations: Expectation[],
107+
actual: any[]
108+
) =>
109+
expectations
110+
.map((e) => {
111+
const diff = printExpectationDiff(e, actual);
112+
113+
if (diff) {
114+
return `${e.toJSON()}
115+
${EXPECTED_COLOR('- Expected')}
116+
${RECEIVED_COLOR('+ Received')}
117+
118+
${diff}`;
119+
}
120+
121+
return undefined;
122+
})
123+
.filter((x) => x)
124+
.join('\n\n');

0 commit comments

Comments
 (0)
Please sign in to comment.