From 334161fbfb8314c951074f6b8c199d76d7f0c39c Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 3 Apr 2018 13:54:39 -0300 Subject: [PATCH] Add support for async matchers --- CHANGELOG.md | 2 + docs/ExpectAPI.md | 45 ++++++++++-- flow-typed/npm/jest_v21.x.x.js | 68 +++++++++-------- .../expect-async-matcher.test.js.snap | 59 +++++++++++++++ .../__tests__/expect-async-matcher.test.js | 30 ++++++++ .../__tests__/failure.test.js | 33 +++++++++ .../__tests__/success.test.js | 33 +++++++++ .../expect-async-matcher/matchers.js | 33 +++++++++ .../expect-async-matcher/package.json | 5 ++ packages/expect/src/index.js | 73 ++++++++++++------- packages/expect/src/jest_matchers_object.js | 10 ++- types/Matchers.js | 6 +- 12 files changed, 329 insertions(+), 68 deletions(-) create mode 100644 integration-tests/__tests__/__snapshots__/expect-async-matcher.test.js.snap create mode 100644 integration-tests/__tests__/expect-async-matcher.test.js create mode 100644 integration-tests/expect-async-matcher/__tests__/failure.test.js create mode 100644 integration-tests/expect-async-matcher/__tests__/success.test.js create mode 100644 integration-tests/expect-async-matcher/matchers.js create mode 100644 integration-tests/expect-async-matcher/package.json diff --git a/CHANGELOG.md b/CHANGELOG.md index e911716f6511..0e9c4c1ef83f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### Features +* `[expect]` Add support for async matchers + ([#5836](https://github.com/facebook/jest/pull/5919)) * `[expect]` Suggest toContainEqual ([#5948](https://github.com/facebook/jest/pull/5953)) * `[jest-config]` Export Jest's default options diff --git a/docs/ExpectAPI.md b/docs/ExpectAPI.md index a9d6aad732c9..9d0b47f25c53 100644 --- a/docs/ExpectAPI.md +++ b/docs/ExpectAPI.md @@ -75,12 +75,45 @@ test('even and odd numbers', () => { }); ``` -Matchers should return an object with two keys. `pass` indicates whether there -was a match or not, and `message` provides a function with no arguments that -returns an error message in case of failure. Thus, when `pass` is false, -`message` should return the error message for when `expect(x).yourMatcher()` -fails. And when `pass` is true, `message` should return the error message for -when `expect(x).not.yourMatcher()` fails. +`expect.extends` also supports async matchers. Async matchers return a Promise +so you will need to await the returned value. Let's use an example matcher to +illustrate the usage of them. We are going to implement a very similar matcher +than `toBeDivisibleBy`, only difference is that the divisible number is going to +be pulled from an external source. + +```js +expect.extend({ + async toBeDivisibleByExternalValue(received) { + const externalValue = await getExternalValueFromRemoteSource(); + const pass = received % externalValue == 0; + if (pass) { + return { + message: () => + `expected ${received} not to be divisible by ${externalValue}`, + pass: true, + }; + } else { + return { + message: () => + `expected ${received} to be divisible by ${externalValue}`, + pass: false, + }; + } + }, +}); + +test('is divisible by external value', async () => { + await expect(100).toBeDivisibleByExternalValue(); + await expect(101).not.toBeDivisibleByExternalValue(); +}); +``` + +Matchers should return an object (or a Promise of an object) with two keys. +`pass` indicates whether there was a match or not, and `message` provides a +function with no arguments that returns an error message in case of failure. +Thus, when `pass` is false, `message` should return the error message for when +`expect(x).yourMatcher()` fails. And when `pass` is true, `message` should +return the error message for when `expect(x).not.yourMatcher()` fails. These helper functions can be found on `this` inside a custom matcher: diff --git a/flow-typed/npm/jest_v21.x.x.js b/flow-typed/npm/jest_v21.x.x.js index 4a467880c095..50dc49e23b3c 100644 --- a/flow-typed/npm/jest_v21.x.x.js +++ b/flow-typed/npm/jest_v21.x.x.js @@ -17,7 +17,7 @@ type JestMockFn, TReturn> = { * An array that contains all the object instances that have been * instantiated from this mock function. */ - instances: Array + instances: Array, }, /** * Resets all information stored in the mockFn.mock.calls and @@ -45,7 +45,7 @@ type JestMockFn, TReturn> = { * will also be executed when the mock is called. */ mockImplementation( - fn: (...args: TArguments) => TReturn + fn: (...args: TArguments) => TReturn, ): JestMockFn, /** * Accepts a function that will be used as an implementation of the mock for @@ -53,7 +53,7 @@ type JestMockFn, TReturn> = { * calls produce different results. */ mockImplementationOnce( - fn: (...args: TArguments) => TReturn + fn: (...args: TArguments) => TReturn, ): JestMockFn, /** * Just a simple sugar function for returning `this` @@ -66,14 +66,14 @@ type JestMockFn, TReturn> = { /** * Sugar for only returning a value once inside your mock */ - mockReturnValueOnce(value: TReturn): JestMockFn + mockReturnValueOnce(value: TReturn): JestMockFn, }; type JestAsymmetricEqualityType = { /** * A custom Jasmine equality tester */ - asymmetricMatch(value: mixed): boolean + asymmetricMatch(value: mixed): boolean, }; type JestCallsType = { @@ -83,21 +83,25 @@ type JestCallsType = { count(): number, first(): mixed, mostRecent(): mixed, - reset(): void + reset(): void, }; type JestClockType = { install(): void, mockDate(date: Date): void, tick(milliseconds?: number): void, - uninstall(): void + uninstall(): void, }; -type JestMatcherResult = { +type JestMatcherSyncResult = { message?: string | (() => string), - pass: boolean + pass: boolean, }; +type JestMatcherAsyncResult = Promise; + +type JestMatcherResult = JestMatcherSyncResult | JestMatcherAsyncResult; + type JestMatcher = (actual: any, expected: any) => JestMatcherResult; type JestPromiseType = { @@ -110,7 +114,7 @@ type JestPromiseType = { * Use resolves to unwrap the value of a fulfilled promise so any other * matcher can be chained. If the promise is rejected the assertion fails. */ - resolves: JestExpectType + resolves: JestExpectType, }; /** @@ -133,7 +137,7 @@ type EnzymeMatchersType = { toIncludeText(text: string): void, toHaveValue(value: any): void, toMatchElement(element: React$Element): void, - toMatchSelector(selector: string): void + toMatchSelector(selector: string): void, }; type JestExpectType = { @@ -277,7 +281,7 @@ type JestExpectType = { * Use .toThrowErrorMatchingSnapshot to test that a function throws a error * matching the most recent snapshot when it is called. */ - toThrowErrorMatchingSnapshot(): void + toThrowErrorMatchingSnapshot(): void, }; type JestObjectType = { @@ -329,7 +333,7 @@ type JestObjectType = { * implementation. */ fn, TReturn>( - implementation?: (...args: TArguments) => TReturn + implementation?: (...args: TArguments) => TReturn, ): JestMockFn, /** * Determines if the given function is a mocked function. @@ -352,7 +356,7 @@ type JestObjectType = { mock( moduleName: string, moduleFactory?: any, - options?: Object + options?: Object, ): JestObjectType, /** * Returns the actual module instead of a mock, bypassing all checks on @@ -420,32 +424,32 @@ type JestObjectType = { * Creates a mock function similar to jest.fn but also tracks calls to * object[methodName]. */ - spyOn(object: Object, methodName: string): JestMockFn + spyOn(object: Object, methodName: string): JestMockFn, }; type JestSpyType = { - calls: JestCallsType + calls: JestCallsType, }; /** Runs this function after every test inside this context */ declare function afterEach( fn: (done: () => void) => ?Promise, - timeout?: number + timeout?: number, ): void; /** Runs this function before every test inside this context */ declare function beforeEach( fn: (done: () => void) => ?Promise, - timeout?: number + timeout?: number, ): void; /** Runs this function after all tests have finished inside this context */ declare function afterAll( fn: (done: () => void) => ?Promise, - timeout?: number + timeout?: number, ): void; /** Runs this function before any tests have started inside this context */ declare function beforeAll( fn: (done: () => void) => ?Promise, - timeout?: number + timeout?: number, ): void; /** A context for grouping tests together */ @@ -463,7 +467,7 @@ declare var describe: { /** * Skip running this describe block */ - skip(name: string, fn: () => void): void + skip(name: string, fn: () => void): void, }; /** An individual test unit */ @@ -478,7 +482,7 @@ declare var it: { ( name: string, fn?: (done: () => void) => ?Promise, - timeout?: number + timeout?: number, ): void, /** * Only run this test @@ -490,7 +494,7 @@ declare var it: { only( name: string, fn?: (done: () => void) => ?Promise, - timeout?: number + timeout?: number, ): void, /** * Skip running this test @@ -502,7 +506,7 @@ declare var it: { skip( name: string, fn?: (done: () => void) => ?Promise, - timeout?: number + timeout?: number, ): void, /** * Run the test concurrently @@ -514,13 +518,13 @@ declare var it: { concurrent( name: string, fn?: (done: () => void) => ?Promise, - timeout?: number - ): void + timeout?: number, + ): void, }; declare function fit( name: string, fn: (done: () => void) => ?Promise, - timeout?: number + timeout?: number, ): void; /** An individual test unit */ declare var test: typeof it; @@ -538,7 +542,7 @@ declare var expect: { /** The object that you want to make assertions against */ (value: any): JestExpectType & JestPromiseType & EnzymeMatchersType, /** Add additional Jasmine matchers to Jest's roster */ - extend(matchers: { [name: string]: JestMatcher }): void, + extend(matchers: {[name: string]: JestMatcher}): void, /** Add a module that formats application-specific data structures. */ addSnapshotSerializer(serializer: (input: Object) => string): void, assertions(expectedAssertions: number): void, @@ -549,7 +553,7 @@ declare var expect: { objectContaining(value: Object): void, /** Matches any received string that contains the exact expected string. */ stringContaining(value: string): void, - stringMatching(value: string | RegExp): void + stringMatching(value: string | RegExp): void, }; // TODO handle return type @@ -572,8 +576,8 @@ declare var jasmine: { createSpy(name: string): JestSpyType, createSpyObj( baseName: string, - methodNames: Array - ): { [methodName: string]: JestSpyType }, + methodNames: Array, + ): {[methodName: string]: JestSpyType}, objectContaining(value: Object): void, - stringMatching(value: string): void + stringMatching(value: string): void, }; diff --git a/integration-tests/__tests__/__snapshots__/expect-async-matcher.test.js.snap b/integration-tests/__tests__/__snapshots__/expect-async-matcher.test.js.snap new file mode 100644 index 000000000000..53443ffd492a --- /dev/null +++ b/integration-tests/__tests__/__snapshots__/expect-async-matcher.test.js.snap @@ -0,0 +1,59 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`shows the correct errors in stderr when failing tests 1`] = ` +Object { + "rest": "FAIL __tests__/failure.test.js + ✕ fail with expected non promise values + ✕ fail with expected non promise values and not + ✕ fail with expected promise values + ✕ fail with expected promise values and not + + ● fail with expected non promise values + + Error + Error: Expected value to have length: + 2 + Received: + 1 + received.length: + 1 + + ● fail with expected non promise values and not + + Error + Error: Expected value to not have length: + 2 + Received: + 1,2 + received.length: + 2 + + ● fail with expected promise values + + Error + Error: Expected value to have length: + 2 + Received: + 1 + received.length: + 1 + + ● fail with expected promise values and not + + Error + Error: Expected value to not have length: + 2 + Received: + 1,2 + received.length: + 2 + +", + "summary": "Test Suites: 1 failed, 1 total +Tests: 4 failed, 4 total +Snapshots: 0 total +Time: <> +Ran all test suites matching /failure.test.js/i. +", +} +`; diff --git a/integration-tests/__tests__/expect-async-matcher.test.js b/integration-tests/__tests__/expect-async-matcher.test.js new file mode 100644 index 000000000000..24ac7e1d747f --- /dev/null +++ b/integration-tests/__tests__/expect-async-matcher.test.js @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. 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'; + +const path = require('path'); +const SkipOnWindows = require('../../scripts/SkipOnWindows'); +const runJest = require('../runJest'); +const {extractSummary} = require('../Utils'); +const dir = path.resolve(__dirname, '../expect-async-matcher'); + +SkipOnWindows.suite(); + +test('works with passing tests', () => { + const result = runJest(dir, ['success.test.js']); + expect(result.status).toBe(0); +}); + +test('shows the correct errors in stderr when failing tests', () => { + const result = runJest(dir, ['failure.test.js']); + + expect(result.status).toBe(1); + expect(extractSummary(result.stderr)).toMatchSnapshot(); +}); diff --git a/integration-tests/expect-async-matcher/__tests__/failure.test.js b/integration-tests/expect-async-matcher/__tests__/failure.test.js new file mode 100644 index 000000000000..e94109f7cafb --- /dev/null +++ b/integration-tests/expect-async-matcher/__tests__/failure.test.js @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. 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'; + +import {toHaveLengthAsync} from '../matchers'; + +expect.extend({ + toHaveLengthAsync, +}); + +it('fail with expected non promise values', async () => { + await (expect([1]): any).toHaveLengthAsync(Promise.resolve(2)); +}); + +it('fail with expected non promise values and not', async () => { + await (expect([1, 2]): any).not.toHaveLengthAsync(Promise.resolve(2)); +}); + +it('fail with expected promise values', async () => { + await (expect(Promise.resolve([1])): any).resolves.toHaveLengthAsync( + Promise.resolve(2) + ); +}); + +it('fail with expected promise values and not', async () => { + await (expect(Promise.resolve([1, 2])).resolves.not: any).toHaveLengthAsync( + Promise.resolve(2) + ); +}); diff --git a/integration-tests/expect-async-matcher/__tests__/success.test.js b/integration-tests/expect-async-matcher/__tests__/success.test.js new file mode 100644 index 000000000000..57808c5dccb5 --- /dev/null +++ b/integration-tests/expect-async-matcher/__tests__/success.test.js @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. 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'; + +import {toHaveLengthAsync} from '../matchers'; + +expect.extend({ + toHaveLengthAsync, +}); + +it('works with expected non promise values', async () => { + await (expect([1]): any).toHaveLengthAsync(Promise.resolve(1)); +}); + +it('works with expected non promise values and not', async () => { + await (expect([1, 2]): any).not.toHaveLengthAsync(Promise.resolve(1)); +}); + +it('works with expected promise values', async () => { + await (expect(Promise.resolve([1])).resolves: any).toHaveLengthAsync( + Promise.resolve(1) + ); +}); + +it('works with expected promise values and not', async () => { + await (expect(Promise.resolve([1, 2])).resolves.not: any).toHaveLengthAsync( + Promise.resolve(1) + ); +}); diff --git a/integration-tests/expect-async-matcher/matchers.js b/integration-tests/expect-async-matcher/matchers.js new file mode 100644 index 000000000000..9f8b01174199 --- /dev/null +++ b/integration-tests/expect-async-matcher/matchers.js @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. 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'; + +export async function toHaveLengthAsync( + received: any, + lengthPromise: Promise +) { + const length = await lengthPromise; + + const pass = received.length === length; + const message = pass + ? () => + `Expected value to not have length:\n` + + ` ${length}\n` + + `Received:\n` + + ` ${received}\n` + + `received.length:\n` + + ` ${received.length}` + : () => + `Expected value to have length:\n` + + ` ${length}\n` + + `Received:\n` + + ` ${received}\n` + + `received.length:\n` + + ` ${received.length}`; + + return {message, pass}; +} diff --git a/integration-tests/expect-async-matcher/package.json b/integration-tests/expect-async-matcher/package.json new file mode 100644 index 000000000000..148788b25446 --- /dev/null +++ b/integration-tests/expect-async-matcher/package.json @@ -0,0 +1,5 @@ +{ + "jest": { + "testEnvironment": "node" + } +} diff --git a/packages/expect/src/index.js b/packages/expect/src/index.js index 4d766833e593..f78dbfac79a3 100644 --- a/packages/expect/src/index.js +++ b/packages/expect/src/index.js @@ -10,6 +10,8 @@ import type { Expect, ExpectationObject, + AsyncExpectationResult, + SyncExpectationResult, ExpectationResult, MatcherState, MatchersObject, @@ -196,7 +198,7 @@ const makeThrowingMatcher = ( isNot: boolean, actual: any, ): ThrowingMatcherFn => { - return function throwingMatcher(...args) { + return function throwingMatcher(...args): any { let throws = true; const matcherContext: MatcherState = Object.assign( // When throws is disabled, the matcher will not throw errors during test @@ -212,11 +214,35 @@ const makeThrowingMatcher = ( utils, }, ); - let result: ExpectationResult; - try { - result = matcher.apply(matcherContext, [actual].concat(args)); - } catch (error) { + const processResult = (result: SyncExpectationResult) => { + _validateResult(result); + + getState().assertionCalls++; + + if ((result.pass && isNot) || (!result.pass && !isNot)) { + // XOR + const message = getMessage(result.message); + const error = new JestAssertionError(message); + // 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; + // 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); + } + + if (throws) { + throw error; + } else { + getState().suppressedErrors.push(error); + } + } + }; + + const handlError = (error: Error) => { if ( matcher[INTERNAL_MATCHER_FLAG] === true && !(error instanceof JestAssertionError) && @@ -228,31 +254,26 @@ const makeThrowingMatcher = ( Error.captureStackTrace(error, throwingMatcher); } throw error; - } + }; - _validateResult(result); - - getState().assertionCalls++; - - if ((result.pass && isNot) || (!result.pass && !isNot)) { - // XOR - const message = getMessage(result.message); - const error = new JestAssertionError(message); - // 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; - // 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); - } + let potentialResult: ExpectationResult; + + try { + potentialResult = matcher.apply(matcherContext, [actual].concat(args)); - if (throws) { - throw error; + if (isPromise((potentialResult: any))) { + const asyncResult = ((potentialResult: any): AsyncExpectationResult); + + return asyncResult + .then(aResult => processResult(aResult)) + .catch(error => handlError(error)); } else { - getState().suppressedErrors.push(error); + const syncResult = ((potentialResult: any): SyncExpectationResult); + + return processResult(syncResult); } + } catch (error) { + return handlError(error); } }; }; diff --git a/packages/expect/src/jest_matchers_object.js b/packages/expect/src/jest_matchers_object.js index 12cae657475d..1e1aee1899f7 100644 --- a/packages/expect/src/jest_matchers_object.js +++ b/packages/expect/src/jest_matchers_object.js @@ -8,7 +8,11 @@ */ import {AsymmetricMatcher} from './asymmetric_matchers'; -import type {Expect, MatchersObject} from 'types/Matchers'; +import type { + Expect, + MatchersObject, + SyncExpectationResult, +} from 'types/Matchers'; // Global matchers object holds the list of available matchers and // the state, that can hold matcher specific values that change over time. @@ -64,10 +68,10 @@ export const setMatchers = ( } asymmetricMatch(other: any) { - const {pass}: {message: () => string, pass: boolean} = matcher( + const {pass} = ((matcher( (other: any), (this.sample: any), - ); + ): any): SyncExpectationResult); return this.inverse ? !pass : pass; } diff --git a/types/Matchers.js b/types/Matchers.js index b8ce53f024ca..68a8b7ed28c5 100644 --- a/types/Matchers.js +++ b/types/Matchers.js @@ -10,11 +10,15 @@ import type {Path} from 'types/Config'; import type {SnapshotState} from 'jest-snapshot'; -export type ExpectationResult = { +export type SyncExpectationResult = { pass: boolean, message: () => string, }; +export type AsyncExpectationResult = Promise; + +export type ExpectationResult = SyncExpectationResult | AsyncExpectationResult; + export type RawMatcherFn = ( expected: any, actual: any,