From 6366b3ef3bb262bf91db049021ff87deb1d3ee96 Mon Sep 17 00:00:00 2001 From: rickhanlonii Date: Sat, 19 May 2018 19:43:04 -0400 Subject: [PATCH 1/4] Add snapshot property matchers --- docs/ExpectAPI.md | 9 ++- docs/SnapshotTesting.md | 55 ++++++++++++++++ .../__tests__/to_match_snapshot.test.js | 62 +++++++++++++++++++ packages/expect/src/index.js | 4 ++ packages/jest-snapshot/src/State.js | 13 ++++ packages/jest-snapshot/src/index.js | 46 ++++++++++++-- packages/jest-snapshot/src/plugins.js | 2 + website/i18n/en.json | 1 - 8 files changed, 183 insertions(+), 9 deletions(-) diff --git a/docs/ExpectAPI.md b/docs/ExpectAPI.md index c818cdbd828c..9eef988fb9cf 100644 --- a/docs/ExpectAPI.md +++ b/docs/ExpectAPI.md @@ -1192,13 +1192,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..f414ab857c37 100644 --- a/integration-tests/__tests__/to_match_snapshot.test.js +++ b/integration-tests/__tests__/to_match_snapshot.test.js @@ -159,3 +159,65 @@ 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); + } +}); diff --git a/packages/expect/src/index.js b/packages/expect/src/index.js index 6fdaa8230b82..021e86773d44 100644 --- a/packages/expect/src/index.js +++ b/packages/expect/src/index.js @@ -21,6 +21,7 @@ import type { } from 'types/Matchers'; import * as utils from 'jest-matcher-utils'; +import {iterableEquality, subsetEquality, getObjectSubset} from './utils'; import matchers from './matchers'; import spyMatchers from './spy_matchers'; import toThrowMatchers, { @@ -223,7 +224,10 @@ const makeThrowingMatcher = ( getState(), { equals, + getObjectSubset, isNot, + iterableEquality, + subsetEquality, utils, }, ); 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..3ceda1d3ab9b 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.iterableEquality, + this.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 81ca7ca8ab2b..ebd775f95e65 100644 --- a/website/i18n/en.json +++ b/website/i18n/en.json @@ -80,7 +80,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", From 46e7bbdc8f3dd69ba3e17e9b372589cd321a3ece Mon Sep 17 00:00:00 2001 From: rickhanlonii Date: Sat, 19 May 2018 19:59:30 -0400 Subject: [PATCH 2/4] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26c1584f6485..e1f15acbcf49 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-cli]` Add `--detectOpenHandles` flag which enables Jest to potentially track down handles keeping it open after tests are complete. ([#6130](https://github.com/facebook/jest/pull/6130)) From 5fe14595cf972e166f631162fcb1dde1d83717e2 Mon Sep 17 00:00:00 2001 From: rickhanlonii Date: Thu, 24 May 2018 14:01:20 +0100 Subject: [PATCH 3/4] Update based on feedback --- .../__tests__/to_match_snapshot.test.js | 31 ++++++++++ packages/expect/src/__tests__/extend.test.js | 7 ++- packages/expect/src/index.js | 58 ++++++++++++------- packages/jest-snapshot/src/index.js | 4 +- 4 files changed, 77 insertions(+), 23 deletions(-) diff --git a/integration-tests/__tests__/to_match_snapshot.test.js b/integration-tests/__tests__/to_match_snapshot.test.js index f414ab857c37..84a9c3efc191 100644 --- a/integration-tests/__tests__/to_match_snapshot.test.js +++ b/integration-tests/__tests__/to_match_snapshot.test.js @@ -221,3 +221,34 @@ test('handles property matchers with custom name', () => { 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..aaa10e986d3b 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,11 @@ 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, { + ...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 021e86773d44..439c4db187fa 100644 --- a/packages/expect/src/index.js +++ b/packages/expect/src/index.js @@ -20,8 +20,8 @@ import type { PromiseMatcherFn, } from 'types/Matchers'; -import * as utils from 'jest-matcher-utils'; -import {iterableEquality, subsetEquality, getObjectSubset} from './utils'; +import * as matcherUtils from 'jest-matcher-utils'; +import {iterableEquality, subsetEquality} from './utils'; import matchers from './matchers'; import spyMatchers from './spy_matchers'; import toThrowMatchers, { @@ -134,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.') ); }; @@ -148,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, + ), ); } @@ -162,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); }, ); @@ -182,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, + ), ); } @@ -194,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 => @@ -214,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 @@ -224,10 +245,7 @@ const makeThrowingMatcher = ( getState(), { equals, - getObjectSubset, isNot, - iterableEquality, - subsetEquality, utils, }, ); @@ -334,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`, ); } }; @@ -354,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/index.js b/packages/jest-snapshot/src/index.js index 3ceda1d3ab9b..985f9abe785d 100644 --- a/packages/jest-snapshot/src/index.js +++ b/packages/jest-snapshot/src/index.js @@ -73,8 +73,8 @@ const toMatchSnapshot = function( if (typeof propertyMatchers === 'object') { const propertyPass = this.equals(received, propertyMatchers, [ - this.iterableEquality, - this.subsetEquality, + this.utils.iterableEquality, + this.utils.subsetEquality, ]); if (!propertyPass) { From 29f5c5df923a44b50bcd91f1928fb57bd4b898b4 Mon Sep 17 00:00:00 2001 From: rickhanlonii Date: Thu, 24 May 2018 17:08:36 +0100 Subject: [PATCH 4/4] Fix tests --- packages/expect/src/__tests__/extend.test.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/expect/src/__tests__/extend.test.js b/packages/expect/src/__tests__/extend.test.js index aaa10e986d3b..7755c1085f73 100644 --- a/packages/expect/src/__tests__/extend.test.js +++ b/packages/expect/src/__tests__/extend.test.js @@ -35,11 +35,13 @@ it('is available globally', () => { it('exposes matcherUtils in context', () => { jestExpect.extend({ _shouldNotError(actual, expected) { - const pass = this.equals(this.utils, { - ...matcherUtils, - iterableEquality, - subsetEquality, - }); + 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`;