Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add snapshot property matchers #6210

Merged
merged 5 commits into from
May 24, 2018
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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-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))
Expand Down
9 changes: 6 additions & 3 deletions docs/ExpectAPI.md
Original file line number Diff line number Diff line change
Expand Up @@ -1192,13 +1192,16 @@ test('this house has my desired features', () => {
});
```

### `.toMatchSnapshot(optionalString)`
### `.toMatchSnapshot(propertyMatchers, snapshotName)`
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it will be better to go with something like this:

### `.toMatchSnapshot(?propertyMatchers|snapshotName, ?snapshotName)`

Even though we explain this later


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)});
});
`);

{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

weird to use scopes here, although I get why you do it. why not just a descirbe around it and multiple tests instead?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My thoughts as well - I was copying the style from the rest of the suite. I'll update the suite 👌

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