Skip to content

Commit

Permalink
Add snapshot assertion (#1113)
Browse files Browse the repository at this point in the history
  • Loading branch information
lithin authored and sindresorhus committed Dec 2, 2016
1 parent 09bf88a commit ee65b6d
Show file tree
Hide file tree
Showing 8 changed files with 251 additions and 28 deletions.
35 changes: 35 additions & 0 deletions lib/assert.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ var util = require('util');
var assert = require('core-assert');
var deepEqual = require('lodash.isequal');
var observableToPromise = require('observable-to-promise');
var indentString = require('indent-string');
var isObservable = require('is-observable');
var isPromise = require('is-promise');
var jestSnapshot = require('jest-snapshot');
var snapshotState = require('./snapshot-state');

const x = module.exports;
const noop = () => {};
Expand Down Expand Up @@ -148,6 +151,38 @@ x.ifError = (err, msg) => {
test(!err, create(err, 'Error', '!==', msg, x.ifError));
};

x._snapshot = function (tree, optionalMessage, match, snapshotStateGetter) {
// set defaults - this allows tests to mock deps easily
var toMatchSnapshot = match || jestSnapshot.toMatchSnapshot;
var getState = snapshotStateGetter || snapshotState.get;

var state = getState();

var context = {
dontThrow: function () {},
currentTestName: this.title,
snapshotState: state
};

var result = toMatchSnapshot.call(context, tree);

var message = 'Please check your code or --update-snapshots\n\n';
if (optionalMessage) {
message += indentString(optionalMessage, 2);
}
if (typeof result.message === 'function') {
message += indentString(result.message(), 2);
}

state.save();

test(result.pass, create(result, false, 'snapshot', message, x.snap));
};

x.snapshot = function (tree, optionalMessage) {
x._snapshot.call(this, tree, optionalMessage);
};

/*
* deprecated APIs
*/
Expand Down
34 changes: 19 additions & 15 deletions lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,19 @@ exports.run = function () {
' ava [<file|directory|glob> ...]',
'',
'Options',
' --init Add AVA to your project',
' --fail-fast Stop after first test failure',
' --serial, -s Run tests serially',
' --tap, -t Generate TAP output',
' --verbose, -v Enable verbose output',
' --no-cache Disable the transpiler cache',
' --no-power-assert Disable Power Assert',
' --match, -m Only run tests with matching title (Can be repeated)',
' --watch, -w Re-run tests when tests and source files change',
' --source, -S Pattern to match source files so tests can be re-run (Can be repeated)',
' --timeout, -T Set global timeout',
' --concurrency, -c Maximum number of test files running at the same time (EXPERIMENTAL)',
' --init Add AVA to your project',
' --fail-fast Stop after first test failure',
' --serial, -s Run tests serially',
' --tap, -t Generate TAP output',
' --verbose, -v Enable verbose output',
' --no-cache Disable the transpiler cache',
' --no-power-assert Disable Power Assert',
' --match, -m Only run tests with matching title (Can be repeated)',
' --watch, -w Re-run tests when tests and source files change',
' --source, -S Pattern to match source files so tests can be re-run (Can be repeated)',
' --timeout, -T Set global timeout',
' --concurrency, -c Maximum number of test files running at the same time (EXPERIMENTAL)',
' --update-snapshots, -u Update snapshots',
'',
'Examples',
' ava',
Expand All @@ -67,7 +68,8 @@ exports.run = function () {
'verbose',
'serial',
'tap',
'watch'
'watch',
'update-snapshots'
],
default: conf,
alias: {
Expand All @@ -78,7 +80,8 @@ exports.run = function () {
w: 'watch',
S: 'source',
T: 'timeout',
c: 'concurrency'
c: 'concurrency',
u: 'update-snapshots'
}
});

Expand Down Expand Up @@ -112,7 +115,8 @@ exports.run = function () {
resolveTestsFrom: cli.input.length === 0 ? pkgDir : process.cwd(),
pkgDir: pkgDir,
timeout: cli.flags.timeout,
concurrency: cli.flags.concurrency ? parseInt(cli.flags.concurrency, 10) : 0
concurrency: cli.flags.concurrency ? parseInt(cli.flags.concurrency, 10) : 0,
updateSnapshots: cli.flags.updateSnapshots
});

var reporter;
Expand Down
3 changes: 2 additions & 1 deletion lib/enhance-assert.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ module.exports.NON_ENHANCED_PATTERNS = [
't.fail([message])',
't.throws(fn, [message])',
't.notThrows(fn, [message])',
't.ifError(error, [message])'
't.ifError(error, [message])',
't.snapshot(contents, [message])'
];

function enhanceAssert(opts) {
Expand Down
30 changes: 30 additions & 0 deletions lib/snapshot-state.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use strict';
var path = require('path');
var jestSnapshot = require('jest-snapshot');
var globals = require('./globals');

var x = module.exports;

x.get = function (initializeState, globalsOptions) {
if (!x.state) {
// set defaults - this allows tests to mock deps easily
var options = globalsOptions || globals.options;
var initializeSnapshotState = initializeState || jestSnapshot.initializeSnapshotState;

var filename = options.file;
var dirname = path.dirname(filename);
var snapshotFileName = path.basename(filename) + '.snap';
var snapshotsFolder = path.join(dirname, '__snapshots__', snapshotFileName);

x.state = initializeSnapshotState(
filename,
options.updateSnapshots,
snapshotsFolder,
true
);
}

return x.state;
};

x.state = null;
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,13 @@
"get-port": "^2.1.0",
"has-flag": "^2.0.0",
"ignore-by-default": "^1.0.0",
"indent-string": "^3.0.0",
"is-ci": "^1.0.7",
"is-generator-fn": "^1.0.0",
"is-obj": "^1.0.0",
"is-observable": "^0.2.0",
"is-promise": "^2.1.0",
"jest-snapshot": "^17.0.3",
"last-line-stream": "^1.0.0",
"lodash.debounce": "^4.0.3",
"lodash.difference": "^4.3.0",
Expand Down
75 changes: 63 additions & 12 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,18 +153,19 @@ $ ava --help
ava [<file|directory|glob> ...]

Options
--init Add AVA to your project
--fail-fast Stop after first test failure
--serial, -s Run tests serially
--tap, -t Generate TAP output
--verbose, -v Enable verbose output
--no-cache Disable the transpiler cache
--no-power-assert Disable Power Assert
--match, -m Only run tests with matching title (Can be repeated)
--watch, -w Re-run tests when tests and source files change
--source, -S Pattern to match source files so tests can be re-run (Can be repeated)
--timeout, -T Set global timeout
--concurrency, -c Maximum number of test files running at the same time (EXPERIMENTAL)
--init Add AVA to your project
--fail-fast Stop after first test failure
--serial, -s Run tests serially
--tap, -- [ ] Generate TAP output
--verbose, -v Enable verbose output
--no-cache Disable the transpiler cache
--no-power-assert Disable Power Assert
--match, -m Only run tests with matching title (Can be repeated)
--watch, -w Re-run tests when tests and source files change
--source, -S Pattern to match source files so tests can be re-run (Can be repeated)
--timeout, -T Set global timeout
--concurrency, -c Maximum number of test files running at the same time (EXPERIMENTAL)
--update-snapshots, -u Update all snapshots

Examples
ava
Expand Down Expand Up @@ -964,6 +965,56 @@ Assert that `contents` does not match `regex`.

Assert that `error` is falsy.

### `.snapshot(contents, [message])`

Make a snapshot of the stringified `contents`.

## Snapshot testing

Snapshot testing comes as another kind of assertion and uses [jest-snapshot](https://facebook.github.io/jest/blog/2016/07/27/jest-14.html) under the hood.

When used with React, it looks very similar to Jest:

```js
// your component
const HelloWorld = () => <h1>Hello World...!</h1>;

export default HelloWorld;
```

```js
// your test
import test from 'ava';
import render from 'react-test-renderer';

import HelloWorld from './';

test('HelloWorld component', t => {
const tree = render.create(<HelloWorld />).toJSON();
t.snapshot(tree);
});
```

The first time you run this test, a snapshot file will be created in `__snapshots__` folder looking something like this:

```
exports[`HelloWorld component 1`] = `
<h1>
Hello World...!
</h1>
`;
```

These snapshots should be committed together with your code so that everyone on the team shares current state of the app.

Every time you run this test afterwards, it will check if the component render has changed. If it did, it will fail the test. Then you will have the choice to check your code - and if the change was intentional, you can use the `--update-snapshots` (or `-u`) flag to update the snapshots into their new version.

That might look like this:

`$ ava --update-snapshots`

Note that snapshots can be used for much more than just testing components - you can equally well test any other (data) structure that you can stringify.

### Skipping assertions

Any assertion can be skipped using the `skip` modifier. Skipped assertions are still counted, so there is no need to change your planned assertion count.
Expand Down
52 changes: 52 additions & 0 deletions test/assert.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';
var test = require('tap').test;
var Promise = require('bluebird');
var sinon = require('sinon');
var assert = require('../lib/assert');

test('.pass()', t => {
Expand Down Expand Up @@ -490,3 +491,54 @@ test('.deepEqual() should not mask RangeError from underlying assert', t => {

t.end();
});

test('snapshot makes a snapshot using a library and global options', function (t) {
var saveSpy = sinon.spy();
var state = {save: saveSpy};
var stateGetter = sinon.stub().returns(state);
var matchStub = sinon.stub().returns({
pass: true
});

assert.title = 'Test name';

t.plan(4);

t.doesNotThrow(function () {
assert._snapshot('tree', undefined, matchStub, stateGetter);
});

t.ok(stateGetter.called);

t.match(matchStub.firstCall.thisValue, {
currentTestName: 'Test name',
snapshotState: state
});

t.ok(saveSpy.calledOnce);

delete assert.title;

t.end();
});

test('if snapshot fails, prints a message', function (t) {
var saveSpy = sinon.spy();
var state = {save: saveSpy};
var stateGetter = sinon.stub().returns(state);
var messageStub = sinon.stub().returns('message');
var matchStub = sinon.stub().returns({
pass: false,
message: messageStub
});

t.plan(2);

t.throws(function () {
assert._snapshot('tree', undefined, matchStub, stateGetter);
});

t.ok(messageStub.calledOnce);

t.end();
});
48 changes: 48 additions & 0 deletions test/snapshot-state.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use strict';

var path = require('path');
var test = require('tap').test;
var sinon = require('sinon');
var snapshotState = require('../lib/snapshot-state');

test('snapshot state gets created and returned', function (t) {
var stateStub = sinon.stub().returns('state');

t.plan(3);

t.doesNotThrow(function () {
var result = snapshotState.get(stateStub, {
file: path.join('hello', 'world.test.js'),
updateSnapshots: false
});

t.is(result, 'state');
});

t.ok(stateStub.calledWith(
path.join('hello', 'world.test.js'),
false,
path.join('hello', '__snapshots__', 'world.test.js.snap'),
true
));

t.end();
});

test('snapshot state is returned immediately if it already exists', function (t) {
var stateSpy = sinon.spy();

t.plan(3);

snapshotState.state = 'already made state';

t.doesNotThrow(function () {
var result = snapshotState.get(stateSpy);

t.is(result, 'already made state');
});

t.false(stateSpy.called);

t.end();
});

0 comments on commit ee65b6d

Please sign in to comment.