diff --git a/CHANGELOG.md b/CHANGELOG.md index e9e156182742..01e8079985a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ### Features +* `[expect]` Expose `getObjectSubset`, `iterableEquality`, and `subsetEquality` + ([#6210](https://github.com/facebook/jest/pull/6210)) +* `[jest-snapshot]` Add snapshot property matchers + ([#6210](https://github.com/facebook/jest/pull/6210)) * `[jest-config]` Support jest-preset.js files within Node modules ([#6185](https://github.com/facebook/jest/pull/6185)) * `[jest-cli]` Add `--detectOpenHandles` flag which enables Jest to potentially diff --git a/docs/ExpectAPI.md b/docs/ExpectAPI.md index 82ab5a5e7537..323119234334 100644 --- a/docs/ExpectAPI.md +++ b/docs/ExpectAPI.md @@ -1201,13 +1201,16 @@ test('this house has my desired features', () => { }); ``` -### `.toMatchSnapshot(optionalString)` +### `.toMatchSnapshot(propertyMatchers, snapshotName)` This ensures that a value matches the most recent snapshot. Check out [the Snapshot Testing guide](SnapshotTesting.md) for more information. -You can also specify an optional snapshot name. Otherwise, the name is inferred -from the test. +The optional propertyMatchers argument allows you to specify asymmetric matchers +which are verified instead of the exact values. + +The last argument allows you option to specify a snapshot name. Otherwise, the +name is inferred from the test. _Note: While snapshot testing is most commonly used with React components, any serializable value can be used as a snapshot._ diff --git a/docs/SnapshotTesting.md b/docs/SnapshotTesting.md index 22ad8af9973a..dc22eea4cb44 100644 --- a/docs/SnapshotTesting.md +++ b/docs/SnapshotTesting.md @@ -140,6 +140,61 @@ watch mode: ![](/jest/img/content/interactiveSnapshotDone.png) +### Property Matchers + +Often there are fields in the object you want to snapshot which are generated +(like IDs and Dates). If you try to snapshot these objects, they will force the +snapshot to fail on every run: + +```javascript +it('will fail every time', () => { + const user = { + createdAt: new Date(), + id: Math.floor(Math.random() * 20), + name: 'LeBron James', + }; + + expect(user).toMatchSnapshot(); +}); + +// Snapshot +exports[`will fail every time 1`] = ` +Object { + "createdAt": 2018-05-19T23:36:09.816Z, + "id": 3, + "name": "LeBron James", +} +`; +``` + +For these cases, Jest allows providing an asymmetric matcher for any property. +These matchers are checked before the snapshot is written or tested, and then +saved to the snapshot file instead of the received value: + +```javascript +it('will check the matchers and pass', () => { + const user = { + createdAt: new Date(), + id: Math.floor(Math.random() * 20), + name: 'LeBron James', + }; + + expect(user).toMatchSnapshot({ + createdAt: expect.any(Date), + id: expect.any(Number), + }); +}); + +// Snapshot +exports[`will check the matchers and pass 1`] = ` +Object { + "createdAt": Any, + "id": Any, + "name": "LeBron James", +} +`; +``` + ## Best Practices Snapshots are a fantastic tool for identifying unexpected interface changes diff --git a/integration-tests/__tests__/to_match_snapshot.test.js b/integration-tests/__tests__/to_match_snapshot.test.js index cb4902f83c69..84a9c3efc191 100644 --- a/integration-tests/__tests__/to_match_snapshot.test.js +++ b/integration-tests/__tests__/to_match_snapshot.test.js @@ -159,3 +159,96 @@ test('accepts custom snapshot name', () => { expect(status).toBe(0); } }); + +test('handles property matchers', () => { + const filename = 'handle-property-matchers.test.js'; + const template = makeTemplate(`test('handles property matchers', () => { + expect({createdAt: $1}).toMatchSnapshot({createdAt: expect.any(Date)}); + }); + `); + + { + writeFiles(TESTS_DIR, {[filename]: template(['new Date()'])}); + const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('1 snapshot written from 1 test suite.'); + expect(status).toBe(0); + } + + { + const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('Snapshots: 1 passed, 1 total'); + expect(status).toBe(0); + } + + { + writeFiles(TESTS_DIR, {[filename]: template(['"string"'])}); + const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch( + 'Received value does not match snapshot properties for "handles property matchers 1".', + ); + expect(stderr).toMatch('Snapshots: 1 failed, 1 total'); + expect(status).toBe(1); + } +}); + +test('handles property matchers with custom name', () => { + const filename = 'handle-property-matchers-with-name.test.js'; + const template = makeTemplate(`test('handles property matchers with name', () => { + expect({createdAt: $1}).toMatchSnapshot({createdAt: expect.any(Date)}, 'custom-name'); + }); + `); + + { + writeFiles(TESTS_DIR, {[filename]: template(['new Date()'])}); + const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('1 snapshot written from 1 test suite.'); + expect(status).toBe(0); + } + + { + const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('Snapshots: 1 passed, 1 total'); + expect(status).toBe(0); + } + + { + writeFiles(TESTS_DIR, {[filename]: template(['"string"'])}); + const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch( + 'Received value does not match snapshot properties for "handles property matchers with name: custom-name 1".', + ); + expect(stderr).toMatch('Snapshots: 1 failed, 1 total'); + expect(status).toBe(1); + } +}); + +test('handles property matchers with deep expect.objectContaining', () => { + const filename = 'handle-property-matchers-with-name.test.js'; + const template = makeTemplate(`test('handles property matchers with deep expect.objectContaining', () => { + expect({ user: { createdAt: $1, name: 'Jest' }}).toMatchSnapshot({ user: expect.objectContaining({ createdAt: expect.any(Date) }) }); + }); + `); + + { + writeFiles(TESTS_DIR, {[filename]: template(['new Date()'])}); + const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('1 snapshot written from 1 test suite.'); + expect(status).toBe(0); + } + + { + const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('Snapshots: 1 passed, 1 total'); + expect(status).toBe(0); + } + + { + writeFiles(TESTS_DIR, {[filename]: template(['"string"'])}); + const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch( + 'Received value does not match snapshot properties for "handles property matchers with deep expect.objectContaining 1".', + ); + expect(stderr).toMatch('Snapshots: 1 failed, 1 total'); + expect(status).toBe(1); + } +}); diff --git a/packages/expect/src/__tests__/extend.test.js b/packages/expect/src/__tests__/extend.test.js index 64511d6eb243..7755c1085f73 100644 --- a/packages/expect/src/__tests__/extend.test.js +++ b/packages/expect/src/__tests__/extend.test.js @@ -7,6 +7,7 @@ */ const matcherUtils = require('jest-matcher-utils'); +const {iterableEquality, subsetEquality} = require('../utils'); const {equals} = require('../jasmine_utils'); const jestExpect = require('../'); @@ -34,7 +35,13 @@ it('is available globally', () => { it('exposes matcherUtils in context', () => { jestExpect.extend({ _shouldNotError(actual, expected) { - const pass = this.utils === matcherUtils; + const pass = this.equals( + this.utils, + Object.assign(matcherUtils, { + iterableEquality, + subsetEquality, + }), + ); const message = pass ? () => `expected this.utils to be defined in an extend call` : () => `expected this.utils not to be defined in an extend call`; diff --git a/packages/expect/src/index.js b/packages/expect/src/index.js index 6fdaa8230b82..439c4db187fa 100644 --- a/packages/expect/src/index.js +++ b/packages/expect/src/index.js @@ -20,7 +20,8 @@ import type { PromiseMatcherFn, } from 'types/Matchers'; -import * as utils from 'jest-matcher-utils'; +import * as matcherUtils from 'jest-matcher-utils'; +import {iterableEquality, subsetEquality} from './utils'; import matchers from './matchers'; import spyMatchers from './spy_matchers'; import toThrowMatchers, { @@ -133,7 +134,7 @@ const expect = (actual: any, ...rest): ExpectationObject => { const getMessage = message => { return ( (message && message()) || - utils.RECEIVED_COLOR('No message was specified for this matcher.') + matcherUtils.RECEIVED_COLOR('No message was specified for this matcher.') ); }; @@ -147,10 +148,16 @@ const makeResolveMatcher = ( const matcherStatement = `.resolves.${isNot ? 'not.' : ''}${matcherName}`; if (!isPromise(actual)) { throw new JestAssertionError( - utils.matcherHint(matcherStatement, 'received', '') + + matcherUtils.matcherHint(matcherStatement, 'received', '') + '\n\n' + - `${utils.RECEIVED_COLOR('received')} value must be a Promise.\n` + - utils.printWithType('Received', actual, utils.printReceived), + `${matcherUtils.RECEIVED_COLOR( + 'received', + )} value must be a Promise.\n` + + matcherUtils.printWithType( + 'Received', + actual, + matcherUtils.printReceived, + ), ); } @@ -161,11 +168,13 @@ const makeResolveMatcher = ( makeThrowingMatcher(matcher, isNot, result, innerErr).apply(null, args), reason => { outerErr.message = - utils.matcherHint(matcherStatement, 'received', '') + + matcherUtils.matcherHint(matcherStatement, 'received', '') + '\n\n' + - `Expected ${utils.RECEIVED_COLOR('received')} Promise to resolve, ` + + `Expected ${matcherUtils.RECEIVED_COLOR( + 'received', + )} Promise to resolve, ` + 'instead it rejected to value\n' + - ` ${utils.printReceived(reason)}`; + ` ${matcherUtils.printReceived(reason)}`; return Promise.reject(outerErr); }, ); @@ -181,10 +190,16 @@ const makeRejectMatcher = ( const matcherStatement = `.rejects.${isNot ? 'not.' : ''}${matcherName}`; if (!isPromise(actual)) { throw new JestAssertionError( - utils.matcherHint(matcherStatement, 'received', '') + + matcherUtils.matcherHint(matcherStatement, 'received', '') + '\n\n' + - `${utils.RECEIVED_COLOR('received')} value must be a Promise.\n` + - utils.printWithType('Received', actual, utils.printReceived), + `${matcherUtils.RECEIVED_COLOR( + 'received', + )} value must be a Promise.\n` + + matcherUtils.printWithType( + 'Received', + actual, + matcherUtils.printReceived, + ), ); } @@ -193,11 +208,13 @@ const makeRejectMatcher = ( return actual.then( result => { outerErr.message = - utils.matcherHint(matcherStatement, 'received', '') + + matcherUtils.matcherHint(matcherStatement, 'received', '') + '\n\n' + - `Expected ${utils.RECEIVED_COLOR('received')} Promise to reject, ` + + `Expected ${matcherUtils.RECEIVED_COLOR( + 'received', + )} Promise to reject, ` + 'instead it resolved to value\n' + - ` ${utils.printReceived(result)}`; + ` ${matcherUtils.printReceived(result)}`; return Promise.reject(outerErr); }, reason => @@ -213,6 +230,11 @@ const makeThrowingMatcher = ( ): ThrowingMatcherFn => { return function throwingMatcher(...args): any { let throws = true; + const utils = Object.assign({}, matcherUtils, { + iterableEquality, + subsetEquality, + }); + const matcherContext: MatcherState = Object.assign( // When throws is disabled, the matcher will not throw errors during test // execution but instead add them to the global matcher state. If a @@ -330,7 +352,7 @@ const _validateResult = result => { 'Matcher functions should ' + 'return an object in the following format:\n' + ' {message?: string | function, pass: boolean}\n' + - `'${utils.stringify(result)}' was returned`, + `'${matcherUtils.stringify(result)}' was returned`, ); } }; @@ -350,7 +372,7 @@ function hasAssertions(...args) { Error.captureStackTrace(error, hasAssertions); } - utils.ensureNoExpected(args[0], '.hasAssertions'); + matcherUtils.ensureNoExpected(args[0], '.hasAssertions'); getState().isExpectingAssertions = true; getState().isExpectingAssertionsError = error; } diff --git a/packages/jest-snapshot/src/State.js b/packages/jest-snapshot/src/State.js index 7f9469da1148..aaacad4d32f2 100644 --- a/packages/jest-snapshot/src/State.js +++ b/packages/jest-snapshot/src/State.js @@ -190,4 +190,17 @@ export default class SnapshotState { } } } + + fail(testName: string, received: any, key?: string) { + this._counters.set(testName, (this._counters.get(testName) || 0) + 1); + const count = Number(this._counters.get(testName)); + + if (!key) { + key = testNameToKey(testName, count); + } + + this._uncheckedKeys.delete(key); + this.unmatched++; + return key; + } } diff --git a/packages/jest-snapshot/src/index.js b/packages/jest-snapshot/src/index.js index 5bc81da90d94..985f9abe785d 100644 --- a/packages/jest-snapshot/src/index.js +++ b/packages/jest-snapshot/src/index.js @@ -48,8 +48,13 @@ const cleanup = (hasteFS: HasteFS, update: SnapshotUpdateState) => { }; }; -const toMatchSnapshot = function(received: any, testName?: string) { +const toMatchSnapshot = function( + received: any, + propertyMatchers?: any, + testName?: string, +) { this.dontThrow && this.dontThrow(); + testName = typeof propertyMatchers === 'string' ? propertyMatchers : testName; const {currentTestName, isNot, snapshotState}: MatcherState = this; @@ -61,12 +66,43 @@ const toMatchSnapshot = function(received: any, testName?: string) { throw new Error('Jest: snapshot state must be initialized.'); } - const result = snapshotState.match( + const fullTestName = testName && currentTestName ? `${currentTestName}: ${testName}` - : currentTestName || '', - received, - ); + : currentTestName || ''; + + if (typeof propertyMatchers === 'object') { + const propertyPass = this.equals(received, propertyMatchers, [ + this.utils.iterableEquality, + this.utils.subsetEquality, + ]); + + if (!propertyPass) { + const key = snapshotState.fail(fullTestName, received); + + const report = () => + `${RECEIVED_COLOR('Received value')} does not match ` + + `${EXPECTED_COLOR(`snapshot properties for "${key}"`)}.\n\n` + + `Expected snapshot to match properties:\n` + + ` ${this.utils.printExpected(propertyMatchers)}` + + `\nReceived:\n` + + ` ${this.utils.printReceived(received)}`; + + return { + message: () => + matcherHint('.toMatchSnapshot', 'value', 'properties') + + '\n\n' + + report(), + name: 'toMatchSnapshot', + pass: false, + report, + }; + } else { + Object.assign(received, propertyMatchers); + } + } + + const result = snapshotState.match(fullTestName, received); const {pass} = result; let {actual, expected} = result; diff --git a/packages/jest-snapshot/src/plugins.js b/packages/jest-snapshot/src/plugins.js index de9df49e1e3a..8a3131ccc980 100644 --- a/packages/jest-snapshot/src/plugins.js +++ b/packages/jest-snapshot/src/plugins.js @@ -18,6 +18,7 @@ const { Immutable, ReactElement, ReactTestComponent, + AsymmetricMatcher, } = prettyFormat.plugins; let PLUGINS: Array = [ @@ -27,6 +28,7 @@ let PLUGINS: Array = [ DOMCollection, Immutable, jestMockSerializer, + AsymmetricMatcher, ]; // Prepend to list so the last added is the first tested. diff --git a/website/i18n/en.json b/website/i18n/en.json index 5ff16dcbd4ee..897fa2f1d0f0 100644 --- a/website/i18n/en.json +++ b/website/i18n/en.json @@ -81,7 +81,6 @@ "Watch more videos|no description given": "Watch more videos", "Who's using Jest?|no description given": "Who's using Jest?", "Jest is used by teams of all sizes to test web applications, node.js services, mobile apps, and APIs.|no description given": "Jest is used by teams of all sizes to test web applications, node.js services, mobile apps, and APIs.", - "More Jest Users|no description given": "More Jest Users", "Talks & Videos|no description given": "Talks & Videos", "We understand that reading through docs can be boring sometimes. Here is a community curated list of talks & videos around Jest.|no description given": "We understand that reading through docs can be boring sometimes. Here is a community curated list of talks & videos around Jest.", "Add your favorite talk|no description given": "Add your favorite talk",