Skip to content

Commit

Permalink
Add snapshot property matchers (#6210)
Browse files Browse the repository at this point in the history
* Add snapshot property matchers

* Update changelog

* Update based on feedback

* Fix tests
  • Loading branch information
rickhanlonii authored and cpojer committed May 24, 2018
1 parent 73a656d commit aac32f9
Show file tree
Hide file tree
Showing 10 changed files with 260 additions and 26 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions docs/ExpectAPI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand Down
55 changes: 55 additions & 0 deletions docs/SnapshotTesting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Date>,
"id": Any<Number>,
"name": "LeBron James",
}
`;
```

## Best Practices

Snapshots are a fantastic tool for identifying unexpected interface changes
Expand Down
93 changes: 93 additions & 0 deletions integration-tests/__tests__/to_match_snapshot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
9 changes: 8 additions & 1 deletion packages/expect/src/__tests__/extend.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

const matcherUtils = require('jest-matcher-utils');
const {iterableEquality, subsetEquality} = require('../utils');
const {equals} = require('../jasmine_utils');
const jestExpect = require('../');

Expand Down Expand Up @@ -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`;
Expand Down
54 changes: 38 additions & 16 deletions packages/expect/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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.')
);
};

Expand All @@ -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,
),
);
}

Expand All @@ -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);
},
);
Expand All @@ -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,
),
);
}

Expand All @@ -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 =>
Expand All @@ -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
Expand Down Expand Up @@ -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`,
);
}
};
Expand All @@ -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;
}
Expand Down
13 changes: 13 additions & 0 deletions packages/jest-snapshot/src/State.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading

0 comments on commit aac32f9

Please sign in to comment.