├── .eslintignore
├── .gitignore
├── screenshot.png
├── .travis.yml
├── test
├── snapshots
│ ├── TestClass.json
│ ├── ArbitraryJson.json
│ ├── TestClass-Fixed.json
│ └── Class-ExpectedJestDiff.json
└── snapshotter.js
├── .eslintrc.js
├── .babelrc
├── src
├── util
│ ├── get-package-root.js
│ └── get-snapshot-path.js
└── snapshotter.js
├── dist
├── util
│ ├── get-package-root.js
│ └── get-snapshot-path.js
└── snapshotter.js
├── README.md
└── package.json
/.eslintignore:
--------------------------------------------------------------------------------
1 | dist/
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | npm-debug.log
3 | .idea/
4 |
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cdlewis/snapshotter/HEAD/screenshot.png
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "node"
4 | - "8"
5 | - "10"
6 | - "12"
7 | - "13"
8 |
--------------------------------------------------------------------------------
/test/snapshots/TestClass.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "div",
3 | "props": {},
4 | "children": [
5 | {}
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/test/snapshots/ArbitraryJson.json:
--------------------------------------------------------------------------------
1 | {
2 | "reducers": {
3 | "tasks": [
4 | {
5 | "name": "taskA"
6 | }
7 | ]
8 | }
9 | }
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "parser": "babel-eslint",
3 | "plugins": ["prettier"],
4 | "rules": {
5 | "prettier/prettier": "error"
6 | }
7 | };
8 |
--------------------------------------------------------------------------------
/test/snapshots/TestClass-Fixed.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "div",
3 | "props": {},
4 | "children": [
5 | {
6 | "type": "p",
7 | "props": {},
8 | "children": [
9 | "Hello World"
10 | ]
11 | }
12 | ]
13 | }
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/preset-env",
5 | {
6 | "useBuiltIns": "entry",
7 | "corejs": 3,
8 | "targets": {
9 | "node": "8"
10 | }
11 | }
12 | ]
13 | ],
14 | "plugins": [
15 | "@babel/plugin-transform-react-jsx"
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/src/util/get-package-root.js:
--------------------------------------------------------------------------------
1 | // copied from jest/packages/jest-util/src/index.js
2 |
3 | import fileExists from 'jest-file-exists';
4 | import path from 'path';
5 | import process from 'process';
6 |
7 | export default () => {
8 | const cwd = process.cwd();
9 |
10 | // Is the cwd somewhere within an npm package?
11 | let root = cwd;
12 | while (!fileExists(path.join(root, 'package.json'))) {
13 | if (root === '/' || root.match(/^[A-Z]:\\/)) {
14 | root = cwd;
15 | break;
16 | }
17 | root = path.resolve(root, '..');
18 | }
19 |
20 | return root;
21 | };
22 |
--------------------------------------------------------------------------------
/test/snapshots/Class-ExpectedJestDiff.json:
--------------------------------------------------------------------------------
1 | "\u001b[32m- Expected\u001b[39m\n\u001b[31m+ Received\u001b[39m\n\n\u001b[2m \u001b[22m \u001b[2mObject {\u001b[22m\n\u001b[2m \u001b[22m \u001b[2m \"children\": Array [\u001b[22m\n\u001b[32m-\u001b[39m \u001b[32m Object {},\u001b[39m\n\u001b[31m+\u001b[39m \u001b[31m Object {\u001b[39m\n\u001b[31m+\u001b[39m \u001b[31m \"children\": Array [\u001b[39m\n\u001b[31m+\u001b[39m \u001b[31m \"Hello World\",\u001b[39m\n\u001b[31m+\u001b[39m \u001b[31m ],\u001b[39m\n\u001b[31m+\u001b[39m \u001b[31m \"props\": Object {},\u001b[39m\n\u001b[31m+\u001b[39m \u001b[31m \"type\": \"p\",\u001b[39m\n\u001b[31m+\u001b[39m \u001b[31m },\u001b[39m\n\u001b[2m \u001b[22m \u001b[2m ],\u001b[22m\n\u001b[2m \u001b[22m \u001b[2m \"props\": Object {},\u001b[22m\n\u001b[2m \u001b[22m \u001b[2m \"type\": \"div\",\u001b[22m\n\u001b[2m \u001b[22m \u001b[2m}\u001b[22m\n"
--------------------------------------------------------------------------------
/dist/util/get-package-root.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | Object.defineProperty(exports, "__esModule", {
4 | value: true
5 | });
6 | exports.default = void 0;
7 |
8 | var _jestFileExists = _interopRequireDefault(require("jest-file-exists"));
9 |
10 | var _path = _interopRequireDefault(require("path"));
11 |
12 | var _process = _interopRequireDefault(require("process"));
13 |
14 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
15 |
16 | // copied from jest/packages/jest-util/src/index.js
17 | var _default = () => {
18 | const cwd = _process.default.cwd(); // Is the cwd somewhere within an npm package?
19 |
20 |
21 | let root = cwd;
22 |
23 | while (!(0, _jestFileExists.default)(_path.default.join(root, 'package.json'))) {
24 | if (root === '/' || root.match(/^[A-Z]:\\/)) {
25 | root = cwd;
26 | break;
27 | }
28 |
29 | root = _path.default.resolve(root, '..');
30 | }
31 |
32 | return root;
33 | };
34 |
35 | exports.default = _default;
--------------------------------------------------------------------------------
/src/util/get-snapshot-path.js:
--------------------------------------------------------------------------------
1 | /* global global */
2 |
3 | import { get, set } from 'lodash';
4 | import path from 'path';
5 | import { readFileSync } from 'fs';
6 | import getPackageRoot from './get-package-root';
7 |
8 | const getSnapshotFilePath = (packageRoot, snapshotDir, id) => {
9 | const snapshotPath = path.join(snapshotDir, `${id}.json`);
10 | const relativeSnapshotPath = path.relative(packageRoot, snapshotPath);
11 | return { relativeSnapshotPath, snapshotPath };
12 | };
13 |
14 | export default id => {
15 | const cacheHit = get(global, 'snapshotter');
16 | if (cacheHit) {
17 | const { packageRoot, snapshotPath } = cacheHit;
18 | return getSnapshotFilePath(packageRoot, snapshotPath, id);
19 | }
20 |
21 | const packageRoot = getPackageRoot();
22 | const config = get(
23 | JSON.parse(readFileSync(`${packageRoot}/package.json`)),
24 | 'snapshotter',
25 | {
26 | snapshotPath: './test/snapshots'
27 | }
28 | );
29 |
30 | set(global, 'snapshotter', { ...config, packageRoot });
31 | return getSnapshotFilePath(packageRoot, config.snapshotPath, id);
32 | };
33 |
--------------------------------------------------------------------------------
/test/snapshotter.js:
--------------------------------------------------------------------------------
1 | import Adapter from 'enzyme-adapter-react-16';
2 | import React from 'react';
3 | import Enzyme from 'enzyme';
4 | import test from 'tape';
5 | import compareToSnapshot from '../src/snapshotter';
6 |
7 | Enzyme.configure({ adapter: new Adapter() });
8 |
9 | const TestClass = () => (
10 |
13 | );
14 |
15 | test('snapshotter detects changes', assert => {
16 | const mockTape = { fail: () => {} };
17 | const shallowWrapper = Enzyme.shallow();
18 | compareToSnapshot(mockTape, shallowWrapper, 'TestClass', {
19 | write: diff => {
20 | // We expect a particular diff output from Jest. Use Snapshotter to validate this.
21 | compareToSnapshot(assert, diff, 'Class-ExpectedJestDiff');
22 | assert.end();
23 | }
24 | });
25 | });
26 |
27 | test('snapshotter handles multiple files', assert => {
28 | const shallowWrapper = Enzyme.shallow();
29 | compareToSnapshot(assert, shallowWrapper, 'TestClass-Fixed');
30 | assert.end();
31 | });
32 |
33 | test('snapshotter handles arbitrary json files', assert => {
34 | const arbitraryJson = {
35 | reducers: {
36 | tasks: [{ name: 'taskA' }]
37 | }
38 | };
39 | compareToSnapshot(assert, arbitraryJson, 'ArbitraryJson');
40 | assert.end();
41 | });
42 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://travis-ci.org/cdlewis/snapshotter)
2 |
3 | # Snapshotter
4 |
5 | Snapshot testing is a compelling feature but sometimes it isn't possible to port
6 | large projects to tools like Jest. Snapshotter is designed to be a drop-in replacement
7 | for Jest's `toMatchSnapshot` function. For backwards compatibility purposes, it
8 | includes built-in support for serialising Enzyme components.
9 |
10 | 
11 |
12 | # Getting Started
13 |
14 | ## Install
15 |
16 | Add the package:
17 |
18 | ```sh
19 | npm install --save-dev snapshotter
20 | ```
21 |
22 | Create a snapshots folder (e.g. `mkdir test/snapshots`) and add it to `package.json`. If you do not specify a folder, Snapshotter will default to `test/snapshots`.
23 |
24 | ```json
25 | "snapshotter": {
26 | "snapshotPath": "./test/snapshots"
27 | }
28 | ```
29 |
30 | ## Usage
31 |
32 | ```jsx
33 | import compareToSnapshot from 'snapshotter'
34 | import React from 'react'
35 | import { shallow } from 'enzyme'
36 | import test from 'tape'
37 |
38 | const TestClass = () => (
39 |
42 | )
43 |
44 | test('TestClass renders', (assert) => {
45 | const shallowWrapper = shallow()
46 | compareToSnapshot(assert, shallowWrapper, 'TestClass')
47 | assert.end()
48 | })
49 | ```
50 |
51 | ## Update snapshots
52 |
53 | To update snapshots, set the `UPDATE_SNAPSHOTS` to a non-falsy value.
54 |
55 | ```sh
56 | UPDATE_SNAPSHOTS=1 npm run test
57 | ```
58 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "snapshotter",
3 | "version": "4.0.0",
4 | "description": "Snapshot testing for Tape",
5 | "main": "dist/snapshotter.js",
6 | "scripts": {
7 | "build": "rm -rf dist && BABEL_ENV=dev babel src --ignore \"**/*.test.js\" --out-dir dist",
8 | "lint": "eslint .",
9 | "preversion": "BABEL_ENV=dev npm run lint && npm test",
10 | "version": "npm run build && git add -A dist",
11 | "postversion": "git push && git push --tags",
12 | "test": "npm run lint && babel-node test/snapshotter.js"
13 | },
14 | "author": "Chris Lewis",
15 | "license": "MIT",
16 | "repository": {
17 | "type": "git",
18 | "url": "https://github.com/cdlewis/snapshotter.git"
19 | },
20 | "dependencies": {
21 | "enzyme-to-json": "3.1.3",
22 | "jest-diff": "^18.1.0",
23 | "jest-file-exists": "^17.0.0",
24 | "lodash": "^4.0.0",
25 | "readline-sync": "^1.4.6"
26 | },
27 | "peerDependencies": {
28 | "tape": "^5.0.0"
29 | },
30 | "devDependencies": {
31 | "@babel/cli": "^7.0.0",
32 | "@babel/core": "^7.0.0",
33 | "@babel/node": "^7.0.0",
34 | "@babel/plugin-transform-react-jsx": "^7.0.0",
35 | "@babel/preset-env": "^7.0.0",
36 | "babel-eslint": "^10.1.0",
37 | "core-js": "^3.6.4",
38 | "enzyme": "^3.1.0",
39 | "enzyme-adapter-react-16": "^1.0.2",
40 | "eslint": "^6.8.0",
41 | "eslint-plugin-prettier": "^3.1.2",
42 | "prettier": "^1.19.1",
43 | "react": "^16.0.0",
44 | "react-dom": "^16.0.0",
45 | "tape": "^5.0.0"
46 | },
47 | "snapshotter": {
48 | "snapshotPath": "./test/snapshots"
49 | },
50 | "prettier": {
51 | "singleQuote": true
52 | },
53 | "engines": {
54 | "node": ">=8.0.0"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/snapshotter.js:
--------------------------------------------------------------------------------
1 | import { get, isEqual } from 'lodash';
2 | import { readFileSync, writeFileSync, mkdirSync } from 'fs';
3 | import process from 'process';
4 | import readlineSync from 'readline-sync';
5 | import { shallowToJson } from 'enzyme-to-json';
6 | import diff from 'jest-diff';
7 | import getSnapshotPath from './util/get-snapshot-path';
8 | import { isEnzymeWrapper } from 'enzyme-to-json/utils.js';
9 | import { dirname } from 'path';
10 |
11 | const stringify = object =>
12 | JSON.stringify(
13 | object,
14 | (key, value) => {
15 | if (typeof value === 'function') {
16 | return '[Function]';
17 | }
18 |
19 | return value;
20 | },
21 | 2
22 | );
23 |
24 | /**
25 | * maybeUpdateSnapshot will prompt the user to update the snapshot if the
26 | * environmental variable, UPDATE_SNAPSHOTS, has been set.
27 | */
28 |
29 | const maybeUpdateSnapshot = (snapshotPath, relativeSnapshotPath, component) => {
30 | if (get(process, 'env.UPDATE_SNAPSHOTS')) {
31 | const shouldUpdate = readlineSync.question(
32 | `\n\x07Write new snapshot to ${relativeSnapshotPath}? (y/n): `
33 | );
34 |
35 | if (shouldUpdate === 'y') {
36 | // Attempt to create the directory if it doesn't already exist (requires Node 10+)
37 | const parentDirectory = dirname(snapshotPath);
38 | try {
39 | mkdirSync(parentDirectory, { recursive: true });
40 | } catch (err) {}
41 |
42 | writeFileSync(snapshotPath, stringify(component), { flag: 'w' });
43 | }
44 | }
45 | };
46 |
47 | module.exports = (assert, component, id, outputBuffer = process.stdout) => {
48 | const serialisedComponent = isEnzymeWrapper(component)
49 | ? JSON.parse(stringify(shallowToJson(component, { noKey: true })))
50 | : component;
51 | const { snapshotPath, relativeSnapshotPath } = getSnapshotPath(id);
52 |
53 | try {
54 | const snapshot = JSON.parse(readFileSync(snapshotPath).toString());
55 | if (isEqual(serialisedComponent, snapshot)) {
56 | assert.pass(`Snapshot matches ${id}`);
57 | return;
58 | }
59 |
60 | assert.fail(`Snapshot for ${id} has changed`);
61 | outputBuffer.write(`${diff(snapshot, serialisedComponent)}\n`);
62 | } catch (err) {
63 | assert.fail(`Snapshot for ${id} missing or invalid`);
64 | }
65 |
66 | maybeUpdateSnapshot(snapshotPath, relativeSnapshotPath, serialisedComponent);
67 | };
68 |
--------------------------------------------------------------------------------
/dist/util/get-snapshot-path.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | Object.defineProperty(exports, "__esModule", {
4 | value: true
5 | });
6 | exports.default = void 0;
7 |
8 | var _lodash = require("lodash");
9 |
10 | var _path = _interopRequireDefault(require("path"));
11 |
12 | var _fs = require("fs");
13 |
14 | var _getPackageRoot = _interopRequireDefault(require("./get-package-root"));
15 |
16 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
17 |
18 | function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; }
19 |
20 | function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; }
21 |
22 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
23 |
24 | const getSnapshotFilePath = (packageRoot, snapshotDir, id) => {
25 | const snapshotPath = _path.default.join(snapshotDir, `${id}.json`);
26 |
27 | const relativeSnapshotPath = _path.default.relative(packageRoot, snapshotPath);
28 |
29 | return {
30 | relativeSnapshotPath,
31 | snapshotPath
32 | };
33 | };
34 |
35 | var _default = id => {
36 | const cacheHit = (0, _lodash.get)(global, 'snapshotter');
37 |
38 | if (cacheHit) {
39 | const {
40 | packageRoot,
41 | snapshotPath
42 | } = cacheHit;
43 | return getSnapshotFilePath(packageRoot, snapshotPath, id);
44 | }
45 |
46 | const packageRoot = (0, _getPackageRoot.default)();
47 | const config = (0, _lodash.get)(JSON.parse((0, _fs.readFileSync)(`${packageRoot}/package.json`)), 'snapshotter', {
48 | snapshotPath: './test/snapshots'
49 | });
50 | (0, _lodash.set)(global, 'snapshotter', _objectSpread(_objectSpread({}, config), {}, {
51 | packageRoot
52 | }));
53 | return getSnapshotFilePath(packageRoot, config.snapshotPath, id);
54 | };
55 |
56 | exports.default = _default;
--------------------------------------------------------------------------------
/dist/snapshotter.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | var _lodash = require("lodash");
4 |
5 | var _fs = require("fs");
6 |
7 | var _process = _interopRequireDefault(require("process"));
8 |
9 | var _readlineSync = _interopRequireDefault(require("readline-sync"));
10 |
11 | var _enzymeToJson = require("enzyme-to-json");
12 |
13 | var _jestDiff = _interopRequireDefault(require("jest-diff"));
14 |
15 | var _getSnapshotPath = _interopRequireDefault(require("./util/get-snapshot-path"));
16 |
17 | var _utils = require("enzyme-to-json/utils.js");
18 |
19 | var _path = require("path");
20 |
21 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
22 |
23 | const stringify = object => JSON.stringify(object, (key, value) => {
24 | if (typeof value === 'function') {
25 | return '[Function]';
26 | }
27 |
28 | return value;
29 | }, 2);
30 | /**
31 | * maybeUpdateSnapshot will prompt the user to update the snapshot if the
32 | * environmental variable, UPDATE_SNAPSHOTS, has been set.
33 | */
34 |
35 |
36 | const maybeUpdateSnapshot = (snapshotPath, relativeSnapshotPath, component) => {
37 | if ((0, _lodash.get)(_process.default, 'env.UPDATE_SNAPSHOTS')) {
38 | const shouldUpdate = _readlineSync.default.question(`\n\x07Write new snapshot to ${relativeSnapshotPath}? (y/n): `);
39 |
40 | if (shouldUpdate === 'y') {
41 | // Attempt to create the directory if it doesn't already exist (requires Node 10+)
42 | const parentDirectory = (0, _path.dirname)(snapshotPath);
43 |
44 | try {
45 | (0, _fs.mkdirSync)(parentDirectory, {
46 | recursive: true
47 | });
48 | } catch (err) {}
49 |
50 | (0, _fs.writeFileSync)(snapshotPath, stringify(component), {
51 | flag: 'w'
52 | });
53 | }
54 | }
55 | };
56 |
57 | module.exports = (assert, component, id, outputBuffer = _process.default.stdout) => {
58 | const serialisedComponent = (0, _utils.isEnzymeWrapper)(component) ? JSON.parse(stringify((0, _enzymeToJson.shallowToJson)(component, {
59 | noKey: true
60 | }))) : component;
61 | const {
62 | snapshotPath,
63 | relativeSnapshotPath
64 | } = (0, _getSnapshotPath.default)(id);
65 |
66 | try {
67 | const snapshot = JSON.parse((0, _fs.readFileSync)(snapshotPath).toString());
68 |
69 | if ((0, _lodash.isEqual)(serialisedComponent, snapshot)) {
70 | assert.pass(`Snapshot matches ${id}`);
71 | return;
72 | }
73 |
74 | assert.fail(`Snapshot for ${id} has changed`);
75 | outputBuffer.write(`${(0, _jestDiff.default)(snapshot, serialisedComponent)}\n`);
76 | } catch (err) {
77 | assert.fail(`Snapshot for ${id} missing or invalid`);
78 | }
79 |
80 | maybeUpdateSnapshot(snapshotPath, relativeSnapshotPath, serialisedComponent);
81 | };
--------------------------------------------------------------------------------