├── .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 |
11 |

Hello World

12 |
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 | [![Build Status](https://travis-ci.org/cdlewis/snapshotter.svg?branch=master)](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 | ![Screenshot](/screenshot.png?raw=true "Screenshot") 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 |
40 |

Hello World

41 |
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 | }; --------------------------------------------------------------------------------