Skip to content

Commit

Permalink
Fixes async matcher stack traces
Browse files Browse the repository at this point in the history
Fixes a bug causing custom async matcher stack traces to be lost. The
issue was caused by the JestAssertionError being created after the
promise for the async matcher resolved - by which point the stack
containing the correct stack trace (pointing to the line where matcher
was called) has been discarded. The issue was fixed by passing the error
that is created before the promise resolves.
  • Loading branch information
Don Schrimsher committed Jan 15, 2019
1 parent 805ec3c commit 7640a08
Show file tree
Hide file tree
Showing 7 changed files with 350 additions and 237 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`showing the line number and code fence for custom async matchers 1`] = `
Object {
"rest": "FAIL __tests__/test.js
showing the line number and code fence for custom async matchers
showing the line number and code fence for custom async matchers
The line number and code fence for this error should be shown properly
11 |
12 | test('showing the line number and code fence for custom async matchers', async () => {
> 13 | await expect(true).failingAsyncMatcher();
| ^
14 | });
15 |
16 | async function failingAsyncMatcher() {
at Object.failingAsyncMatcher (__tests__/test.js:13:22)
",
"summary": "Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 total
Snapshots: 0 total
Time: <<REPLACED>>
Ran all test suites matching /test.js/i.
",
}
`;
22 changes: 22 additions & 0 deletions e2e/__tests__/customAsyncMatcherStackTrace.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/

'use strict';

import path from 'path';
import runJest from '../runJest';
import {extractSummary} from '../Utils';
const dir = path.resolve(__dirname, '../custom-async-matcher-stack-trace');

test('showing the line number and code fence for custom async matchers', () => {
const testOutput = runJest(dir, ['test.js']);
const result = extractSummary(testOutput.stderr);

expect(result).toMatchSnapshot();
});
22 changes: 22 additions & 0 deletions e2e/custom-async-matcher-stack-trace/__tests__/test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
'use strict';

expect.extend({failingAsyncMatcher});

test('showing the line number and code fence for custom async matchers', async () => {
await expect(true).failingAsyncMatcher();
});

async function failingAsyncMatcher() {
const message = () =>
'The line number and code fence for this error should be shown properly';
const pass = false;

return {message, pass};
}
1 change: 1 addition & 0 deletions e2e/custom-async-matcher-stack-trace/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
16 changes: 16 additions & 0 deletions packages/expect/src/JestAssertionError.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/

'use strict';

class JestAssertionError extends Error {
matcherResult: any;
}

export default JestAssertionError;
244 changes: 7 additions & 237 deletions packages/expect/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,14 @@
* @flow
*/

import type {
Expect,
ExpectationObject,
AsyncExpectationResult,
SyncExpectationResult,
ExpectationResult,
MatcherState,
MatchersObject,
RawMatcherFn,
ThrowingMatcherFn,
PromiseMatcherFn,
} from 'types/Matchers';
import type {Expect, ExpectationObject, MatchersObject} from 'types/Matchers';

import * as matcherUtils from 'jest-matcher-utils';
import {iterableEquality, subsetEquality} from './utils';
import matchers from './matchers';
import spyMatchers from './spyMatchers';
import toThrowMatchers, {
createMatcher as createThrowMatcher,
} from './toThrowMatchers';
import {equals} from './jasmineUtils';
import {
any,
anything,
Expand All @@ -41,22 +28,18 @@ import {
stringNotMatching,
} from './asymmetricMatchers';
import {
INTERNAL_MATCHER_FLAG,
getState,
setState,
getMatchers,
setMatchers,
} from './jestMatchersObject';
import extractExpectedAssertionsErrors from './extractExpectedAssertionsErrors';

class JestAssertionError extends Error {
matcherResult: any;
}

const isPromise = obj =>
!!obj &&
(typeof obj === 'object' || typeof obj === 'function') &&
typeof obj.then === 'function';
import JestAssertionError from './JestAssertionError';
import {
makeThrowingMatcher,
makeResolveMatcher,
makeRejectMatcher,
} from '../build/matcherMakers';

const createToThrowErrorMatchingSnapshotMatcher = function(matcher) {
return function(received: any, testNameOrInlineSnapshot?: string) {
Expand Down Expand Up @@ -131,201 +114,6 @@ const expect = (actual: any, ...rest): ExpectationObject => {
return expectation;
};

const getMessage = message =>
(message && message()) ||
matcherUtils.RECEIVED_COLOR('No message was specified for this matcher.');

const makeResolveMatcher = (
matcherName: string,
matcher: RawMatcherFn,
isNot: boolean,
actual: Promise<any>,
outerErr: JestAssertionError,
): PromiseMatcherFn => (...args) => {
const options = {
isNot,
promise: 'resolves',
};

if (!isPromise(actual)) {
throw new JestAssertionError(
matcherUtils.matcherErrorMessage(
matcherUtils.matcherHint(matcherName, undefined, '', options),
`${matcherUtils.RECEIVED_COLOR('received')} value must be a promise`,
matcherUtils.printWithType(
'Received',
actual,
matcherUtils.printReceived,
),
),
);
}

const innerErr = new JestAssertionError();

return actual.then(
result =>
makeThrowingMatcher(matcher, isNot, 'resolves', result, innerErr).apply(
null,
args,
),
reason => {
outerErr.message =
matcherUtils.matcherHint(matcherName, undefined, '', options) +
'\n\n' +
`Received promise rejected instead of resolved\n` +
`Rejected to value: ${matcherUtils.printReceived(reason)}`;
return Promise.reject(outerErr);
},
);
};

const makeRejectMatcher = (
matcherName: string,
matcher: RawMatcherFn,
isNot: boolean,
actual: Promise<any>,
outerErr: JestAssertionError,
): PromiseMatcherFn => (...args) => {
const options = {
isNot,
promise: 'rejects',
};

if (!isPromise(actual)) {
throw new JestAssertionError(
matcherUtils.matcherErrorMessage(
matcherUtils.matcherHint(matcherName, undefined, '', options),
`${matcherUtils.RECEIVED_COLOR('received')} value must be a promise`,
matcherUtils.printWithType(
'Received',
actual,
matcherUtils.printReceived,
),
),
);
}

const innerErr = new JestAssertionError();

return actual.then(
result => {
outerErr.message =
matcherUtils.matcherHint(matcherName, undefined, '', options) +
'\n\n' +
`Received promise resolved instead of rejected\n` +
`Resolved to value: ${matcherUtils.printReceived(result)}`;
return Promise.reject(outerErr);
},
reason =>
makeThrowingMatcher(matcher, isNot, 'rejects', reason, innerErr).apply(
null,
args,
),
);
};

const makeThrowingMatcher = (
matcher: RawMatcherFn,
isNot: boolean,
promise: string,
actual: any,
err?: JestAssertionError,
): ThrowingMatcherFn =>
function throwingMatcher(...args): any {
let throws = true;
const utils = Object.assign({}, matcherUtils, {
iterableEquality,
subsetEquality,
});

const matcherContext: MatcherState = Object.assign(
// When throws is disabled, the matcher will not throw errors during test
// execution but instead add them to the global matcher state. If a
// matcher throws, test execution is normally stopped immediately. The
// snapshot matcher uses it because we want to log all snapshot
// failures in a test.
{dontThrow: () => (throws = false)},
getState(),
{
equals,
error: err,
isNot,
promise,
utils,
},
);

const processResult = (result: SyncExpectationResult) => {
_validateResult(result);

getState().assertionCalls++;

if ((result.pass && isNot) || (!result.pass && !isNot)) {
// XOR
const message = getMessage(result.message);
let error;

if (err) {
error = err;
error.message = message;
} else {
error = new JestAssertionError(message);

// Try to remove this function from the stack trace frame.
// Guard for some environments (browsers) that do not support this feature.
if (Error.captureStackTrace) {
Error.captureStackTrace(error, throwingMatcher);
}
}
// Passing the result of the matcher with the error so that a custom
// reporter could access the actual and expected objects of the result
// for example in order to display a custom visual diff
error.matcherResult = result;

if (throws) {
throw error;
} else {
getState().suppressedErrors.push(error);
}
}
};

const handlError = (error: Error) => {
if (
matcher[INTERNAL_MATCHER_FLAG] === true &&
!(error instanceof JestAssertionError) &&
error.name !== 'PrettyFormatPluginError' &&
// Guard for some environments (browsers) that do not support this feature.
Error.captureStackTrace
) {
// Try to remove this and deeper functions from the stack trace frame.
Error.captureStackTrace(error, throwingMatcher);
}
throw error;
};

let potentialResult: ExpectationResult;

try {
potentialResult = matcher.apply(matcherContext, [actual].concat(args));

if (isPromise((potentialResult: any))) {
const asyncResult = ((potentialResult: any): AsyncExpectationResult);

return asyncResult
.then(aResult => processResult(aResult))
.catch(error => handlError(error));
} else {
const syncResult = ((potentialResult: any): SyncExpectationResult);

return processResult(syncResult);
}
} catch (error) {
return handlError(error);
}
};

expect.extend = (matchers: MatchersObject): void =>
setMatchers(matchers, false, expect);

Expand All @@ -344,24 +132,6 @@ expect.arrayContaining = arrayContaining;
expect.stringContaining = stringContaining;
expect.stringMatching = stringMatching;

const _validateResult = result => {
if (
typeof result !== 'object' ||
typeof result.pass !== 'boolean' ||
(result.message &&
(typeof result.message !== 'string' &&
typeof result.message !== 'function'))
) {
throw new Error(
'Unexpected return from a matcher function.\n' +
'Matcher functions should ' +
'return an object in the following format:\n' +
' {message?: string | function, pass: boolean}\n' +
`'${matcherUtils.stringify(result)}' was returned`,
);
}
};

function assertions(expected: number) {
const error = new Error();
if (Error.captureStackTrace) {
Expand Down
Loading

0 comments on commit 7640a08

Please sign in to comment.