Skip to content

Commit

Permalink
feat: Pretty print argument diffs in UnexpectedCall error messages
Browse files Browse the repository at this point in the history
  • Loading branch information
NiGhTTraX committed Sep 24, 2023
1 parent 49a833e commit ba4f6b5
Show file tree
Hide file tree
Showing 9 changed files with 285 additions and 82 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
71 changes: 22 additions & 49 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 35 additions & 18 deletions src/errors.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable class-methods-use-this */
import { expectAnsilessContain, expectAnsilessEqual } from '../tests/ansiless';
import { SM } from '../tests/old';
import {
Expand All @@ -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';

Expand Down Expand Up @@ -117,29 +118,45 @@ foobar`
});

describe('UnexpectedCall', () => {
it('should print the property and the existing expectations', () => {
const e1 = SM.mock<Expectation>();
const e2 = SM.mock<Expectation>();
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();
});
});

Expand Down
27 changes: 22 additions & 5 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
}
}
}

Expand Down
18 changes: 15 additions & 3 deletions src/expectation/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>();
Expand All @@ -27,12 +32,16 @@ import { isMatcher, MATCHER_SYMBOL } from './matcher';
*/
const matches = <T>(
cb: (actual: T) => boolean,
{ toJSON = () => `matches(${cb.toString()})` }: { toJSON?: () => string } = {}
{
toJSON = () => `matches(${cb.toString()})`,
getDiff = (actual) => ({ actual, expected: toJSON() }),
}: Partial<Pick<Matcher, 'toJSON' | 'getDiff'>> = {}
): TypeMatcher<T> => {
const matcher: Matcher = {
[MATCHER_SYMBOL]: true,
matches: (arg: T) => cb(arg),
matches: (actual: T) => cb(actual),
toJSON,
getDiff,
};

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

return isEqual(removeUndefined(actual), removeUndefined(expected));
},
{ toJSON: () => printArg(expected) }
{
toJSON: () => printArg(expected),
getDiff: (actual) => ({ actual, expected }),
}
);

/**
Expand Down
22 changes: 22 additions & 0 deletions src/expectation/matcher.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading

0 comments on commit ba4f6b5

Please sign in to comment.