diff --git a/CHANGELOG.md b/CHANGELOG.md index 72272f4a0e20..39021d60caf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## master +### Features + +- `[jest-snapshot]` Introduce `toMatchInlineSnapshot` and `toThrowErrorMatchingInlineSnapshot` matchers ([#6380](https://github.com/facebook/jest/pull/6380)) + ### Chore & Maintenance - `[website]` Switch domain to https://jestjs.io ([#6549](https://github.com/facebook/jest/pull/6549)) diff --git a/TestUtils.js b/TestUtils.js index c92b4ba9ae60..78bb0db0e1bc 100644 --- a/TestUtils.js +++ b/TestUtils.js @@ -90,6 +90,7 @@ const DEFAULT_PROJECT_CONFIG: ProjectConfig = { modulePathIgnorePatterns: [], modulePaths: [], name: 'test_name', + prettier: 'prettier', resetMocks: false, resetModules: false, resolver: null, diff --git a/docs/Configuration.md b/docs/Configuration.md index 8dcda10f2ca2..88c18ade096f 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -402,6 +402,12 @@ Presets may also be relative filesystem paths. } ``` +### `prettier` [string] + +Default: `'prettier'` + +Sets the path to the [`prettier`](https://prettier.io/) node module used to update inline snapshots. + ### `projects` [array] Default: `undefined` diff --git a/docs/ExpectAPI.md b/docs/ExpectAPI.md index 8cfc199be6fa..7cd3c1abde86 100644 --- a/docs/ExpectAPI.md +++ b/docs/ExpectAPI.md @@ -1025,12 +1025,18 @@ test('this house has my desired features', () => { This ensures that a value matches the most recent snapshot. Check out [the Snapshot Testing guide](SnapshotTesting.md) for more information. -The optional propertyMatchers argument allows you to specify asymmetric matchers which are verified instead of the exact values. +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._ +### `.toMatchInlineSnapshot(propertyMatchers, inlineSnapshot)` + +Ensures that a value matches the most recent snapshot. Unlike [`.toMatchSnapshot()`](#tomatchsnapshotpropertymatchers-snapshotname), the snapshots will be written to the current source file, inline. + +Check out the section on [Inline Snapshots](./SnapshotTesting.md#inline-snapshots) for more info. + ### `.toStrictEqual(value)` Use `.toStrictEqual` to test that objects have the same types as well as structure. @@ -1134,3 +1140,9 @@ exports[`drinking flavors throws on octopus 1`] = `"yuck, octopus flavor"`; ``` Check out [React Tree Snapshot Testing](http://facebook.github.io/jest/blog/2016/07/27/jest-14.html) for more information on snapshot testing. + +### `.toThrowErrorMatchingInlineSnapshot()` + +This matcher is much like [`.toThrowErrorMatchingSnapshot`](#tothrowerrormatchingsnapshot), except instead of writing the snapshot value to a `.snap` file, it will be written into the source code automatically. + +Check out the section on [Inline Snapshots](./SnapshotTesting.md#inline-snapshots) for more info. diff --git a/docs/SnapshotTesting.md b/docs/SnapshotTesting.md index 7e6e571a25cd..d77cf190324f 100644 --- a/docs/SnapshotTesting.md +++ b/docs/SnapshotTesting.md @@ -93,6 +93,49 @@ Once you're finished, Jest will give you a summary before returning back to watc ![](/img/content/interactiveSnapshotDone.png) +### Inline Snapshots + +Inline snapshots behave identically to external snapshots (`.snap` files), except the snapshot values are written automatically back into the source code. This means you can get the benefits of automatically generated snapshots without having to switch to an external file to make sure the correct value was written. + +> Inline snapshots are powered by [Prettier](https://prettier.io). To use inline snapshots you must have `prettier` installed in your project. Your Prettier configuration will be respected when writing to test files. +> +> If you have `prettier` installed in a location where Jest can't find it, you can tell Jest how to find it using the [`"prettier"`](./Configuration.md#prettier-string) configuration property. + +**Example:** + +First, you write a test, calling `.toMatchInlineSnapshot()` with no arguments: + +```javascript +it('renders correctly', () => { + const tree = renderer + .create(Prettier) + .toJSON(); + expect(tree).toMatchInlineSnapshot(); +}); +``` + +The next time you run Jest, `tree` will be evaluated, and a snapshot will be written as an argument to `toMatchInlineSnapshot`: + +```javascript +it('renders correctly', () => { + const tree = renderer + .create(Prettier) + .toJSON(); + expect(tree).toMatchInlineSnapshot(` + + Prettier + +`); +}); +``` + +That's all there is to it! You can even update the snapshots with `--updateSnapshot` or using the `u` key in `--watch` mode. + ### 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: diff --git a/e2e/__tests__/__snapshots__/show_config.test.js.snap b/e2e/__tests__/__snapshots__/show_config.test.js.snap index 04b1d7f17d90..9e44545bda86 100644 --- a/e2e/__tests__/__snapshots__/show_config.test.js.snap +++ b/e2e/__tests__/__snapshots__/show_config.test.js.snap @@ -33,6 +33,7 @@ exports[`--showConfig outputs config info and exits 1`] = ` \\"moduleNameMapper\\": {}, \\"modulePathIgnorePatterns\\": [], \\"name\\": \\"[md5 hash]\\", + \\"prettier\\": null, \\"resetMocks\\": false, \\"resetModules\\": false, \\"resolver\\": null, diff --git a/e2e/__tests__/__snapshots__/to_match_inline_snapshot.test.js.snap b/e2e/__tests__/__snapshots__/to_match_inline_snapshot.test.js.snap new file mode 100644 index 000000000000..9045ee66b05f --- /dev/null +++ b/e2e/__tests__/__snapshots__/to_match_inline_snapshot.test.js.snap @@ -0,0 +1,115 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`basic support: initial write 1`] = ` +"test('inline snapshots', () => + expect({apple: 'original value'}).toMatchInlineSnapshot(\` +Object { + \\"apple\\": \\"original value\\", +} +\`)); +" +`; + +exports[`basic support: snapshot mismatch 1`] = ` +"test('inline snapshots', () => + expect({apple: 'updated value'}).toMatchInlineSnapshot(\` +Object { + \\"apple\\": \\"original value\\", +} +\`)); +" +`; + +exports[`basic support: snapshot passed 1`] = ` +"test('inline snapshots', () => + expect({apple: 'original value'}).toMatchInlineSnapshot(\` +Object { + \\"apple\\": \\"original value\\", +} +\`)); +" +`; + +exports[`basic support: snapshot updated 1`] = ` +"test('inline snapshots', () => + expect({apple: 'updated value'}).toMatchInlineSnapshot(\` +Object { + \\"apple\\": \\"updated value\\", +} +\`)); +" +`; + +exports[`handles property matchers: initial write 1`] = ` +"test('handles property matchers', () => { + expect({createdAt: new Date()}).toMatchInlineSnapshot( + {createdAt: expect.any(Date)}, + \` +Object { + \\"createdAt\\": Any, +} +\`, + ); +}); +" +`; + +exports[`handles property matchers: snapshot failed 1`] = ` +"test('handles property matchers', () => { + expect({createdAt: \\"string\\"}).toMatchInlineSnapshot( + {createdAt: expect.any(Date)}, + \` +Object { + \\"createdAt\\": Any, +} +\`, + ); +}); +" +`; + +exports[`handles property matchers: snapshot passed 1`] = ` +"test('handles property matchers', () => { + expect({createdAt: new Date()}).toMatchInlineSnapshot( + {createdAt: expect.any(Date)}, + \` +Object { + \\"createdAt\\": Any, +} +\`, + ); +}); +" +`; + +exports[`handles property matchers: snapshot updated 1`] = ` +"test('handles property matchers', () => { + expect({createdAt: 'string'}).toMatchInlineSnapshot( + {createdAt: expect.any(String)}, + \` +Object { + \\"createdAt\\": Any, +} +\`, + ); +}); +" +`; + +exports[`supports async matchers 1`] = ` +"test('inline snapshots', async () => { + expect(Promise.resolve('success')).resolves.toMatchInlineSnapshot( + \`\\"success\\"\`, + ); + expect(Promise.reject('fail')).rejects.toMatchInlineSnapshot(\`\\"fail\\"\`); +}); +" +`; + +exports[`supports async tests 1`] = ` +"test('inline snapshots', async () => { + await 'next tick'; + expect(42).toMatchInlineSnapshot(\`42\`); +}); +" +`; diff --git a/e2e/__tests__/__snapshots__/to_throw_error_matching_inline_snapshot.test.js.snap b/e2e/__tests__/__snapshots__/to_throw_error_matching_inline_snapshot.test.js.snap new file mode 100644 index 000000000000..0cd63ce7fd2a --- /dev/null +++ b/e2e/__tests__/__snapshots__/to_throw_error_matching_inline_snapshot.test.js.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should support rejecting promises 1`] = ` +"test('should support rejecting promises', async () => { + await expect( + Promise.reject(new Error('octopus')), + ).rejects.toThrowErrorMatchingInlineSnapshot(\`\\"octopus\\"\`); +}); +" +`; + +exports[`updates existing snapshot: updated snapshot 1`] = ` +"test('updates existing snapshot', () => { + expect(() => { + throw new Error('apple'); + }).toThrowErrorMatchingInlineSnapshot(\`\\"apple\\"\`); +}); +" +`; + +exports[`works fine when function throws error: initial write 1`] = ` +"test('works fine when function throws error', () => { + expect(() => { + throw new Error('apple'); + }).toThrowErrorMatchingInlineSnapshot(\`\\"apple\\"\`); +}); +" +`; diff --git a/e2e/__tests__/to_match_inline_snapshot.test.js b/e2e/__tests__/to_match_inline_snapshot.test.js new file mode 100644 index 000000000000..f02ad696c316 --- /dev/null +++ b/e2e/__tests__/to_match_inline_snapshot.test.js @@ -0,0 +1,165 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +const fs = require('fs'); +const path = require('path'); +const {makeTemplate, writeFiles, cleanup} = require('../Utils'); +const runJest = require('../runJest'); + +const DIR = path.resolve(__dirname, '../toMatchInlineSnapshot'); +const TESTS_DIR = path.resolve(DIR, '__tests__'); + +const readFile = filename => + fs.readFileSync(path.join(TESTS_DIR, filename), 'utf8'); + +beforeEach(() => cleanup(TESTS_DIR)); +afterAll(() => cleanup(TESTS_DIR)); + +test('basic support', () => { + const filename = 'basic-support.test.js'; + const template = makeTemplate( + `test('inline snapshots', () => expect($1).toMatchInlineSnapshot());\n`, + ); + + { + writeFiles(TESTS_DIR, { + [filename]: template(['{apple: "original value"}']), + }); + const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]); + const fileAfter = readFile(filename); + expect(stderr).toMatch('1 snapshot written from 1 test suite.'); + expect(status).toBe(0); + expect(fileAfter).toMatchSnapshot('initial write'); + } + + { + const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]); + const fileAfter = readFile(filename); + expect(stderr).toMatch('Snapshots: 1 passed, 1 total'); + expect(stderr).not.toMatch('1 snapshot written from 1 test suite.'); + expect(status).toBe(0); + expect(fileAfter).toMatchSnapshot('snapshot passed'); + } + + // This test below also covers how jest-editor-support creates terse messages + // for letting a Snapshot update, so if the wording is updated, please edit + // /packages/jest-editor-support/src/test_reconciler.js + { + writeFiles(TESTS_DIR, { + [filename]: readFile(filename).replace('original value', 'updated value'), + }); + const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]); + const fileAfter = readFile(filename); + expect(stderr).toMatch('Received value does not match stored snapshot'); + expect(status).toBe(1); + expect(fileAfter).toMatchSnapshot('snapshot mismatch'); + } + + { + const {stderr, status} = runJest(DIR, [ + '-w=1', + '--ci=false', + filename, + '-u', + ]); + const fileAfter = readFile(filename); + expect(stderr).toMatch('1 snapshot updated from 1 test suite.'); + expect(status).toBe(0); + expect(fileAfter).toMatchSnapshot('snapshot updated'); + } +}); + +test('handles property matchers', () => { + const filename = 'handle-property-matchers.test.js'; + const template = makeTemplate(`test('handles property matchers', () => { + expect({createdAt: $1}).toMatchInlineSnapshot({createdAt: expect.any(Date)}); + }); + `); + + { + writeFiles(TESTS_DIR, {[filename]: template(['new Date()'])}); + const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]); + const fileAfter = readFile(filename); + expect(stderr).toMatch('1 snapshot written from 1 test suite.'); + expect(status).toBe(0); + expect(fileAfter).toMatchSnapshot('initial write'); + } + + { + const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]); + const fileAfter = readFile(filename); + expect(stderr).toMatch('Snapshots: 1 passed, 1 total'); + expect(status).toBe(0); + expect(fileAfter).toMatchSnapshot('snapshot passed'); + } + + { + writeFiles(TESTS_DIR, { + [filename]: readFile(filename).replace('new Date()', '"string"'), + }); + const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]); + const fileAfter = readFile(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); + expect(fileAfter).toMatchSnapshot('snapshot failed'); + } + + { + writeFiles(TESTS_DIR, { + [filename]: readFile(filename).replace('any(Date)', 'any(String)'), + }); + const {stderr, status} = runJest(DIR, [ + '-w=1', + '--ci=false', + filename, + '-u', + ]); + const fileAfter = readFile(filename); + expect(stderr).toMatch('1 snapshot updated from 1 test suite.'); + expect(status).toBe(0); + expect(fileAfter).toMatchSnapshot('snapshot updated'); + } +}); + +test('supports async matchers', () => { + const filename = 'async-matchers.test.js'; + const test = ` + test('inline snapshots', async () => { + expect(Promise.resolve('success')).resolves.toMatchInlineSnapshot(); + expect(Promise.reject('fail')).rejects.toMatchInlineSnapshot(); + }); + `; + + writeFiles(TESTS_DIR, {[filename]: test}); + const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]); + const fileAfter = readFile(filename); + expect(stderr).toMatch('2 snapshots written from 1 test suite.'); + expect(status).toBe(0); + expect(fileAfter).toMatchSnapshot(); +}); + +test('supports async tests', () => { + const filename = 'async.test.js'; + const test = ` + test('inline snapshots', async () => { + await 'next tick'; + expect(42).toMatchInlineSnapshot(); + }); + `; + + writeFiles(TESTS_DIR, {[filename]: test}); + const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]); + const fileAfter = readFile(filename); + expect(stderr).toMatch('1 snapshot written from 1 test suite.'); + expect(status).toBe(0); + expect(fileAfter).toMatchSnapshot(); +}); diff --git a/e2e/__tests__/to_throw_error_matching_inline_snapshot.test.js b/e2e/__tests__/to_throw_error_matching_inline_snapshot.test.js new file mode 100644 index 000000000000..a50b01dd1dcb --- /dev/null +++ b/e2e/__tests__/to_throw_error_matching_inline_snapshot.test.js @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +const path = require('path'); +const fs = require('fs'); +const {makeTemplate, writeFiles, cleanup} = require('../Utils'); +const runJest = require('../runJest'); + +const DIR = path.resolve(__dirname, '../toThrowErrorMatchingInlineSnapshot'); +const TESTS_DIR = path.resolve(DIR, '__tests__'); + +const readFile = filename => + fs.readFileSync(path.join(TESTS_DIR, filename), 'utf8'); + +beforeEach(() => cleanup(TESTS_DIR)); +afterAll(() => cleanup(TESTS_DIR)); + +test('works fine when function throws error', () => { + const filename = 'works-fine-when-function-throws-error.test.js'; + const template = makeTemplate(` + test('works fine when function throws error', () => { + expect(() => { + throw new Error('apple'); + }) + .toThrowErrorMatchingInlineSnapshot(); + }); + `); + + { + writeFiles(TESTS_DIR, {[filename]: template()}); + const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]); + const fileAfter = readFile(filename); + expect(stderr).toMatch('1 snapshot written from 1 test suite.'); + expect(fileAfter).toMatchSnapshot('initial write'); + expect(status).toBe(0); + } +}); + +test('updates existing snapshot', () => { + const filename = 'updates-existing-snapshot.test.js'; + const template = makeTemplate(` + test('updates existing snapshot', () => { + expect(() => { + throw new Error('apple'); + }) + .toThrowErrorMatchingInlineSnapshot(\`"banana"\`); + }); + `); + + { + writeFiles(TESTS_DIR, {[filename]: template()}); + const {stderr, status} = runJest(DIR, [ + '-w=1', + '--ci=false', + filename, + '-u', + ]); + const fileAfter = readFile(filename); + expect(stderr).toMatch('1 snapshot updated from 1 test suite.'); + expect(fileAfter).toMatchSnapshot('updated snapshot'); + expect(status).toBe(0); + } +}); + +test('cannot be used with .not', () => { + const filename = 'cannot-be-used-with-not.test.js'; + const template = makeTemplate(` + test('cannot be used with .not', () => { + expect('').not.toThrowErrorMatchingInlineSnapshot(); + }); + `); + + { + writeFiles(TESTS_DIR, {[filename]: template()}); + const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch( + 'Jest: `.not` cannot be used with `.toThrowErrorMatchingInlineSnapshot()`.', + ); + expect(status).toBe(1); + } +}); + +test('should support rejecting promises', () => { + const filename = 'should-support-rejecting-promises.test.js'; + const template = makeTemplate(` + test('should support rejecting promises', async () => { + await expect(Promise.reject(new Error('octopus'))) + .rejects.toThrowErrorMatchingInlineSnapshot(); + }); + `); + + writeFiles(TESTS_DIR, {[filename]: template()}); + const {stderr, status} = runJest(DIR, ['-w=1', '--ci=false', filename]); + const fileAfter = readFile(filename); + expect(stderr).toMatch('1 snapshot written from 1 test suite.'); + expect(fileAfter).toMatchSnapshot(); + expect(status).toBe(0); +}); diff --git a/e2e/toMatchInlineSnapshot/package.json b/e2e/toMatchInlineSnapshot/package.json new file mode 100644 index 000000000000..148788b25446 --- /dev/null +++ b/e2e/toMatchInlineSnapshot/package.json @@ -0,0 +1,5 @@ +{ + "jest": { + "testEnvironment": "node" + } +} diff --git a/e2e/toThrowErrorMatchingInlineSnapshot/package.json b/e2e/toThrowErrorMatchingInlineSnapshot/package.json new file mode 100644 index 000000000000..148788b25446 --- /dev/null +++ b/e2e/toThrowErrorMatchingInlineSnapshot/package.json @@ -0,0 +1,5 @@ +{ + "jest": { + "testEnvironment": "node" + } +} diff --git a/packages/expect/src/index.js b/packages/expect/src/index.js index 8fc8ada66637..573d1bf9b42c 100644 --- a/packages/expect/src/index.js +++ b/packages/expect/src/index.js @@ -59,15 +59,18 @@ const isPromise = obj => typeof obj.then === 'function'; const createToThrowErrorMatchingSnapshotMatcher = function(matcher) { - return function(received: any, testName?: string) { - return matcher.apply(this, [received, testName, true]); + return function(received: any, testNameOrInlineSnapshot?: string) { + return matcher.apply(this, [received, testNameOrInlineSnapshot, true]); }; }; const getPromiseMatcher = (name, matcher) => { if (name === 'toThrow' || name === 'toThrowError') { return createThrowMatcher('.' + name, true); - } else if (name === 'toThrowErrorMatchingSnapshot') { + } else if ( + name === 'toThrowErrorMatchingSnapshot' || + name === 'toThrowErrorMatchingInlineSnapshot' + ) { return createToThrowErrorMatchingSnapshotMatcher(matcher); } @@ -239,6 +242,7 @@ const makeThrowingMatcher = ( getState(), { equals, + error: err, isNot, utils, }, diff --git a/packages/jest-circus/src/legacy_code_todo_rewrite/jest_adapter_init.js b/packages/jest-circus/src/legacy_code_todo_rewrite/jest_adapter_init.js index 4fc29a28d48a..4c0bc16fe057 100644 --- a/packages/jest-circus/src/legacy_code_todo_rewrite/jest_adapter_init.js +++ b/packages/jest-circus/src/legacy_code_todo_rewrite/jest_adapter_init.js @@ -92,7 +92,11 @@ export const initialize = ({ }); const {expand, updateSnapshot} = globalConfig; - const snapshotState = new SnapshotState(testPath, {expand, updateSnapshot}); + const snapshotState = new SnapshotState(testPath, { + expand, + getPrettier: () => (config.prettier ? localRequire(config.prettier) : null), + updateSnapshot, + }); setState({snapshotState, testPath}); // Return it back to the outer scope (test runner outside the VM). diff --git a/packages/jest-circus/src/legacy_code_todo_rewrite/jest_expect.js b/packages/jest-circus/src/legacy_code_todo_rewrite/jest_expect.js index 62204131faee..de48a4a8ea78 100644 --- a/packages/jest-circus/src/legacy_code_todo_rewrite/jest_expect.js +++ b/packages/jest-circus/src/legacy_code_todo_rewrite/jest_expect.js @@ -14,7 +14,9 @@ import expect from 'expect'; import { addSerializer, toMatchSnapshot, + toMatchInlineSnapshot, toThrowErrorMatchingSnapshot, + toThrowErrorMatchingInlineSnapshot, } from 'jest-snapshot'; type JasmineMatcher = { @@ -29,7 +31,9 @@ export default (config: {expand: boolean}) => { expand: config.expand, }); expect.extend({ + toMatchInlineSnapshot, toMatchSnapshot, + toThrowErrorMatchingInlineSnapshot, toThrowErrorMatchingSnapshot, }); diff --git a/packages/jest-cli/src/cli/args.js b/packages/jest-cli/src/cli/args.js index b12f0a6c84a2..8efb9c89d7a2 100644 --- a/packages/jest-cli/src/cli/args.js +++ b/packages/jest-cli/src/cli/args.js @@ -424,6 +424,11 @@ export const options = { description: "A preset that is used as a base for Jest's configuration.", type: 'string', }, + prettier: { + default: 'prettier', + description: 'The path to the "prettier" module used for inline snapshots.', + type: 'string', + }, projects: { description: 'A list of projects that use Jest to run all tests of all ' + diff --git a/packages/jest-config/src/defaults.js b/packages/jest-config/src/defaults.js index 8fc32221d1ff..b5eab6f3ec58 100644 --- a/packages/jest-config/src/defaults.js +++ b/packages/jest-config/src/defaults.js @@ -49,6 +49,7 @@ export default ({ notify: false, notifyMode: 'always', preset: null, + prettier: 'prettier', projects: null, resetMocks: false, resetModules: false, diff --git a/packages/jest-config/src/index.js b/packages/jest-config/src/index.js index e41f728a0d53..59a453c93ef5 100644 --- a/packages/jest-config/src/index.js +++ b/packages/jest-config/src/index.js @@ -173,6 +173,7 @@ const getConfigs = ( modulePathIgnorePatterns: options.modulePathIgnorePatterns, modulePaths: options.modulePaths, name: options.name, + prettier: options.prettier, resetMocks: options.resetMocks, resetModules: options.resetModules, resolver: options.resolver, diff --git a/packages/jest-config/src/normalize.js b/packages/jest-config/src/normalize.js index 56147c042584..769874db3c48 100644 --- a/packages/jest-config/src/normalize.js +++ b/packages/jest-config/src/normalize.js @@ -372,12 +372,11 @@ export default function normalize(options: InitialOptions, argv: Argv) { options = (options: InitialOptions); if (options.resolver) { - newOptions.resolver = resolve( - null, - options.rootDir, - 'resolver', - options.resolver, - ); + newOptions.resolver = resolve(null, { + filePath: options.resolver, + key: 'resolver', + rootDir: options.rootDir, + }); } Object.keys(options).reduce((newOptions, key) => { @@ -394,8 +393,12 @@ export default function normalize(options: InitialOptions, argv: Argv) { case 'snapshotSerializers': value = options[key] && - options[key].map( - resolve.bind(null, newOptions.resolver, options.rootDir, key), + options[key].map(filePath => + resolve(newOptions.resolver, { + filePath, + key, + rootDir: options.rootDir, + }), ); break; case 'modulePaths': @@ -428,9 +431,27 @@ export default function normalize(options: InitialOptions, argv: Argv) { case 'setupTestFrameworkScriptFile': case 'testResultsProcessor': case 'testRunner': + case 'filter': value = options[key] && - resolve(newOptions.resolver, options.rootDir, key, options[key]); + resolve(newOptions.resolver, { + filePath: options[key], + key, + rootDir: options.rootDir, + }); + break; + case 'prettier': + // We only want this to throw if "prettier" is explicitly passed from + // config or CLI, and the requested path isn't found. Otherwise we set + // it to null and throw an error lazily when it is used. + value = + options[key] && + resolve(newOptions.resolver, { + filePath: options[key], + key, + optional: options[key] === DEFAULT_CONFIG[key], + rootDir: options.rootDir, + }); break; case 'moduleNameMapper': const moduleNameMapper = options[key]; @@ -447,12 +468,11 @@ export default function normalize(options: InitialOptions, argv: Argv) { transform && Object.keys(transform).map(regex => [ regex, - resolve( - newOptions.resolver, - options.rootDir, + resolve(newOptions.resolver, { + filePath: transform[regex], key, - transform[regex], - ), + rootDir: options.rootDir, + }), ]); break; case 'coveragePathIgnorePatterns': @@ -466,12 +486,14 @@ export default function normalize(options: InitialOptions, argv: Argv) { case 'haste': value = Object.assign({}, options[key]); if (value.hasteImplModulePath != null) { - value.hasteImplModulePath = resolve( - newOptions.resolver, - options.rootDir, - 'haste.hasteImplModulePath', - replaceRootDirInPath(options.rootDir, value.hasteImplModulePath), - ); + value.hasteImplModulePath = resolve(newOptions.resolver, { + filePath: replaceRootDirInPath( + options.rootDir, + value.hasteImplModulePath, + ), + key: 'haste.hasteImplModulePath', + rootDir: options.rootDir, + }); } break; case 'projects': @@ -501,11 +523,6 @@ export default function normalize(options: InitialOptions, argv: Argv) { case 'testRegex': value = options[key] && replacePathSepForRegex(options[key]); break; - case 'filter': - value = - options[key] && - resolve(newOptions.resolver, options.rootDir, key, options[key]); - break; case 'automock': case 'bail': case 'browser': @@ -563,7 +580,11 @@ export default function normalize(options: InitialOptions, argv: Argv) { break; case 'watchPlugins': value = (options[key] || []).map(watchPlugin => - resolve(newOptions.resolver, options.rootDir, key, watchPlugin), + resolve(newOptions.resolver, { + filePath: watchPlugin, + key, + rootDir: options.rootDir, + }), ); break; } diff --git a/packages/jest-config/src/utils.js b/packages/jest-config/src/utils.js index 1e1118dcb722..e36eb47d4686 100644 --- a/packages/jest-config/src/utils.js +++ b/packages/jest-config/src/utils.js @@ -13,6 +13,14 @@ import path from 'path'; import {ValidationError} from 'jest-validate'; import Resolver from 'jest-resolve'; import chalk from 'chalk'; + +type ResolveOptions = {| + rootDir: string, + key: string, + filePath: Path, + optional?: boolean, +|}; + export const BULLET: string = chalk.bold('\u25cf '); export const DOCUMENTATION_NOTE = ` ${chalk.bold( 'Configuration Documentation:', @@ -25,9 +33,7 @@ const createValidationError = (message: string) => export const resolve = ( resolver: ?string, - rootDir: string, - key: string, - filePath: Path, + {key, filePath, rootDir, optional}: ResolveOptions, ) => { const module = Resolver.findNodeModule( replaceRootDirInPath(rootDir, filePath), @@ -37,7 +43,7 @@ export const resolve = ( }, ); - if (!module) { + if (!module && !optional) { throw createValidationError( ` Module ${chalk.bold(filePath)} in the ${chalk.bold( key, diff --git a/packages/jest-config/src/valid_config.js b/packages/jest-config/src/valid_config.js index 1c5372c05b0a..00c4774b2723 100644 --- a/packages/jest-config/src/valid_config.js +++ b/packages/jest-config/src/valid_config.js @@ -65,6 +65,7 @@ export default ({ notifyMode: 'always', onlyChanged: false, preset: 'react-native', + prettier: '/node_modules/prettier', projects: ['project-a', 'project-b/'], reporters: [ 'default', diff --git a/packages/jest-jasmine2/src/jest_expect.js b/packages/jest-jasmine2/src/jest_expect.js index 0f3dceb3c18c..46ef61026e4c 100644 --- a/packages/jest-jasmine2/src/jest_expect.js +++ b/packages/jest-jasmine2/src/jest_expect.js @@ -13,7 +13,9 @@ import expect from 'expect'; import { addSerializer, toMatchSnapshot, + toMatchInlineSnapshot, toThrowErrorMatchingSnapshot, + toThrowErrorMatchingInlineSnapshot, } from 'jest-snapshot'; type JasmineMatcher = { @@ -27,7 +29,9 @@ export default (config: {expand: boolean}) => { global.expect = expect; expect.setState({expand: config.expand}); expect.extend({ + toMatchInlineSnapshot, toMatchSnapshot, + toThrowErrorMatchingInlineSnapshot, toThrowErrorMatchingSnapshot, }); (expect: Object).addSnapshotSerializer = addSerializer; diff --git a/packages/jest-jasmine2/src/setup_jest_globals.js b/packages/jest-jasmine2/src/setup_jest_globals.js index 91722c0e3518..1cd545338ade 100644 --- a/packages/jest-jasmine2/src/setup_jest_globals.js +++ b/packages/jest-jasmine2/src/setup_jest_globals.js @@ -98,7 +98,11 @@ export default ({ }); patchJasmine(); const {expand, updateSnapshot} = globalConfig; - const snapshotState = new SnapshotState(testPath, {expand, updateSnapshot}); + const snapshotState = new SnapshotState(testPath, { + expand, + getPrettier: () => (config.prettier ? localRequire(config.prettier) : null), + updateSnapshot, + }); setState({snapshotState, testPath}); // Return it back to the outer scope (test runner outside the VM). return snapshotState; diff --git a/packages/jest-message-util/src/index.js b/packages/jest-message-util/src/index.js index 16d46a24902d..67bd810f795d 100644 --- a/packages/jest-message-util/src/index.js +++ b/packages/jest-message-util/src/index.js @@ -153,7 +153,10 @@ export const formatExecError = ( return TITLE_INDENT + TITLE_BULLET + messageToUse + stack + '\n'; }; -const removeInternalStackEntries = (lines, options: StackTraceOptions) => { +const removeInternalStackEntries = ( + lines: string[], + options: StackTraceOptions, +): string[] => { let pathCounter = 0; return lines.filter(line => { @@ -221,7 +224,12 @@ const formatPaths = (config: StackTraceConfig, relativeTestPath, line) => { return STACK_TRACE_COLOR(match[1]) + filePath + STACK_TRACE_COLOR(match[3]); }; -const getTopFrame = (lines: string[]) => { +export const getStackTraceLines = ( + stack: string, + options: StackTraceOptions = {noStackTrace: false}, +) => removeInternalStackEntries(stack.split(/\n/), options); + +export const getTopFrame = (lines: string[]) => { for (const line of lines) { if (line.includes(PATH_NODE_MODULES) || line.includes(PATH_JEST_PACKAGES)) { continue; @@ -243,14 +251,12 @@ export const formatStackTrace = ( options: StackTraceOptions, testPath: ?Path, ) => { - let lines = stack.split(/\n/); + const lines = getStackTraceLines(stack, options); + const topFrame = getTopFrame(lines); let renderedCallsite = ''; const relativeTestPath = testPath ? slash(path.relative(config.rootDir, testPath)) : null; - lines = removeInternalStackEntries(lines, options); - - const topFrame = getTopFrame(lines); if (topFrame) { const filename = topFrame.file; diff --git a/packages/jest-snapshot/package.json b/packages/jest-snapshot/package.json index 86361d77efa3..0bf947bac739 100644 --- a/packages/jest-snapshot/package.json +++ b/packages/jest-snapshot/package.json @@ -8,11 +8,19 @@ "license": "MIT", "main": "build/index.js", "dependencies": { + "babel-traverse": "^6.0.0", + "babel-types": "^6.0.0", "chalk": "^2.0.1", "jest-diff": "^23.2.0", "jest-matcher-utils": "^23.2.0", + "jest-message-util": "^23.2.0", + "jest-resolve": "^23.2.0", "mkdirp": "^0.5.1", "natural-compare": "^1.4.0", - "pretty-format": "^23.2.0" + "pretty-format": "^23.2.0", + "semver": "^5.5.0" + }, + "devDependencies": { + "prettier": "^1.13.4" } } diff --git a/packages/jest-snapshot/src/State.js b/packages/jest-snapshot/src/State.js index aaacad4d32f2..d93a35b47dcb 100644 --- a/packages/jest-snapshot/src/State.js +++ b/packages/jest-snapshot/src/State.js @@ -10,6 +10,7 @@ import type {Path, SnapshotUpdateState} from 'types/Config'; import fs from 'fs'; +import {getTopFrame, getStackTraceLines} from 'jest-message-util'; import { saveSnapshotFile, getSnapshotData, @@ -19,13 +20,23 @@ import { testNameToKey, unescape, } from './utils'; +import {saveInlineSnapshots, type InlineSnapshot} from './inline_snapshots'; export type SnapshotStateOptions = {| updateSnapshot: SnapshotUpdateState, + getPrettier: () => null | any, snapshotPath?: string, expand?: boolean, |}; +export type SnapshotMatchOptions = {| + testName: string, + received: any, + key?: string, + inlineSnapshot?: string, + error?: Error, +|}; + export default class SnapshotState { _counters: Map; _dirty: boolean; @@ -33,7 +44,9 @@ export default class SnapshotState { _updateSnapshot: SnapshotUpdateState; _snapshotData: {[key: string]: string}; _snapshotPath: Path; + _inlineSnapshots: Array; _uncheckedKeys: Set; + _getPrettier: () => null | any; added: number; expand: boolean; matched: number; @@ -48,6 +61,8 @@ export default class SnapshotState { ); this._snapshotData = data; this._dirty = dirty; + this._getPrettier = options.getPrettier; + this._inlineSnapshots = []; this._uncheckedKeys = new Set(Object.keys(this._snapshotData)); this._counters = new Map(); this._index = 0; @@ -67,22 +82,48 @@ export default class SnapshotState { }); } - _addSnapshot(key: string, receivedSerialized: string) { + _addSnapshot( + key: string, + receivedSerialized: string, + options: {isInline: boolean, error?: Error}, + ) { this._dirty = true; - this._snapshotData[key] = receivedSerialized; + if (options.isInline) { + const error = options.error || new Error(); + const lines = getStackTraceLines(error.stack); + const frame = getTopFrame(lines); + if (!frame) { + throw new Error("Jest: Couln't infer stack frame for inline snapshot."); + } + this._inlineSnapshots.push({ + frame, + snapshot: receivedSerialized, + }); + } else { + this._snapshotData[key] = receivedSerialized; + } } save() { - const isEmpty = Object.keys(this._snapshotData).length === 0; + const hasExternalSnapshots = Object.keys(this._snapshotData).length; + const hasInlineSnapshots = this._inlineSnapshots.length; + const isEmpty = !hasExternalSnapshots && !hasInlineSnapshots; + const status = { deleted: false, saved: false, }; if ((this._dirty || this._uncheckedKeys.size) && !isEmpty) { - saveSnapshotFile(this._snapshotData, this._snapshotPath); + if (hasExternalSnapshots) { + saveSnapshotFile(this._snapshotData, this._snapshotPath); + } + if (hasInlineSnapshots) { + const prettier = this._getPrettier(); // Load lazily + saveInlineSnapshots(this._inlineSnapshots, prettier); + } status.saved = true; - } else if (isEmpty && fs.existsSync(this._snapshotPath)) { + } else if (!hasExternalSnapshots && fs.existsSync(this._snapshotPath)) { if (this._updateSnapshot === 'all') { fs.unlinkSync(this._snapshotPath); } @@ -108,9 +149,16 @@ export default class SnapshotState { } } - match(testName: string, received: any, key?: string) { + match({ + testName, + received, + key, + inlineSnapshot, + error, + }: SnapshotMatchOptions) { this._counters.set(testName, (this._counters.get(testName) || 0) + 1); const count = Number(this._counters.get(testName)); + const isInline = inlineSnapshot !== undefined; if (!key) { key = testNameToKey(testName, count); @@ -119,11 +167,14 @@ export default class SnapshotState { this._uncheckedKeys.delete(key); const receivedSerialized = serialize(received); - const expected = this._snapshotData[key]; + const expected = isInline ? inlineSnapshot : this._snapshotData[key]; const pass = expected === receivedSerialized; - const hasSnapshot = this._snapshotData[key] !== undefined; + const hasSnapshot = isInline + ? inlineSnapshot !== '' + : this._snapshotData[key] !== undefined; + const snapshotIsPersisted = isInline || fs.existsSync(this._snapshotPath); - if (pass) { + if (pass && !isInline) { // Executing a snapshot file as JavaScript and writing the strings back // when other snapshots have changed loses the proper escaping for some // characters. Since we check every snapshot in every test, use the newly @@ -142,7 +193,7 @@ export default class SnapshotState { // * There's no snapshot file or a file without this snapshot on a CI environment. if ( (hasSnapshot && this._updateSnapshot === 'all') || - ((!hasSnapshot || !fs.existsSync(this._snapshotPath)) && + ((!hasSnapshot || !snapshotIsPersisted) && (this._updateSnapshot === 'new' || this._updateSnapshot === 'all')) ) { if (this._updateSnapshot === 'all') { @@ -152,12 +203,12 @@ export default class SnapshotState { } else { this.added++; } - this._addSnapshot(key, receivedSerialized); + this._addSnapshot(key, receivedSerialized, {error, isInline}); } else { this.matched++; } } else { - this._addSnapshot(key, receivedSerialized); + this._addSnapshot(key, receivedSerialized, {error, isInline}); this.added++; } diff --git a/packages/jest-snapshot/src/__mocks__/prettier.js b/packages/jest-snapshot/src/__mocks__/prettier.js new file mode 100644 index 000000000000..5c553328aa7f --- /dev/null +++ b/packages/jest-snapshot/src/__mocks__/prettier.js @@ -0,0 +1,19 @@ +const prettier = require.requireActual('prettier'); + +module.exports = { + format: (text, opts) => + prettier.format( + text, + Object.assign( + { + pluginSearchDirs: [ + require('path').dirname(require.resolve('prettier')), + ], + }, + opts, + ), + ), + getFileInfo: {sync: () => ({inferredParser: 'babylon'})}, + resolveConfig: {sync: jest.fn()}, + version: prettier.version, +}; diff --git a/packages/jest-snapshot/src/__tests__/inline_snapshots.test.js b/packages/jest-snapshot/src/__tests__/inline_snapshots.test.js new file mode 100644 index 000000000000..772365774cb5 --- /dev/null +++ b/packages/jest-snapshot/src/__tests__/inline_snapshots.test.js @@ -0,0 +1,155 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +jest.mock('fs'); +jest.mock('prettier'); + +const fs = require('fs'); +const path = require('path'); +const prettier = require('prettier'); + +const {saveInlineSnapshots} = require('../inline_snapshots'); + +const writeFileSync = fs.writeFileSync; +const readFileSync = fs.readFileSync; +const existsSync = fs.existsSync; +const statSync = fs.statSync; +const readdirSync = fs.readdirSync; +beforeEach(() => { + fs.writeFileSync = jest.fn(); + fs.readFileSync = jest.fn(); + fs.existsSync = jest.fn(() => true); + fs.statSync = jest.fn(filePath => ({ + isDirectory: () => !filePath.endsWith('.js'), + })); + fs.readdirSync = jest.fn(() => []); + + prettier.resolveConfig.sync.mockReset(); +}); +afterEach(() => { + fs.writeFileSync = writeFileSync; + fs.readFileSync = readFileSync; + fs.existsSync = existsSync; + fs.statSync = statSync; + fs.readdirSync = readdirSync; +}); + +test('saveInlineSnapshots() replaces empty function call with a template literal', () => { + const filename = path.join(__dirname, 'my.test.js'); + fs.readFileSync = jest.fn(() => `expect(1).toMatchInlineSnapshot();\n`); + + saveInlineSnapshots( + [ + { + frame: {column: 11, file: filename, line: 1}, + snapshot: `1`, + }, + ], + prettier, + ); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + filename, + 'expect(1).toMatchInlineSnapshot(`1`);\n', + ); +}); + +test.each([['babylon'], ['flow'], ['typescript']])( + 'saveInlineSnapshots() replaces existing template literal - %s parser', + parser => { + const filename = path.join(__dirname, 'my.test.js'); + fs.readFileSync = jest.fn(() => 'expect(1).toMatchInlineSnapshot(`2`);\n'); + + prettier.resolveConfig.sync.mockReturnValue({parser}); + + saveInlineSnapshots( + [ + { + frame: {column: 11, file: filename, line: 1}, + snapshot: `1`, + }, + ], + prettier, + ); + + expect(prettier.resolveConfig.sync.mock.results[0].value).toEqual({parser}); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + filename, + 'expect(1).toMatchInlineSnapshot(`1`);\n', + ); + }, +); + +test('saveInlineSnapshots() replaces existing template literal with property matchers', () => { + const filename = path.join(__dirname, 'my.test.js'); + fs.readFileSync = jest.fn( + () => 'expect(1).toMatchInlineSnapshot({}, `2`);\n', + ); + + saveInlineSnapshots( + [ + { + frame: {column: 11, file: filename, line: 1}, + snapshot: `1`, + }, + ], + prettier, + ); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + filename, + 'expect(1).toMatchInlineSnapshot({}, `1`);\n', + ); +}); + +test('saveInlineSnapshots() throws if frame does not match', () => { + const filename = path.join(__dirname, 'my.test.js'); + fs.readFileSync = jest.fn(() => 'expect(1).toMatchInlineSnapshot();\n'); + + const save = () => + saveInlineSnapshots( + [ + { + frame: {column: 2 /* incorrect */, file: filename, line: 1}, + snapshot: `1`, + }, + ], + prettier, + ); + + expect(save).toThrowError(/Couldn't locate all inline snapshots./); +}); + +test('saveInlineSnapshots() throws if multiple calls to to the same location', () => { + const filename = path.join(__dirname, 'my.test.js'); + fs.readFileSync = jest.fn(() => 'expect(1).toMatchInlineSnapshot();\n'); + + const frame = {column: 11, file: filename, line: 1}; + const save = () => + saveInlineSnapshots( + [{frame, snapshot: `1`}, {frame, snapshot: `2`}], + prettier, + ); + + expect(save).toThrowError( + /Multiple inline snapshots for the same call are not supported./, + ); +}); + +test('saveInlineSnapshots() uses escaped backticks', () => { + const filename = path.join(__dirname, 'my.test.js'); + fs.readFileSync = jest.fn(() => 'expect("`").toMatchInlineSnapshot();\n'); + + const frame = {column: 13, file: filename, line: 1}; + saveInlineSnapshots([{frame, snapshot: '`'}], prettier); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + filename, + 'expect("`").toMatchInlineSnapshot(`\\``);\n', + ); +}); diff --git a/packages/jest-snapshot/src/__tests__/throw_matcher.test.js b/packages/jest-snapshot/src/__tests__/throw_matcher.test.js index 9a23c6b1a061..83f268bf2bb5 100644 --- a/packages/jest-snapshot/src/__tests__/throw_matcher.test.js +++ b/packages/jest-snapshot/src/__tests__/throw_matcher.test.js @@ -27,7 +27,9 @@ it('throw matcher can take func', () => { throw new Error('coconut'); }); - expect(matchFn).toHaveBeenCalledWith('', 'coconut'); + expect(matchFn).toHaveBeenCalledWith( + expect.objectContaining({received: 'coconut', testName: ''}), + ); }); describe('throw matcher from promise', () => { @@ -42,7 +44,9 @@ describe('throw matcher from promise', () => { it('can take error', () => { throwMatcher(new Error('coconut'), 'testName', true); - expect(matchFn).toHaveBeenCalledWith('', 'coconut'); + expect(matchFn).toHaveBeenCalledWith( + expect.objectContaining({received: 'coconut', testName: ''}), + ); }); it('can take custom error', () => { @@ -50,6 +54,8 @@ describe('throw matcher from promise', () => { throwMatcher(new CustomError('coconut'), 'testName', true); - expect(matchFn).toHaveBeenCalledWith('', 'coconut'); + expect(matchFn).toHaveBeenCalledWith( + expect.objectContaining({received: 'coconut', testName: ''}), + ); }); }); diff --git a/packages/jest-snapshot/src/index.js b/packages/jest-snapshot/src/index.js index 985f9abe785d..27af60fd85df 100644 --- a/packages/jest-snapshot/src/index.js +++ b/packages/jest-snapshot/src/index.js @@ -53,13 +53,59 @@ const toMatchSnapshot = function( propertyMatchers?: any, testName?: string, ) { - this.dontThrow && this.dontThrow(); + return _toMatchSnapshot({ + context: this, + propertyMatchers, + received, + testName, + }); +}; + +const toMatchInlineSnapshot = function( + received: any, + propertyMatchersOrInlineSnapshot?: any, + inlineSnapshot?: string, +) { + let propertyMatchers; + if (typeof propertyMatchersOrInlineSnapshot === 'string') { + inlineSnapshot = propertyMatchersOrInlineSnapshot; + } else { + propertyMatchers = propertyMatchersOrInlineSnapshot; + } + return _toMatchSnapshot({ + context: this, + inlineSnapshot: inlineSnapshot || '', + propertyMatchers, + received, + }); +}; + +const _toMatchSnapshot = ({ + context, + received, + propertyMatchers, + testName, + inlineSnapshot, +}: { + context: MatcherState & {dontThrow?: () => any}, + received: any, + propertyMatchers?: any, + testName?: string, + inlineSnapshot?: string, +}) => { + context.dontThrow && context.dontThrow(); testName = typeof propertyMatchers === 'string' ? propertyMatchers : testName; - const {currentTestName, isNot, snapshotState}: MatcherState = this; + const {currentTestName, isNot, snapshotState} = context; if (isNot) { - throw new Error('Jest: `.not` cannot be used with `.toMatchSnapshot()`.'); + const matcherName = + typeof inlineSnapshot === 'string' + ? 'toMatchInlineSnapshot' + : 'toMatchSnapshot'; + throw new Error( + `Jest: \`.not\` cannot be used with \`.${matcherName}()\`.`, + ); } if (!snapshotState) { @@ -72,9 +118,9 @@ const toMatchSnapshot = function( : currentTestName || ''; if (typeof propertyMatchers === 'object') { - const propertyPass = this.equals(received, propertyMatchers, [ - this.utils.iterableEquality, - this.utils.subsetEquality, + const propertyPass = context.equals(received, propertyMatchers, [ + context.utils.iterableEquality, + context.utils.subsetEquality, ]); if (!propertyPass) { @@ -84,9 +130,9 @@ const toMatchSnapshot = function( `${RECEIVED_COLOR('Received value')} does not match ` + `${EXPECTED_COLOR(`snapshot properties for "${key}"`)}.\n\n` + `Expected snapshot to match properties:\n` + - ` ${this.utils.printExpected(propertyMatchers)}` + + ` ${context.utils.printExpected(propertyMatchers)}` + `\nReceived:\n` + - ` ${this.utils.printReceived(received)}`; + ` ${context.utils.printReceived(received)}`; return { message: () => @@ -102,7 +148,12 @@ const toMatchSnapshot = function( } } - const result = snapshotState.match(fullTestName, received); + const result = snapshotState.match({ + error: context.error, + inlineSnapshot, + received, + testName: fullTestName, + }); const {pass} = result; let {actual, expected} = result; @@ -153,13 +204,50 @@ const toThrowErrorMatchingSnapshot = function( testName?: string, fromPromise: boolean, ) { - this.dontThrow && this.dontThrow(); + return _toThrowErrorMatchingSnapshot({ + context: this, + fromPromise, + received, + testName, + }); +}; + +const toThrowErrorMatchingInlineSnapshot = function( + received: any, + inlineSnapshot?: string, + fromPromise?: boolean, +) { + return _toThrowErrorMatchingSnapshot({ + context: this, + fromPromise, + inlineSnapshot: inlineSnapshot || '', + received, + }); +}; - const {isNot} = this; +const _toThrowErrorMatchingSnapshot = ({ + context, + received, + testName, + fromPromise, + inlineSnapshot, +}: { + context: MatcherState & {dontThrow?: () => any}, + received: any, + testName?: string, + fromPromise?: boolean, + inlineSnapshot?: string, +}) => { + context.dontThrow && context.dontThrow(); + const {isNot} = context; + const matcherName = + typeof inlineSnapshot === 'string' + ? 'toThrowErrorMatchingInlineSnapshot' + : 'toThrowErrorMatchingSnapshot'; if (isNot) { throw new Error( - 'Jest: `.not` cannot be used with `.toThrowErrorMatchingSnapshot()`.', + `Jest: \`.not\` cannot be used with \`.${matcherName}()\`.`, ); } @@ -177,14 +265,19 @@ const toThrowErrorMatchingSnapshot = function( if (error === undefined) { throw new Error( - matcherHint('.toThrowErrorMatchingSnapshot', '() => {}', '') + + matcherHint(`.${matcherName}`, '() => {}', '') + '\n\n' + `Expected the function to throw an error.\n` + `But it didn't throw anything.`, ); } - return toMatchSnapshot.call(this, error.message, testName); + return _toMatchSnapshot({ + context, + inlineSnapshot, + received: error.message, + testName, + }); }; module.exports = { @@ -193,7 +286,9 @@ module.exports = { addSerializer, cleanup, getSerializers, + toMatchInlineSnapshot, toMatchSnapshot, + toThrowErrorMatchingInlineSnapshot, toThrowErrorMatchingSnapshot, utils, }; diff --git a/packages/jest-snapshot/src/inline_snapshots.js b/packages/jest-snapshot/src/inline_snapshots.js new file mode 100644 index 000000000000..388225cc4d9b --- /dev/null +++ b/packages/jest-snapshot/src/inline_snapshots.js @@ -0,0 +1,174 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import fs from 'fs'; +import semver from 'semver'; +import path from 'path'; +import traverse from 'babel-traverse'; +import {templateElement, templateLiteral, file} from 'babel-types'; + +import type {Path} from 'types/Config'; +import {escapeBacktickString} from './utils'; + +export type InlineSnapshot = {| + snapshot: string, + frame: {line: number, column: number, file: string}, +|}; + +export const saveInlineSnapshots = ( + snapshots: InlineSnapshot[], + prettier: any, +) => { + if (!prettier) { + throw new Error( + `Jest: Inline Snapshots requires Prettier.\n` + + `Please ensure "prettier" is installed in your project.`, + ); + } + + // Custom parser API was added in 1.5.0 + if (semver.lt(prettier.version, '1.5.0')) { + throw new Error( + `Jest: Inline Snapshots require prettier>=1.5.0.\n` + + `Please upgrade "prettier".`, + ); + } + + const snapshotsByFile = groupSnapshotsByFile(snapshots); + + for (const sourceFilePath of Object.keys(snapshotsByFile)) { + saveSnapshotsForFile( + snapshotsByFile[sourceFilePath], + sourceFilePath, + prettier, + ); + } +}; + +const saveSnapshotsForFile = ( + snapshots: Array, + sourceFilePath: Path, + prettier: any, +) => { + const sourceFile = fs.readFileSync(sourceFilePath, 'utf8'); + + // Resolve project configuration. + // For older versions of Prettier, do not load configuration. + const config = prettier.resolveConfig + ? prettier.resolveConfig.sync(sourceFilePath, { + editorconfig: true, + }) + : null; + + // Detect the parser for the test file. + // For older versions of Prettier, fallback to a simple parser detection. + const inferredParser = prettier.getFileInfo + ? prettier.getFileInfo.sync(sourceFilePath).inferredParser + : (config && config.parser) || simpleDetectParser(sourceFilePath); + + // Format the source code using the custom parser API. + const newSourceFile = prettier.format( + sourceFile, + Object.assign({}, config, { + filepath: sourceFilePath, + parser: createParser(snapshots, inferredParser), + }), + ); + + if (newSourceFile !== sourceFile) { + fs.writeFileSync(sourceFilePath, newSourceFile); + } +}; + +const groupSnapshotsBy = (createKey: InlineSnapshot => string) => ( + snapshots: Array, +) => + snapshots.reduce((object, inlineSnapshot) => { + const key = createKey(inlineSnapshot); + return Object.assign(object, { + [key]: (object[key] || []).concat(inlineSnapshot), + }); + }, {}); + +const groupSnapshotsByFrame = groupSnapshotsBy( + ({frame: {line, column}}) => `${line}:${column - 1}`, +); +const groupSnapshotsByFile = groupSnapshotsBy(({frame: {file}}) => file); + +const createParser = (snapshots: InlineSnapshot[], inferredParser: string) => ( + text: string, + parsers: {[key: string]: (string) => any}, + options: any, +) => { + // Workaround for https://github.com/prettier/prettier/issues/3150 + options.parser = inferredParser; + + const groupedSnapshots = groupSnapshotsByFrame(snapshots); + const remainingSnapshots = new Set(snapshots.map(({snapshot}) => snapshot)); + let ast = parsers[inferredParser](text); + + // Flow uses a 'Program' parent node, babel expects a 'File'. + if (ast.type !== 'File') { + ast = file(ast, ast.comments, ast.tokens); + delete ast.program.comments; + } + + traverse(ast, { + CallExpression({node: {arguments: args, callee}}) { + if ( + callee.type !== 'MemberExpression' || + callee.property.type !== 'Identifier' + ) { + return; + } + const {line, column} = callee.property.loc.start; + const snapshotsForFrame = groupedSnapshots[`${line}:${column}`]; + if (!snapshotsForFrame) { + return; + } + if (snapshotsForFrame.length > 1) { + throw new Error( + 'Jest: Multiple inline snapshots for the same call are not supported.', + ); + } + const snapshotIndex = args.findIndex( + ({type}) => type === 'TemplateLiteral', + ); + const values = snapshotsForFrame.map(({snapshot}) => { + remainingSnapshots.delete(snapshot); + + return templateLiteral( + [templateElement({raw: escapeBacktickString(snapshot)})], + [], + ); + }); + const replacementNode = values[0]; + + if (snapshotIndex > -1) { + args[snapshotIndex] = replacementNode; + } else { + args.push(replacementNode); + } + }, + }); + + if (remainingSnapshots.size) { + throw new Error(`Jest: Couldn't locate all inline snapshots.`); + } + + return ast; +}; + +const simpleDetectParser = (filePath: Path) => { + const extname = path.extname(filePath); + if (/tsx?$/.test(extname)) { + return 'typescript'; + } + return 'babylon'; +}; diff --git a/packages/jest-snapshot/src/utils.js b/packages/jest-snapshot/src/utils.js index 1f35957100f7..9da1b83ce3f9 100644 --- a/packages/jest-snapshot/src/utils.js +++ b/packages/jest-snapshot/src/utils.js @@ -145,8 +145,11 @@ export const serialize = (data: any): string => // unescape double quotes export const unescape = (data: any): string => data.replace(/\\(")/g, '$1'); +export const escapeBacktickString = (str: string) => + str.replace(/`|\\|\${/g, '\\$&'); + const printBacktickString = (str: string) => - '`' + str.replace(/`|\\|\${/g, '\\$&') + '`'; + '`' + escapeBacktickString(str) + '`'; export const ensureDirectoryExists = (filePath: Path) => { try { diff --git a/packages/jest-validate/src/__tests__/fixtures/jest_config.js b/packages/jest-validate/src/__tests__/fixtures/jest_config.js index 263ec29aa871..705c526264bd 100644 --- a/packages/jest-validate/src/__tests__/fixtures/jest_config.js +++ b/packages/jest-validate/src/__tests__/fixtures/jest_config.js @@ -41,6 +41,7 @@ const defaultConfig = { notify: false, notifyMode: 'always', preset: null, + prettier: 'prettier', resetMocks: false, resetModules: false, restoreMocks: false, @@ -99,6 +100,7 @@ const validConfig = { notify: false, notifyMode: 'always', preset: 'react-native', + prettier: '/node_modules/prettier', resetMocks: false, resetModules: false, restoreMocks: false, diff --git a/types/Config.js b/types/Config.js index db18796f5ecc..29c1bf891188 100644 --- a/types/Config.js +++ b/types/Config.js @@ -53,6 +53,7 @@ export type DefaultOptions = {| notify: boolean, notifyMode: string, preset: ?string, + prettier: ?string, projects: ?Array, resetMocks: boolean, resetModules: boolean, @@ -135,6 +136,7 @@ export type InitialOptions = { passWithNoTests?: boolean, preprocessorIgnorePatterns?: Array, preset?: ?string, + prettier?: ?string, projects?: Array, replname?: ?string, resetMocks?: boolean, @@ -259,6 +261,7 @@ export type ProjectConfig = {| modulePathIgnorePatterns: Array, modulePaths: Array, name: string, + prettier: string, resetMocks: boolean, resetModules: boolean, resolver: ?Path, diff --git a/types/Matchers.js b/types/Matchers.js index 68a8b7ed28c5..70be4f1a50ab 100644 --- a/types/Matchers.js +++ b/types/Matchers.js @@ -30,7 +30,8 @@ export type PromiseMatcherFn = (actual: any) => Promise; export type MatcherState = { assertionCalls: number, currentTestName?: string, - equals: (any, any) => boolean, + error?: Error, + equals: (any, any, ?Array) => boolean, expand?: boolean, expectedAssertionsNumber: ?number, isExpectingAssertions: ?boolean, diff --git a/yarn.lock b/yarn.lock index 95f73acf3df8..d3767c42c6b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8415,6 +8415,10 @@ prettier@^1.13.3: version "1.13.4" resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.13.4.tgz#31bbae6990f13b1093187c731766a14036fa72e6" +prettier@^1.13.4: + version "1.13.5" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.13.5.tgz#7ae2076998c8edce79d63834e9b7b09fead6bfd0" + pretty-format@^22.4.0, pretty-format@^22.4.3: version "22.4.3" resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-22.4.3.tgz#f873d780839a9c02e9664c8a082e9ee79eaac16f"