From 3e84b422d2cccd1bc81775802fe6f6d9cc8b6959 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 --- flow-typed/npm/jest_v21.x.x.js | 68 +++++++++--------- .../__tests__/expect_async_matcher.js | 60 ++++++++++++++++ packages/expect/src/index.js | 69 +++++++++++-------- packages/expect/src/jest_matchers_object.js | 10 ++- types/Matchers.js | 6 +- yarn.lock | 2 +- 6 files changed, 151 insertions(+), 64 deletions(-) create mode 100644 integration-tests/__tests__/expect_async_matcher.js 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__/expect_async_matcher.js b/integration-tests/__tests__/expect_async_matcher.js new file mode 100644 index 000000000000..9a9b51700e3e --- /dev/null +++ b/integration-tests/__tests__/expect_async_matcher.js @@ -0,0 +1,60 @@ +/** + * 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'; + +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}; +} + +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]).not: any).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/packages/expect/src/index.js b/packages/expect/src/index.js index 4d766833e593..11eee862e7e6 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, @@ -212,10 +214,48 @@ const makeThrowingMatcher = ( utils, }, ); - let result: ExpectationResult; + + 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); + } + } + }; + + let potentialResult: ExpectationResult; try { - result = matcher.apply(matcherContext, [actual].concat(args)); + potentialResult = matcher.apply(matcherContext, [actual].concat(args)); + + if (isPromise((potentialResult: any))) { + const asyncResult = ((potentialResult: any): AsyncExpectationResult); + + asyncResult.then(aResult => processResult(aResult)); + } else { + const syncResult = ((potentialResult: any): SyncExpectationResult); + + processResult(syncResult); + } } catch (error) { if ( matcher[INTERNAL_MATCHER_FLAG] === true && @@ -229,31 +269,6 @@ const makeThrowingMatcher = ( } 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); - } - - if (throws) { - throw error; - } else { - getState().suppressedErrors.push(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, diff --git a/yarn.lock b/yarn.lock index c31206ad02ac..6e9c2c64cdc0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8445,7 +8445,7 @@ supports-color@^3.1.2: dependencies: has-flag "^1.0.0" -supports-color@^5.2.0, supports-color@^5.3.0: +supports-color@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.3.0.tgz#5b24ac15db80fa927cf5227a4a33fd3c4c7676c0" dependencies: