Skip to content

Commit

Permalink
Add support for async matchers (#5919)
Browse files Browse the repository at this point in the history
  • Loading branch information
bilby91 authored and cpojer committed Apr 12, 2018
1 parent a0bf957 commit b80dc79
Show file tree
Hide file tree
Showing 12 changed files with 329 additions and 68 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 39 additions & 6 deletions docs/ExpectAPI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
68 changes: 36 additions & 32 deletions flow-typed/npm/jest_v21.x.x.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ type JestMockFn<TArguments: $ReadOnlyArray<*>, TReturn> = {
* An array that contains all the object instances that have been
* instantiated from this mock function.
*/
instances: Array<TReturn>
instances: Array<TReturn>,
},
/**
* Resets all information stored in the mockFn.mock.calls and
Expand Down Expand Up @@ -45,15 +45,15 @@ type JestMockFn<TArguments: $ReadOnlyArray<*>, TReturn> = {
* will also be executed when the mock is called.
*/
mockImplementation(
fn: (...args: TArguments) => TReturn
fn: (...args: TArguments) => TReturn,
): JestMockFn<TArguments, TReturn>,
/**
* Accepts a function that will be used as an implementation of the mock for
* one call to the mocked function. Can be chained so that multiple function
* calls produce different results.
*/
mockImplementationOnce(
fn: (...args: TArguments) => TReturn
fn: (...args: TArguments) => TReturn,
): JestMockFn<TArguments, TReturn>,
/**
* Just a simple sugar function for returning `this`
Expand All @@ -66,14 +66,14 @@ type JestMockFn<TArguments: $ReadOnlyArray<*>, TReturn> = {
/**
* Sugar for only returning a value once inside your mock
*/
mockReturnValueOnce(value: TReturn): JestMockFn<TArguments, TReturn>
mockReturnValueOnce(value: TReturn): JestMockFn<TArguments, TReturn>,
};

type JestAsymmetricEqualityType = {
/**
* A custom Jasmine equality tester
*/
asymmetricMatch(value: mixed): boolean
asymmetricMatch(value: mixed): boolean,
};

type JestCallsType = {
Expand All @@ -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<JestMatcherSyncResult>;

type JestMatcherResult = JestMatcherSyncResult | JestMatcherAsyncResult;

type JestMatcher = (actual: any, expected: any) => JestMatcherResult;

type JestPromiseType = {
Expand All @@ -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,
};

/**
Expand All @@ -133,7 +137,7 @@ type EnzymeMatchersType = {
toIncludeText(text: string): void,
toHaveValue(value: any): void,
toMatchElement(element: React$Element<any>): void,
toMatchSelector(selector: string): void
toMatchSelector(selector: string): void,
};

type JestExpectType = {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -329,7 +333,7 @@ type JestObjectType = {
* implementation.
*/
fn<TArguments: $ReadOnlyArray<*>, TReturn>(
implementation?: (...args: TArguments) => TReturn
implementation?: (...args: TArguments) => TReturn,
): JestMockFn<TArguments, TReturn>,
/**
* Determines if the given function is a mocked function.
Expand All @@ -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
Expand Down Expand Up @@ -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<any, any>
spyOn(object: Object, methodName: string): JestMockFn<any, any>,
};

type JestSpyType = {
calls: JestCallsType
calls: JestCallsType,
};

/** Runs this function after every test inside this context */
declare function afterEach(
fn: (done: () => void) => ?Promise<mixed>,
timeout?: number
timeout?: number,
): void;
/** Runs this function before every test inside this context */
declare function beforeEach(
fn: (done: () => void) => ?Promise<mixed>,
timeout?: number
timeout?: number,
): void;
/** Runs this function after all tests have finished inside this context */
declare function afterAll(
fn: (done: () => void) => ?Promise<mixed>,
timeout?: number
timeout?: number,
): void;
/** Runs this function before any tests have started inside this context */
declare function beforeAll(
fn: (done: () => void) => ?Promise<mixed>,
timeout?: number
timeout?: number,
): void;

/** A context for grouping tests together */
Expand All @@ -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 */
Expand All @@ -478,7 +482,7 @@ declare var it: {
(
name: string,
fn?: (done: () => void) => ?Promise<mixed>,
timeout?: number
timeout?: number,
): void,
/**
* Only run this test
Expand All @@ -490,7 +494,7 @@ declare var it: {
only(
name: string,
fn?: (done: () => void) => ?Promise<mixed>,
timeout?: number
timeout?: number,
): void,
/**
* Skip running this test
Expand All @@ -502,7 +506,7 @@ declare var it: {
skip(
name: string,
fn?: (done: () => void) => ?Promise<mixed>,
timeout?: number
timeout?: number,
): void,
/**
* Run the test concurrently
Expand All @@ -514,13 +518,13 @@ declare var it: {
concurrent(
name: string,
fn?: (done: () => void) => ?Promise<mixed>,
timeout?: number
): void
timeout?: number,
): void,
};
declare function fit(
name: string,
fn: (done: () => void) => ?Promise<mixed>,
timeout?: number
timeout?: number,
): void;
/** An individual test unit */
declare var test: typeof it;
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -572,8 +576,8 @@ declare var jasmine: {
createSpy(name: string): JestSpyType,
createSpyObj(
baseName: string,
methodNames: Array<string>
): { [methodName: string]: JestSpyType },
methodNames: Array<string>,
): {[methodName: string]: JestSpyType},
objectContaining(value: Object): void,
stringMatching(value: string): void
stringMatching(value: string): void,
};
Original file line number Diff line number Diff line change
@@ -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: <<REPLACED>>
Ran all test suites matching /failure.test.js/i.
",
}
`;
30 changes: 30 additions & 0 deletions integration-tests/__tests__/expect-async-matcher.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
Loading

0 comments on commit b80dc79

Please sign in to comment.