Skip to content

Commit aac32f9

Browse files
rickhanloniicpojer
authored andcommitted
Add snapshot property matchers (#6210)
* Add snapshot property matchers * Update changelog * Update based on feedback * Fix tests
1 parent 73a656d commit aac32f9

File tree

10 files changed

+260
-26
lines changed

10 files changed

+260
-26
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
### Features
44

5+
* `[expect]` Expose `getObjectSubset`, `iterableEquality`, and `subsetEquality`
6+
([#6210](https://github.com/facebook/jest/pull/6210))
7+
* `[jest-snapshot]` Add snapshot property matchers
8+
([#6210](https://github.com/facebook/jest/pull/6210))
59
* `[jest-config]` Support jest-preset.js files within Node modules
610
([#6185](https://github.com/facebook/jest/pull/6185))
711
* `[jest-cli]` Add `--detectOpenHandles` flag which enables Jest to potentially

docs/ExpectAPI.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1201,13 +1201,16 @@ test('this house has my desired features', () => {
12011201
});
12021202
```
12031203

1204-
### `.toMatchSnapshot(optionalString)`
1204+
### `.toMatchSnapshot(propertyMatchers, snapshotName)`
12051205

12061206
This ensures that a value matches the most recent snapshot. Check out
12071207
[the Snapshot Testing guide](SnapshotTesting.md) for more information.
12081208

1209-
You can also specify an optional snapshot name. Otherwise, the name is inferred
1210-
from the test.
1209+
The optional propertyMatchers argument allows you to specify asymmetric matchers
1210+
which are verified instead of the exact values.
1211+
1212+
The last argument allows you option to specify a snapshot name. Otherwise, the
1213+
name is inferred from the test.
12111214

12121215
_Note: While snapshot testing is most commonly used with React components, any
12131216
serializable value can be used as a snapshot._

docs/SnapshotTesting.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,61 @@ watch mode:
140140

141141
![](/jest/img/content/interactiveSnapshotDone.png)
142142

143+
### Property Matchers
144+
145+
Often there are fields in the object you want to snapshot which are generated
146+
(like IDs and Dates). If you try to snapshot these objects, they will force the
147+
snapshot to fail on every run:
148+
149+
```javascript
150+
it('will fail every time', () => {
151+
const user = {
152+
createdAt: new Date(),
153+
id: Math.floor(Math.random() * 20),
154+
name: 'LeBron James',
155+
};
156+
157+
expect(user).toMatchSnapshot();
158+
});
159+
160+
// Snapshot
161+
exports[`will fail every time 1`] = `
162+
Object {
163+
"createdAt": 2018-05-19T23:36:09.816Z,
164+
"id": 3,
165+
"name": "LeBron James",
166+
}
167+
`;
168+
```
169+
170+
For these cases, Jest allows providing an asymmetric matcher for any property.
171+
These matchers are checked before the snapshot is written or tested, and then
172+
saved to the snapshot file instead of the received value:
173+
174+
```javascript
175+
it('will check the matchers and pass', () => {
176+
const user = {
177+
createdAt: new Date(),
178+
id: Math.floor(Math.random() * 20),
179+
name: 'LeBron James',
180+
};
181+
182+
expect(user).toMatchSnapshot({
183+
createdAt: expect.any(Date),
184+
id: expect.any(Number),
185+
});
186+
});
187+
188+
// Snapshot
189+
exports[`will check the matchers and pass 1`] = `
190+
Object {
191+
"createdAt": Any<Date>,
192+
"id": Any<Number>,
193+
"name": "LeBron James",
194+
}
195+
`;
196+
```
197+
143198
## Best Practices
144199

145200
Snapshots are a fantastic tool for identifying unexpected interface changes

integration-tests/__tests__/to_match_snapshot.test.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,96 @@ test('accepts custom snapshot name', () => {
159159
expect(status).toBe(0);
160160
}
161161
});
162+
163+
test('handles property matchers', () => {
164+
const filename = 'handle-property-matchers.test.js';
165+
const template = makeTemplate(`test('handles property matchers', () => {
166+
expect({createdAt: $1}).toMatchSnapshot({createdAt: expect.any(Date)});
167+
});
168+
`);
169+
170+
{
171+
writeFiles(TESTS_DIR, {[filename]: template(['new Date()'])});
172+
const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]);
173+
expect(stderr).toMatch('1 snapshot written from 1 test suite.');
174+
expect(status).toBe(0);
175+
}
176+
177+
{
178+
const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]);
179+
expect(stderr).toMatch('Snapshots: 1 passed, 1 total');
180+
expect(status).toBe(0);
181+
}
182+
183+
{
184+
writeFiles(TESTS_DIR, {[filename]: template(['"string"'])});
185+
const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]);
186+
expect(stderr).toMatch(
187+
'Received value does not match snapshot properties for "handles property matchers 1".',
188+
);
189+
expect(stderr).toMatch('Snapshots: 1 failed, 1 total');
190+
expect(status).toBe(1);
191+
}
192+
});
193+
194+
test('handles property matchers with custom name', () => {
195+
const filename = 'handle-property-matchers-with-name.test.js';
196+
const template = makeTemplate(`test('handles property matchers with name', () => {
197+
expect({createdAt: $1}).toMatchSnapshot({createdAt: expect.any(Date)}, 'custom-name');
198+
});
199+
`);
200+
201+
{
202+
writeFiles(TESTS_DIR, {[filename]: template(['new Date()'])});
203+
const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]);
204+
expect(stderr).toMatch('1 snapshot written from 1 test suite.');
205+
expect(status).toBe(0);
206+
}
207+
208+
{
209+
const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]);
210+
expect(stderr).toMatch('Snapshots: 1 passed, 1 total');
211+
expect(status).toBe(0);
212+
}
213+
214+
{
215+
writeFiles(TESTS_DIR, {[filename]: template(['"string"'])});
216+
const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]);
217+
expect(stderr).toMatch(
218+
'Received value does not match snapshot properties for "handles property matchers with name: custom-name 1".',
219+
);
220+
expect(stderr).toMatch('Snapshots: 1 failed, 1 total');
221+
expect(status).toBe(1);
222+
}
223+
});
224+
225+
test('handles property matchers with deep expect.objectContaining', () => {
226+
const filename = 'handle-property-matchers-with-name.test.js';
227+
const template = makeTemplate(`test('handles property matchers with deep expect.objectContaining', () => {
228+
expect({ user: { createdAt: $1, name: 'Jest' }}).toMatchSnapshot({ user: expect.objectContaining({ createdAt: expect.any(Date) }) });
229+
});
230+
`);
231+
232+
{
233+
writeFiles(TESTS_DIR, {[filename]: template(['new Date()'])});
234+
const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]);
235+
expect(stderr).toMatch('1 snapshot written from 1 test suite.');
236+
expect(status).toBe(0);
237+
}
238+
239+
{
240+
const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]);
241+
expect(stderr).toMatch('Snapshots: 1 passed, 1 total');
242+
expect(status).toBe(0);
243+
}
244+
245+
{
246+
writeFiles(TESTS_DIR, {[filename]: template(['"string"'])});
247+
const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]);
248+
expect(stderr).toMatch(
249+
'Received value does not match snapshot properties for "handles property matchers with deep expect.objectContaining 1".',
250+
);
251+
expect(stderr).toMatch('Snapshots: 1 failed, 1 total');
252+
expect(status).toBe(1);
253+
}
254+
});

packages/expect/src/__tests__/extend.test.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
const matcherUtils = require('jest-matcher-utils');
10+
const {iterableEquality, subsetEquality} = require('../utils');
1011
const {equals} = require('../jasmine_utils');
1112
const jestExpect = require('../');
1213

@@ -34,7 +35,13 @@ it('is available globally', () => {
3435
it('exposes matcherUtils in context', () => {
3536
jestExpect.extend({
3637
_shouldNotError(actual, expected) {
37-
const pass = this.utils === matcherUtils;
38+
const pass = this.equals(
39+
this.utils,
40+
Object.assign(matcherUtils, {
41+
iterableEquality,
42+
subsetEquality,
43+
}),
44+
);
3845
const message = pass
3946
? () => `expected this.utils to be defined in an extend call`
4047
: () => `expected this.utils not to be defined in an extend call`;

packages/expect/src/index.js

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import type {
2020
PromiseMatcherFn,
2121
} from 'types/Matchers';
2222

23-
import * as utils from 'jest-matcher-utils';
23+
import * as matcherUtils from 'jest-matcher-utils';
24+
import {iterableEquality, subsetEquality} from './utils';
2425
import matchers from './matchers';
2526
import spyMatchers from './spy_matchers';
2627
import toThrowMatchers, {
@@ -133,7 +134,7 @@ const expect = (actual: any, ...rest): ExpectationObject => {
133134
const getMessage = message => {
134135
return (
135136
(message && message()) ||
136-
utils.RECEIVED_COLOR('No message was specified for this matcher.')
137+
matcherUtils.RECEIVED_COLOR('No message was specified for this matcher.')
137138
);
138139
};
139140

@@ -147,10 +148,16 @@ const makeResolveMatcher = (
147148
const matcherStatement = `.resolves.${isNot ? 'not.' : ''}${matcherName}`;
148149
if (!isPromise(actual)) {
149150
throw new JestAssertionError(
150-
utils.matcherHint(matcherStatement, 'received', '') +
151+
matcherUtils.matcherHint(matcherStatement, 'received', '') +
151152
'\n\n' +
152-
`${utils.RECEIVED_COLOR('received')} value must be a Promise.\n` +
153-
utils.printWithType('Received', actual, utils.printReceived),
153+
`${matcherUtils.RECEIVED_COLOR(
154+
'received',
155+
)} value must be a Promise.\n` +
156+
matcherUtils.printWithType(
157+
'Received',
158+
actual,
159+
matcherUtils.printReceived,
160+
),
154161
);
155162
}
156163

@@ -161,11 +168,13 @@ const makeResolveMatcher = (
161168
makeThrowingMatcher(matcher, isNot, result, innerErr).apply(null, args),
162169
reason => {
163170
outerErr.message =
164-
utils.matcherHint(matcherStatement, 'received', '') +
171+
matcherUtils.matcherHint(matcherStatement, 'received', '') +
165172
'\n\n' +
166-
`Expected ${utils.RECEIVED_COLOR('received')} Promise to resolve, ` +
173+
`Expected ${matcherUtils.RECEIVED_COLOR(
174+
'received',
175+
)} Promise to resolve, ` +
167176
'instead it rejected to value\n' +
168-
` ${utils.printReceived(reason)}`;
177+
` ${matcherUtils.printReceived(reason)}`;
169178
return Promise.reject(outerErr);
170179
},
171180
);
@@ -181,10 +190,16 @@ const makeRejectMatcher = (
181190
const matcherStatement = `.rejects.${isNot ? 'not.' : ''}${matcherName}`;
182191
if (!isPromise(actual)) {
183192
throw new JestAssertionError(
184-
utils.matcherHint(matcherStatement, 'received', '') +
193+
matcherUtils.matcherHint(matcherStatement, 'received', '') +
185194
'\n\n' +
186-
`${utils.RECEIVED_COLOR('received')} value must be a Promise.\n` +
187-
utils.printWithType('Received', actual, utils.printReceived),
195+
`${matcherUtils.RECEIVED_COLOR(
196+
'received',
197+
)} value must be a Promise.\n` +
198+
matcherUtils.printWithType(
199+
'Received',
200+
actual,
201+
matcherUtils.printReceived,
202+
),
188203
);
189204
}
190205

@@ -193,11 +208,13 @@ const makeRejectMatcher = (
193208
return actual.then(
194209
result => {
195210
outerErr.message =
196-
utils.matcherHint(matcherStatement, 'received', '') +
211+
matcherUtils.matcherHint(matcherStatement, 'received', '') +
197212
'\n\n' +
198-
`Expected ${utils.RECEIVED_COLOR('received')} Promise to reject, ` +
213+
`Expected ${matcherUtils.RECEIVED_COLOR(
214+
'received',
215+
)} Promise to reject, ` +
199216
'instead it resolved to value\n' +
200-
` ${utils.printReceived(result)}`;
217+
` ${matcherUtils.printReceived(result)}`;
201218
return Promise.reject(outerErr);
202219
},
203220
reason =>
@@ -213,6 +230,11 @@ const makeThrowingMatcher = (
213230
): ThrowingMatcherFn => {
214231
return function throwingMatcher(...args): any {
215232
let throws = true;
233+
const utils = Object.assign({}, matcherUtils, {
234+
iterableEquality,
235+
subsetEquality,
236+
});
237+
216238
const matcherContext: MatcherState = Object.assign(
217239
// When throws is disabled, the matcher will not throw errors during test
218240
// execution but instead add them to the global matcher state. If a
@@ -330,7 +352,7 @@ const _validateResult = result => {
330352
'Matcher functions should ' +
331353
'return an object in the following format:\n' +
332354
' {message?: string | function, pass: boolean}\n' +
333-
`'${utils.stringify(result)}' was returned`,
355+
`'${matcherUtils.stringify(result)}' was returned`,
334356
);
335357
}
336358
};
@@ -350,7 +372,7 @@ function hasAssertions(...args) {
350372
Error.captureStackTrace(error, hasAssertions);
351373
}
352374

353-
utils.ensureNoExpected(args[0], '.hasAssertions');
375+
matcherUtils.ensureNoExpected(args[0], '.hasAssertions');
354376
getState().isExpectingAssertions = true;
355377
getState().isExpectingAssertionsError = error;
356378
}

packages/jest-snapshot/src/State.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,4 +190,17 @@ export default class SnapshotState {
190190
}
191191
}
192192
}
193+
194+
fail(testName: string, received: any, key?: string) {
195+
this._counters.set(testName, (this._counters.get(testName) || 0) + 1);
196+
const count = Number(this._counters.get(testName));
197+
198+
if (!key) {
199+
key = testNameToKey(testName, count);
200+
}
201+
202+
this._uncheckedKeys.delete(key);
203+
this.unmatched++;
204+
return key;
205+
}
193206
}

0 commit comments

Comments
 (0)