Skip to content

Commit

Permalink
fix: support variadic custom asymmetric matchers (#6898)
Browse files Browse the repository at this point in the history
  • Loading branch information
JamieMason authored and SimenB committed Aug 29, 2018
1 parent a19cad6 commit 0b1e8c3
Show file tree
Hide file tree
Showing 5 changed files with 124 additions and 24 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

### Fixes

- `[expect]` Fix variadic custom asymmetric matchers ([#6898](https://github.com/facebook/jest/pull/6898))
- `[jest-cli]` Fix incorrect `testEnvironmentOptions` warning ([#6852](https://github.com/facebook/jest/pull/6852))
- `[jest-each`] Prevent done callback being supplied to describe ([#6843](https://github.com/facebook/jest/pull/6843))
- `[jest-config`] Better error message for a case when a preset module was found, but no `jest-preset.js` or `jest-preset.json` at the root ([#6863](https://github.com/facebook/jest/pull/6863))
Expand Down
40 changes: 29 additions & 11 deletions docs/ExpectAPI.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,38 +31,41 @@ The argument to `expect` should be the value that your code produces, and any ar

### `expect.extend(matchers)`

You can use `expect.extend` to add your own matchers to Jest. For example, let's say that you're testing a number theory library and you're frequently asserting that numbers are divisible by other numbers. You could abstract that into a `toBeDivisibleBy` matcher:
You can use `expect.extend` to add your own matchers to Jest. For example, let's say that you're testing a number utility library and you're frequently asserting that numbers appear within particular ranges of other numbers. You could abstract that into a `toBeWithinRange` matcher:

```js
expect.extend({
toBeDivisibleBy(received, argument) {
const pass = received % argument == 0;
toBeWithinRange(received, floor, ceiling) {
const pass = received >= floor && received <= ceiling;
if (pass) {
return {
message: () =>
`expected ${received} not to be divisible by ${argument}`,
`expected ${received} not to be within range ${floor} - ${ceiling}`,
pass: true,
};
} else {
return {
message: () => `expected ${received} to be divisible by ${argument}`,
message: () =>
`expected ${received} to be within range ${floor} - ${ceiling}`,
pass: false,
};
}
},
});

test('even and odd numbers', () => {
expect(100).toBeDivisibleBy(2);
expect(101).not.toBeDivisibleBy(2);
test('numeric ranges', () => {
expect(100).toBeWithinRange(90, 110);
expect(101).not.toBeWithinRange(0, 100);
expect({apples: 6, bananas: 3}).toEqual({
apples: expect.toBeDivisibleBy(2),
bananas: expect.not.toBeDivisibleBy(2),
apples: expect.toBeWithinRange(1, 10),

This comment has been minimized.

Copy link
@igorbarbashin

igorbarbashin Sep 12, 2018

This throws an error TypeError: expect.toBeWithinRange is not a function.
I think custom matchers are not directly accessible on expect object.
Is there a different way of doing it?

This comment has been minimized.

Copy link
@SimenB

SimenB Sep 13, 2018

Member

You can see the test added in this commit uses toBeWithinRange , so it does work. Not sure what's up with your setup, but it's probably an error there

bananas: expect.not.toBeWithinRange(11, 20),
});
});
```

`expect.extend` 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.
#### Async Matchers

`expect.extend` 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 matcher called `toBeDivisibleByExternalValue`, where the divisible number is going to be pulled from an external source.

```js
expect.extend({
Expand Down Expand Up @@ -91,8 +94,23 @@ test('is divisible by external value', async () => {
});
```

#### Custom Matchers API

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.

Matchers are called with the argument passed to `expect(x)` followed by the arguments passed to `.yourMatcher(y, z)`:

```js
expect.extend({
yourMatcher(x, y, z) {
return {
pass: true,
message: '',
};
},
});
```

These helper functions can be found on `this` inside a custom matcher:

#### `this.isNot`
Expand Down
46 changes: 43 additions & 3 deletions packages/expect/src/__tests__/__snapshots__/extend.test.js.snap
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`defines asymmetric matchers 1`] = `
exports[`defines asymmetric unary matchers 1`] = `
"<dim>expect(</><red>received</><dim>).toEqual(</><green>expected</><dim>)</>
Expected value to equal:
Expand All @@ -19,7 +19,7 @@ Difference:
<dim> }</>"
`;
exports[`defines asymmetric matchers that can be prefixed by not 1`] = `
exports[`defines asymmetric unary matchers that can be prefixed by not 1`] = `
"<dim>expect(</><red>received</><dim>).toEqual(</><green>expected</><dim>)</>
Expected value to equal:
Expand All @@ -38,6 +38,46 @@ Difference:
<dim> }</>"
`;
exports[`is available globally 1`] = `"expected 15 to be divisible by 2"`;
exports[`defines asymmetric variadic matchers 1`] = `
"<dim>expect(</><red>received</><dim>).toEqual(</><green>expected</><dim>)</>
Expected value to equal:
<green>{\\"value\\": toBeWithinRange<4, 11>}</>
Received:
<red>{\\"value\\": 3}</>
Difference:
<green>- Expected</>
<red>+ Received</>
<dim> Object {</>
<green>- \\"value\\": toBeWithinRange<4, 11>,</>
<red>+ \\"value\\": 3,</>
<dim> }</>"
`;
exports[`defines asymmetric variadic matchers that can be prefixed by not 1`] = `
"<dim>expect(</><red>received</><dim>).toEqual(</><green>expected</><dim>)</>
Expected value to equal:
<green>{\\"value\\": not.toBeWithinRange<1, 3>}</>
Received:
<red>{\\"value\\": 2}</>
Difference:
<green>- Expected</>
<red>+ Received</>
<dim> Object {</>
<green>- \\"value\\": not.toBeWithinRange<1, 3>,</>
<red>+ \\"value\\": 2,</>
<dim> }</>"
`;
exports[`is available globally when matcher is unary 1`] = `"expected 15 to be divisible by 2"`;
exports[`is available globally when matcher is variadic 1`] = `"expected 15 to be within range 1 - 3"`;
exports[`is ok if there is no message specified 1`] = `"<red>No message was specified for this matcher.</>"`;
45 changes: 42 additions & 3 deletions packages/expect/src/__tests__/extend.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,17 @@ jestExpect.extend({

return {message, pass};
},
toBeWithinRange(actual, floor, ceiling) {
const pass = actual >= floor && actual <= ceiling;
const message = pass
? () => `expected ${actual} not to be within range ${floor} - ${ceiling}`
: () => `expected ${actual} to be within range ${floor} - ${ceiling}`;

return {message, pass};
},
});

it('is available globally', () => {
it('is available globally when matcher is unary', () => {
jestExpect(15).toBeDivisibleBy(5);
jestExpect(15).toBeDivisibleBy(3);
jestExpect(15).not.toBeDivisibleBy(6);
Expand All @@ -32,6 +40,15 @@ it('is available globally', () => {
).toThrowErrorMatchingSnapshot();
});

it('is available globally when matcher is variadic', () => {
jestExpect(15).toBeWithinRange(10, 20);
jestExpect(15).not.toBeWithinRange(6);

jestExpect(() =>
jestExpect(15).toBeWithinRange(1, 3),
).toThrowErrorMatchingSnapshot();
});

it('exposes matcherUtils in context', () => {
jestExpect.extend({
_shouldNotError(actual, expected) {
Expand Down Expand Up @@ -78,7 +95,7 @@ it('exposes an equality function to custom matchers', () => {
expect(() => jestExpect().toBeOne()).not.toThrow();
});

it('defines asymmetric matchers', () => {
it('defines asymmetric unary matchers', () => {
expect(() =>
jestExpect({value: 2}).toEqual({value: jestExpect.toBeDivisibleBy(2)}),
).not.toThrow();
Expand All @@ -87,11 +104,33 @@ it('defines asymmetric matchers', () => {
).toThrowErrorMatchingSnapshot();
});

it('defines asymmetric matchers that can be prefixed by not', () => {
it('defines asymmetric unary matchers that can be prefixed by not', () => {
expect(() =>
jestExpect({value: 2}).toEqual({value: jestExpect.not.toBeDivisibleBy(2)}),
).toThrowErrorMatchingSnapshot();
expect(() =>
jestExpect({value: 3}).toEqual({value: jestExpect.not.toBeDivisibleBy(2)}),
).not.toThrow();
});

it('defines asymmetric variadic matchers', () => {
expect(() =>
jestExpect({value: 2}).toEqual({value: jestExpect.toBeWithinRange(1, 3)}),
).not.toThrow();
expect(() =>
jestExpect({value: 3}).toEqual({value: jestExpect.toBeWithinRange(4, 11)}),
).toThrowErrorMatchingSnapshot();
});

it('defines asymmetric variadic matchers that can be prefixed by not', () => {
expect(() =>
jestExpect({value: 2}).toEqual({
value: jestExpect.not.toBeWithinRange(1, 3),
}),
).toThrowErrorMatchingSnapshot();
expect(() =>
jestExpect({value: 3}).toEqual({
value: jestExpect.not.toBeWithinRange(5, 7),
}),
).not.toThrow();
});
16 changes: 9 additions & 7 deletions packages/expect/src/jest_matchers_object.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,18 @@ export const setMatchers = (
// expect is defined

class CustomMatcher extends AsymmetricMatcher {
sample: any;
sample: Array<any>;

constructor(sample: any, inverse: boolean = false) {
constructor(inverse: boolean = false, ...sample: Array<any>) {
super();
this.sample = sample;
this.inverse = inverse;
this.sample = sample;
}

asymmetricMatch(other: any) {
const {pass} = ((matcher(
(other: any),
(this.sample: any),
...(this.sample: any),
): any): SyncExpectationResult);

return this.inverse ? !pass : pass;
Expand All @@ -85,15 +85,17 @@ export const setMatchers = (
}

toAsymmetricMatcher() {
return `${this.toString()}<${this.sample}>`;
return `${this.toString()}<${this.sample.join(', ')}>`;
}
}

expect[key] = (sample: any) => new CustomMatcher(sample);
expect[key] = (...sample: Array<any>) =>
new CustomMatcher(false, ...sample);
if (!expect.not) {
expect.not = {};
}
expect.not[key] = (sample: any) => new CustomMatcher(sample, true);
expect.not[key] = (...sample: Array<any>) =>
new CustomMatcher(true, ...sample);
}
});

Expand Down

0 comments on commit 0b1e8c3

Please sign in to comment.