From 0b1e8c361f33249f69b81916a7408ced91469334 Mon Sep 17 00:00:00 2001 From: Jamie Mason Date: Wed, 29 Aug 2018 10:16:03 +0100 Subject: [PATCH] fix: support variadic custom asymmetric matchers (#6898) --- CHANGELOG.md | 1 + docs/ExpectAPI.md | 40 +++++++++++----- .../__snapshots__/extend.test.js.snap | 46 +++++++++++++++++-- packages/expect/src/__tests__/extend.test.js | 45 ++++++++++++++++-- packages/expect/src/jest_matchers_object.js | 16 ++++--- 5 files changed, 124 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d14b32ae79f1..c0dbc8210e4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/docs/ExpectAPI.md b/docs/ExpectAPI.md index fc60a8775b06..77236126a489 100644 --- a/docs/ExpectAPI.md +++ b/docs/ExpectAPI.md @@ -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), + 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({ @@ -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` diff --git a/packages/expect/src/__tests__/__snapshots__/extend.test.js.snap b/packages/expect/src/__tests__/__snapshots__/extend.test.js.snap index 381b08c9f64f..73440c1c56ea 100644 --- a/packages/expect/src/__tests__/__snapshots__/extend.test.js.snap +++ b/packages/expect/src/__tests__/__snapshots__/extend.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`defines asymmetric matchers 1`] = ` +exports[`defines asymmetric unary matchers 1`] = ` "expect(received).toEqual(expected) Expected value to equal: @@ -19,7 +19,7 @@ Difference: }" `; -exports[`defines asymmetric matchers that can be prefixed by not 1`] = ` +exports[`defines asymmetric unary matchers that can be prefixed by not 1`] = ` "expect(received).toEqual(expected) Expected value to equal: @@ -38,6 +38,46 @@ Difference: }" `; -exports[`is available globally 1`] = `"expected 15 to be divisible by 2"`; +exports[`defines asymmetric variadic matchers 1`] = ` +"expect(received).toEqual(expected) + +Expected value to equal: + {\\"value\\": toBeWithinRange<4, 11>} +Received: + {\\"value\\": 3} + +Difference: + +- Expected ++ Received + + Object { +- \\"value\\": toBeWithinRange<4, 11>, ++ \\"value\\": 3, + }" +`; + +exports[`defines asymmetric variadic matchers that can be prefixed by not 1`] = ` +"expect(received).toEqual(expected) + +Expected value to equal: + {\\"value\\": not.toBeWithinRange<1, 3>} +Received: + {\\"value\\": 2} + +Difference: + +- Expected ++ Received + + Object { +- \\"value\\": not.toBeWithinRange<1, 3>, ++ \\"value\\": 2, + }" +`; + +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`] = `"No message was specified for this matcher."`; diff --git a/packages/expect/src/__tests__/extend.test.js b/packages/expect/src/__tests__/extend.test.js index 7755c1085f73..8fe6b6f3c78e 100644 --- a/packages/expect/src/__tests__/extend.test.js +++ b/packages/expect/src/__tests__/extend.test.js @@ -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); @@ -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) { @@ -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(); @@ -87,7 +104,7 @@ 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(); @@ -95,3 +112,25 @@ it('defines asymmetric matchers that can be prefixed by not', () => { 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(); +}); diff --git a/packages/expect/src/jest_matchers_object.js b/packages/expect/src/jest_matchers_object.js index 1e1aee1899f7..253681620fce 100644 --- a/packages/expect/src/jest_matchers_object.js +++ b/packages/expect/src/jest_matchers_object.js @@ -59,18 +59,18 @@ export const setMatchers = ( // expect is defined class CustomMatcher extends AsymmetricMatcher { - sample: any; + sample: Array; - constructor(sample: any, inverse: boolean = false) { + constructor(inverse: boolean = false, ...sample: Array) { 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; @@ -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) => + new CustomMatcher(false, ...sample); if (!expect.not) { expect.not = {}; } - expect.not[key] = (sample: any) => new CustomMatcher(sample, true); + expect.not[key] = (...sample: Array) => + new CustomMatcher(true, ...sample); } });