From ab13484acbefb6db9dd93cc2d6f65a9922006b5b Mon Sep 17 00:00:00 2001 From: Art Date: Wed, 21 Jun 2023 15:50:13 +0700 Subject: [PATCH] feat: add match named snapshot (#14045) Co-authored-by: Tom Mrazauskas --- CHANGELOG.md | 3 +- docs/ExpectAPI.md | 16 + docs/SnapshotTesting.md | 6 + ...rowErrorMatchingNamedSnapshot.test.ts.snap | 8 + e2e/__tests__/toMatchNamedSnapshot.test.ts | 379 ++++++++++++++++++ .../toMatchNamedSnapshotWithRetries.test.ts | 102 +++++ e2e/__tests__/toMatchSnapshot.test.ts | 54 ++- .../toThrowErrorMatchingNamedSnapshot.test.ts | 125 ++++++ .../toThrowErrorMatchingSnapshot.test.ts | 4 +- .../package.json | 5 + e2e/to-match-named-snapshot/package.json | 5 + .../package.json | 5 + packages/expect/src/index.ts | 3 +- packages/jest-expect/src/index.ts | 4 + .../__typetests__/matchers.test.ts | 60 +++ .../__snapshots__/printSnapshot.test.ts.snap | 148 +++++++ .../src/__tests__/printSnapshot.test.ts | 337 ++++++++++++++++ .../src/__tests__/throwMatcher.test.ts | 128 ++++-- packages/jest-snapshot/src/index.ts | 161 +++++++- packages/jest-snapshot/src/types.ts | 20 + .../jest-types/__typetests__/expect.test.ts | 38 ++ 21 files changed, 1562 insertions(+), 49 deletions(-) create mode 100644 e2e/__tests__/__snapshots__/toThrowErrorMatchingNamedSnapshot.test.ts.snap create mode 100644 e2e/__tests__/toMatchNamedSnapshot.test.ts create mode 100644 e2e/__tests__/toMatchNamedSnapshotWithRetries.test.ts create mode 100644 e2e/__tests__/toThrowErrorMatchingNamedSnapshot.test.ts create mode 100644 e2e/to-match-named-snapshot-with-retries/package.json create mode 100644 e2e/to-match-named-snapshot/package.json create mode 100644 e2e/to-throw-error-matching-named-snapshot/package.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 281d8a568266..921c44b96219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,9 @@ ### Features - `[jest-cli]` Include type definitions to generated config files ([#14078](https://github.com/facebook/jest/pull/14078)) -- `[jest-snapshot]` Support arrays as property matchers ([#14025](https://github.com/facebook/jest/pull/14025)) - `[jest-core, jest-circus, jest-reporter, jest-runner]` Added support for reporting about start individual test cases using jest-circus ([#14174](https://github.com/jestjs/jest/pull/14174)) +- `[jest-snapshot]` Support arrays as property matchers ([#14025](https://github.com/facebook/jest/pull/14025)) +- `[jest-snapshot]` Add `toMatchNamedSnapshot` to bring snapshot support to concurrent mode ([#14045](https://github.com/facebook/jest/pull/14045)) ### Fixes diff --git a/docs/ExpectAPI.md b/docs/ExpectAPI.md index d140e0dd4340..6acd9b76a4a9 100644 --- a/docs/ExpectAPI.md +++ b/docs/ExpectAPI.md @@ -744,6 +744,16 @@ You can provide an optional `propertyMatchers` object argument, which has asymme You can provide an optional `hint` string argument that is appended to the test name. Although Jest always appends a number at the end of a snapshot name, short descriptive hints might be more useful than numbers to differentiate **multiple** snapshots in a **single** `it` or `test` block. Jest sorts snapshots by name in the corresponding `.snap` file. +### `.toMatchNamedSnapshot(snapshotName, propertyMatchers?)` + +This ensures that a value matches the most recent snapshot. Check out [the Snapshot Testing guide](SnapshotTesting.md) for more information. + +You can provide an optional `propertyMatchers` object argument, which has asymmetric matchers as values of a subset of expected properties, **if** the received value will be an **object** instance. It is like `toMatchObject` with flexible criteria for a subset of properties, followed by a snapshot test as exact criteria for the rest of the properties. + +By setting the `snapshotName` explicitly (as opposed to letting Jest infer the name from context) it can be guaranteed to be consistent across test runs, whatever the context at the time of evaluation. Jest always appends a number at the end of a snapshot name. + +Jest sorts snapshots by name in the corresponding `.snap` file. + ### `.toMatchInlineSnapshot(propertyMatchers?, inlineSnapshot)` Ensures that a value matches the most recent snapshot. @@ -876,6 +886,12 @@ exports[`drinking flavors throws on octopus 1`] = `"yuck, octopus flavor"`; Check out [React Tree Snapshot Testing](/blog/2016/07/27/jest-14) for more information on snapshot testing. +### `.toThrowErrorMatchingNamedSnapshot(snapshotName)` + +Use `.toThrowErrorMatchingNamedSnapshot` to test that a function throws an error matching the most recent snapshot when it is called. + +By setting the `snapshotName` explicitly (as opposed to letting Jest infer the name from context) it can be guaranteed to be consistent across test runs, whatever the context at the time of evaluation. Jest always appends a number at the end of a snapshot name. + ### `.toThrowErrorMatchingInlineSnapshot(inlineSnapshot)` Use `.toThrowErrorMatchingInlineSnapshot` to test that a function throws an error matching the most recent snapshot when it is called. diff --git a/docs/SnapshotTesting.md b/docs/SnapshotTesting.md index 2fcef23b1579..81d3dfc44dce 100644 --- a/docs/SnapshotTesting.md +++ b/docs/SnapshotTesting.md @@ -52,6 +52,12 @@ More information on how snapshot testing works and why we built it can be found ::: +:::tip + +The `toMatchSnapshot` matcher infers the name based on the test function context when the snapshot is evaluated. This can cause snapshots in tests that are run concurrently to have different names on each test run. If you want to guarantee consistent names, you can use the `toMatchNamedSnapshot` matcher. + +::: + ### Updating Snapshots It's straightforward to spot when a snapshot test fails after a bug has been introduced. When that happens, go ahead and fix the issue and make sure your snapshot tests are passing again. Now, let's talk about the case when a snapshot test is failing due to an intentional implementation change. diff --git a/e2e/__tests__/__snapshots__/toThrowErrorMatchingNamedSnapshot.test.ts.snap b/e2e/__tests__/__snapshots__/toThrowErrorMatchingNamedSnapshot.test.ts.snap new file mode 100644 index 000000000000..4f9e826c3398 --- /dev/null +++ b/e2e/__tests__/__snapshots__/toThrowErrorMatchingNamedSnapshot.test.ts.snap @@ -0,0 +1,8 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`support-reject 1`] = ` +"// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[\`support-reject 1\`] = \`"octopus"\`; +" +`; diff --git a/e2e/__tests__/toMatchNamedSnapshot.test.ts b/e2e/__tests__/toMatchNamedSnapshot.test.ts new file mode 100644 index 000000000000..148d5f8f8c91 --- /dev/null +++ b/e2e/__tests__/toMatchNamedSnapshot.test.ts @@ -0,0 +1,379 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as path from 'path'; +import {cleanup, makeTemplate, writeFiles} from '../Utils'; +import runJest from '../runJest'; + +const DIR = path.resolve(__dirname, '../to-match-named-snapshot'); +const TESTS_DIR = path.resolve(DIR, '__tests__'); + +beforeEach(() => cleanup(TESTS_DIR)); +afterAll(() => cleanup(TESTS_DIR)); + +test('basic support', () => { + const filename = 'basic-support.test.js'; + const template = makeTemplate( + "test('named snapshots', () => expect($1).toMatchNamedSnapshot('basic-support'));", + ); + + { + writeFiles(TESTS_DIR, { + [filename]: template(['{apple: "original value"}']), + }); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('1 snapshot written from 1 test suite.'); + expect(exitCode).toBe(0); + } + + { + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('Snapshots: 1 passed, 1 total'); + expect(stderr).not.toMatch('1 snapshot written from 1 test suite.'); + expect(exitCode).toBe(0); + } + + { + writeFiles(TESTS_DIR, { + [filename]: template(['{apple: "updated value"}']), + }); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('Snapshot name: `named snapshots 1`'); + expect(exitCode).toBe(1); + } + + { + const {stderr, exitCode} = runJest(DIR, [ + '-w=1', + '--ci=false', + filename, + '-u', + ]); + expect(stderr).toMatch('1 snapshot updated from 1 test suite.'); + expect(exitCode).toBe(0); + } +}); + +test('error thrown before snapshot', () => { + const filename = 'error-thrown-before-snapshot.test.js'; + const template = makeTemplate(`test('snapshots', () => { + expect($1).toBeTruthy(); + expect($2).toMatchNamedSnapshot('error-thrown-before-snapshot-$3'); + });`); + + { + writeFiles(TESTS_DIR, { + [filename]: template(['true', '{a: "original"}', '1']), + }); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('1 snapshot written from 1 test suite.'); + expect(exitCode).toBe(0); + } + + { + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('Snapshots: 1 passed, 1 total'); + expect(exitCode).toBe(0); + } + + { + writeFiles(TESTS_DIR, { + [filename]: template(['false', '{a: "original"}', '2']), + }); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).not.toMatch('1 obsolete snapshot found'); + expect(exitCode).toBe(1); + } +}); + +test('first snapshot fails, second passes', () => { + const filename = 'first-snapshot-fails-second-passes.test.js'; + const template = makeTemplate(`test('named snapshots', () => { + expect($1).toMatchNamedSnapshot('test-snapshot'); + expect($2).toMatchNamedSnapshot('test-snapshot-2'); + });`); + + { + writeFiles(TESTS_DIR, {[filename]: template(["'apple'", "'banana'", '1'])}); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('2 snapshots written from 1 test suite.'); + expect(exitCode).toBe(0); + } + + { + writeFiles(TESTS_DIR, {[filename]: template(["'kiwi'", "'banana'", '2'])}); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('Snapshot name: `named snapshots 1`'); + // Match lines separately because empty line has been replaced with space: + expect(stderr).toMatch('Snapshot: "apple"'); + expect(stderr).toMatch('Received: "kiwi"'); + expect(stderr).not.toMatch('1 obsolete snapshot found'); + expect(exitCode).toBe(1); + } +}); + +test('does not mark snapshots as obsolete in skipped tests if snapshot name match test name', () => { + const filename = 'no-obsolete-if-skipped.test.js'; + const template = makeTemplate(`test('snapshots', () => { + expect(true).toBe(true); + }); + + $1('will be skipped', () => { + expect({a: 6}).toMatchNamedSnapshot('will be skipped'); + }); + `); + + { + writeFiles(TESTS_DIR, {[filename]: template(['test'])}); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('1 snapshot written from 1 test suite.'); + expect(exitCode).toBe(0); + } + + { + writeFiles(TESTS_DIR, {[filename]: template(['test.skip'])}); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + console.log(stderr); + expect(stderr).not.toMatch('1 obsolete snapshot found'); + expect(exitCode).toBe(0); + } +}); + +test('mark snapshots as obsolete in skipped tests if snapshot name does not match test name', () => { + const filename = 'no-obsolete-if-skipped.test.js'; + const template = makeTemplate(`test('snapshots', () => { + expect(true).toBe(true); + }); + + $1('will be skipped', () => { + expect({a: 6}).toMatchNamedSnapshot('will be obsolete'); + }); + `); + + { + writeFiles(TESTS_DIR, {[filename]: template(['test'])}); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('1 snapshot written from 1 test suite.'); + expect(exitCode).toBe(0); + } + + { + writeFiles(TESTS_DIR, {[filename]: template(['test.skip'])}); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + console.log(stderr); + expect(stderr).toMatch('1 snapshot obsolete'); + expect(exitCode).toBe(1); + } +}); + +test('throws the error if snapshot name is not string', () => { + const filename = 'no-obsolete-if-skipped.test.js'; + const template = makeTemplate(` + test('will be error', () => { + expect({a: 6}).toMatchNamedSnapshot(true); + }); + `); + + { + writeFiles(TESTS_DIR, {[filename]: template(['test.skip'])}); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + console.log(stderr); + expect(stderr).toMatch('Expected snapshotName must be a string'); + expect(exitCode).toBe(1); + } +}); + +test('accepts custom snapshot name', () => { + const filename = 'accept-custom-snapshot-name.test.js'; + const template = makeTemplate(`test('accepts custom snapshot name', () => { + expect(true).toMatchNamedSnapshot('custom-name'); + }); + `); + + { + writeFiles(TESTS_DIR, {[filename]: template()}); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('1 snapshot written from 1 test suite.'); + expect(exitCode).toBe(0); + } +}); + +test('handles property matchers', () => { + const filename = 'handle-property-matchers.test.js'; + const template = makeTemplate(`test('handles property matchers', () => { + expect({createdAt: $1}).toMatchNamedSnapshot('property-matchers',{createdAt: expect.any(Date)}); + }); + `); + + { + writeFiles(TESTS_DIR, {[filename]: template(['new Date()'])}); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('1 snapshot written from 1 test suite.'); + expect(exitCode).toBe(0); + } + + { + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('Snapshots: 1 passed, 1 total'); + expect(exitCode).toBe(0); + } + + { + writeFiles(TESTS_DIR, {[filename]: template(['"string"'])}); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('Snapshot name: `handles property matchers 1`'); + expect(stderr).toMatch('Snapshots: 1 failed, 1 total'); + expect(exitCode).toBe(1); + } +}); + +test('handles invalid property matchers', () => { + const filename = 'handle-invalid-property-matchers.test.js'; + { + writeFiles(TESTS_DIR, { + [filename]: `test('invalid property matchers', () => { + expect({foo: 'bar'}).toMatchNamedSnapshot('null-property-matcher', null); + }); + `, + }); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('Expected properties must be an object'); + expect(exitCode).toBe(1); + } +}); + +test('handles undefined property matchers', () => { + const filename = 'handle-undefined-property-matchers.test.js'; + { + writeFiles(TESTS_DIR, { + [filename]: `test('invalid property matchers', () => { + expect({foo: 'bar'}).toMatchNamedSnapshot('undefined-property-matcher', undefined); + }); + `, + }); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('1 snapshot written'); + expect(exitCode).toBe(0); + } +}); + +test('handles property matchers with deep properties', () => { + const filename = 'handle-property-matchers-with-name.test.js'; + const template = + makeTemplate(`test('handles property matchers with deep properties', () => { + expect({ user: { createdAt: $1, name: $2 }}).toMatchNamedSnapshot('deep-property-matchers', { user: { createdAt: expect.any(Date), name: $2 }}); + }); + `); + + { + writeFiles(TESTS_DIR, { + [filename]: template(['new Date()', '"Jest"']), + }); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('1 snapshot written from 1 test suite.'); + expect(exitCode).toBe(0); + } + + { + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('Snapshots: 1 passed, 1 total'); + expect(exitCode).toBe(0); + } + + { + writeFiles(TESTS_DIR, {[filename]: template(['"string"', '"Jest"'])}); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch( + 'Snapshot name: `handles property matchers with deep properties 1`', + ); + expect(stderr).toMatch('Expected properties'); + expect(stderr).toMatch('Snapshots: 1 failed, 1 total'); + expect(exitCode).toBe(1); + } + + { + writeFiles(TESTS_DIR, { + [filename]: template(['new Date()', '"CHANGED"']), + }); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch( + 'Snapshot name: `handles property matchers with deep properties 1`', + ); + expect(stderr).toMatch('Snapshots: 1 failed, 1 total'); + expect(exitCode).toBe(1); + } +}); + +test('error duplicate snapshot name', () => { + const filename = 'duplicate-match-named-snapshot.test.js'; + const template = makeTemplate( + `test('duplicate named snapshots', () => { + expect($1).toMatchNamedSnapshot('basic-support'); + expect($1).toMatchNamedSnapshot('basic-support'); + }); + `, + ); + { + writeFiles(TESTS_DIR, {[filename]: template(['test'])}); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + console.log(stderr); + + expect(stderr).toMatch( + 'The specific snapshot name was duplicate with the other snapshot.', + ); + expect(stderr).toMatch('Snapshot name: basic-support'); + expect(exitCode).toBe(1); + } +}); + +test('support concurrent testing', () => { + const filename = 'match-snapshot-when-test-concurrent.test.js'; + const template = makeTemplate(`describe('group 1', () => { + $1('concurrent 1', async () => { + expect('concurrent 1-1').toMatchNamedSnapshot('test1'); + $2 + }); + + $1('concurrent 2', async () => { + expect('concurrent 1-2').toMatchNamedSnapshot('test2'); + $2 + }); + }); + + describe('group 2', () => { + $1('concurrent 1', async () => { + expect('concurrent 2-1').toMatchNamedSnapshot('test3'); + $2 + }); + + $1('concurrent 2', async () => { + expect('concurrent 2-2').toMatchNamedSnapshot('test4'); + $2 + }); + }); + `); + { + writeFiles(TESTS_DIR, {[filename]: template(['test'])}); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + console.log(stderr); + + expect(exitCode).toBe(0); + } + + { + writeFiles(TESTS_DIR, { + [filename]: template([ + 'test.concurrent', + 'await new Promise(resolve => setTimeout(resolve, 5000));', + ]), + }); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + console.log(stderr); + + expect(exitCode).toBe(0); + } +}); diff --git a/e2e/__tests__/toMatchNamedSnapshotWithRetries.test.ts b/e2e/__tests__/toMatchNamedSnapshotWithRetries.test.ts new file mode 100644 index 000000000000..957d6a83afb1 --- /dev/null +++ b/e2e/__tests__/toMatchNamedSnapshotWithRetries.test.ts @@ -0,0 +1,102 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as path from 'path'; +import {skipSuiteOnJasmine} from '@jest/test-utils'; +import {cleanup, makeTemplate, writeFiles} from '../Utils'; +import runJest from '../runJest'; + +const DIR = path.resolve(__dirname, '../to-match-named-snapshot-with-retries'); +const TESTS_DIR = path.resolve(DIR, '__tests__'); + +beforeEach(() => cleanup(TESTS_DIR)); +afterAll(() => cleanup(TESTS_DIR)); + +skipSuiteOnJasmine(); + +test('works with a single snapshot', () => { + const filename = 'basic-support.test.js'; + const template = makeTemplate(` + let index = 0; + afterEach(() => { + index += 1; + }); + jest.retryTimes($2); + test('snapshots', () => expect($1).toMatchNamedSnapshot('snapshot-retries')); + `); + + { + writeFiles(TESTS_DIR, { + [filename]: template(['3', '1' /* retries */]), + }); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('1 snapshot written from 1 test suite.'); + expect(exitCode).toBe(0); + } + + { + writeFiles(TESTS_DIR, { + [filename]: template(['index', '2' /* retries */]), + }); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('Received: 2'); + expect(stderr).toMatch('1 snapshot failed from 1 test suite.'); + expect(exitCode).toBe(1); + } + + { + writeFiles(TESTS_DIR, { + [filename]: template(['index', '4' /* retries */]), + }); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('Snapshots: 1 passed, 1 total'); + expect(exitCode).toBe(0); + } +}); + +test('works when multiple tests have snapshots but only one of them failed multiple times', () => { + const filename = 'basic-support.test.js'; + const template = makeTemplate(` + test('passing snapshots', () => expect('foo').toMatchNamedSnapshot('passing snapshots')); + describe('with retries', () => { + let index = 0; + afterEach(() => { + index += 1; + }); + jest.retryTimes($2); + test('snapshots', () => expect($1).toMatchNamedSnapshot('with retries')); + }); + `); + + { + writeFiles(TESTS_DIR, { + [filename]: template(['3', '2' /* retries */]), + }); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('2 snapshots written from 1 test suite.'); + expect(exitCode).toBe(0); + } + + { + writeFiles(TESTS_DIR, { + [filename]: template(['index', '2' /* retries */]), + }); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('Received: 2'); + expect(stderr).toMatch('1 snapshot failed from 1 test suite.'); + expect(exitCode).toBe(1); + } + + { + writeFiles(TESTS_DIR, { + [filename]: template(['index', '4' /* retries */]), + }); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('Snapshots: 1 passed, 1 total'); + expect(exitCode).toBe(0); + } +}); diff --git a/e2e/__tests__/toMatchSnapshot.test.ts b/e2e/__tests__/toMatchSnapshot.test.ts index c31bfaf4d623..dd9715c6c38c 100644 --- a/e2e/__tests__/toMatchSnapshot.test.ts +++ b/e2e/__tests__/toMatchSnapshot.test.ts @@ -142,10 +142,10 @@ test('does not mark snapshots as obsolete in skipped tests', () => { } }); -test('accepts custom snapshot name', () => { +test('accepts snapshot hint', () => { const filename = 'accept-custom-snapshot-name.test.js'; - const template = makeTemplate(`test('accepts custom snapshot name', () => { - expect(true).toMatchSnapshot('custom-name'); + const template = makeTemplate(`test('accepts snapshot hint', () => { + expect(true).toMatchSnapshot('custom-hint'); }); `); @@ -304,3 +304,51 @@ test('handles property matchers with deep properties', () => { expect(exitCode).toBe(1); } }); + +test('does not support concurrent testing', () => { + const filename = 'match-snapshot-when-test-concurrent.test.js'; + const template = makeTemplate(`describe('group 1', () => { + $1('concurrent 1', async () => { + expect('concurrent 1-1').toMatchSnapshot(); + $2 + }); + + $1('concurrent 2', async () => { + expect('concurrent 1-2').toMatchSnapshot(); + $2 + }); + }); + + describe('group 2', () => { + $1('concurrent 1', async () => { + expect('concurrent 2-1').toMatchSnapshot(); + $2 + }); + + $1('concurrent 2', async () => { + expect('concurrent 2-2').toMatchSnapshot(); + $2 + }); + }); + `); + { + writeFiles(TESTS_DIR, {[filename]: template(['test'])}); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + + expect(stderr).toMatch('4 snapshots written'); + expect(exitCode).toBe(0); + } + + { + writeFiles(TESTS_DIR, { + [filename]: template([ + 'test.concurrent', + 'await new Promise(resolve => setTimeout(resolve, 5000));', + ]), + }); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + + expect(stderr).toMatch('snapshots obsolete'); + expect(exitCode).toBe(1); + } +}); diff --git a/e2e/__tests__/toThrowErrorMatchingNamedSnapshot.test.ts b/e2e/__tests__/toThrowErrorMatchingNamedSnapshot.test.ts new file mode 100644 index 000000000000..2fae119c79b8 --- /dev/null +++ b/e2e/__tests__/toThrowErrorMatchingNamedSnapshot.test.ts @@ -0,0 +1,125 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as path from 'path'; +import * as fs from 'graceful-fs'; +import {cleanup, makeTemplate, writeFiles} from '../Utils'; +import runJest from '../runJest'; + +const DIR = path.resolve( + __dirname, + '../to-throw-error-matching-named-snapshot', +); +const TESTS_DIR = path.resolve(DIR, '__tests__'); + +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'); }) + .toThrowErrorMatchingNamedSnapshot('works-fine'); + }); + `); + + { + writeFiles(TESTS_DIR, {[filename]: template()}); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('1 snapshot written from 1 test suite.'); + expect(exitCode).toBe(0); + } +}); + +test("throws the error if tested function didn't throw error", () => { + const filename = 'throws-if-tested-function-did-not-throw.test.js'; + const template = + makeTemplate(`test('throws the error if tested function did not throw error', () => { + expect(() => {}).toThrowErrorMatchingNamedSnapshot('error if did not throw error'); + }); + `); + + { + writeFiles(TESTS_DIR, {[filename]: template()}); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('Received function did not throw'); + expect(exitCode).toBe(1); + } +}); + +test('throws the error if snapshot name is not string', () => { + const filename = 'throws-if-tested-function-did-not-throw.test.js'; + const template = + makeTemplate(`test('throws the error if snapshot name is not string', () => { + expect(() => { throw new Error('apple'); }).toThrowErrorMatchingNamedSnapshot(true); + }); + `); + + { + writeFiles(TESTS_DIR, {[filename]: template()}); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('Expected snapshotName must be a string'); + expect(exitCode).toBe(1); + } +}); + +test('accepts custom snapshot name', () => { + const filename = 'accept-custom-snapshot-name.test.js'; + const template = makeTemplate(`test('accepts custom snapshot name', () => { + expect(() => { throw new Error('apple'); }) + .toThrowErrorMatchingNamedSnapshot('custom-name'); + }); + `); + + { + writeFiles(TESTS_DIR, {[filename]: template()}); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('1 snapshot written from 1 test suite.'); + expect(exitCode).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(() => { throw new Error('apple'); }) + .not + .toThrowErrorMatchingNamedSnapshot('cannot-used-with-not'); + }); + `); + + { + writeFiles(TESTS_DIR, {[filename]: template()}); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + expect(stderr).toMatch('Snapshot matchers cannot be used with not'); + expect(exitCode).toBe(1); + } +}); + +test('should support rejecting promises', () => { + const filename = 'should-support-rejecting-promises.test.js'; + const template = + makeTemplate(`test('should support rejecting promises', () => { + return expect(Promise.reject(new Error('octopus'))).rejects.toThrowErrorMatchingNamedSnapshot('support-reject'); + }); + `); + + { + writeFiles(TESTS_DIR, {[filename]: template()}); + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false', filename]); + + const snapshot = fs.readFileSync( + `${TESTS_DIR}/__snapshots__/${filename}.snap`, + 'utf8', + ); + + expect(stderr).toMatch('1 snapshot written from 1 test suite.'); + expect(snapshot).toMatchNamedSnapshot('support-reject'); + expect(exitCode).toBe(0); + } +}); diff --git a/e2e/__tests__/toThrowErrorMatchingSnapshot.test.ts b/e2e/__tests__/toThrowErrorMatchingSnapshot.test.ts index 0137be8037a5..aa62422e6238 100644 --- a/e2e/__tests__/toThrowErrorMatchingSnapshot.test.ts +++ b/e2e/__tests__/toThrowErrorMatchingSnapshot.test.ts @@ -49,11 +49,11 @@ test("throws the error if tested function didn't throw error", () => { } }); -test('accepts custom snapshot name', () => { +test('accepts custom snapshot hint', () => { const filename = 'accept-custom-snapshot-name.test.js'; const template = makeTemplate(`test('accepts custom snapshot name', () => { expect(() => { throw new Error('apple'); }) - .toThrowErrorMatchingSnapshot('custom-name'); + .toThrowErrorMatchingSnapshot('custom-hint'); }); `); diff --git a/e2e/to-match-named-snapshot-with-retries/package.json b/e2e/to-match-named-snapshot-with-retries/package.json new file mode 100644 index 000000000000..148788b25446 --- /dev/null +++ b/e2e/to-match-named-snapshot-with-retries/package.json @@ -0,0 +1,5 @@ +{ + "jest": { + "testEnvironment": "node" + } +} diff --git a/e2e/to-match-named-snapshot/package.json b/e2e/to-match-named-snapshot/package.json new file mode 100644 index 000000000000..148788b25446 --- /dev/null +++ b/e2e/to-match-named-snapshot/package.json @@ -0,0 +1,5 @@ +{ + "jest": { + "testEnvironment": "node" + } +} diff --git a/e2e/to-throw-error-matching-named-snapshot/package.json b/e2e/to-throw-error-matching-named-snapshot/package.json new file mode 100644 index 000000000000..148788b25446 --- /dev/null +++ b/e2e/to-throw-error-matching-named-snapshot/package.json @@ -0,0 +1,5 @@ +{ + "jest": { + "testEnvironment": "node" + } +} diff --git a/packages/expect/src/index.ts b/packages/expect/src/index.ts index 5cc2cd68f6c3..eda221ff209b 100644 --- a/packages/expect/src/index.ts +++ b/packages/expect/src/index.ts @@ -91,7 +91,8 @@ const getPromiseMatcher = (name: string, matcher: RawMatcherFn) => { return createThrowMatcher(name, true); } else if ( name === 'toThrowErrorMatchingSnapshot' || - name === 'toThrowErrorMatchingInlineSnapshot' + name === 'toThrowErrorMatchingInlineSnapshot' || + name === 'toThrowErrorMatchingNamedSnapshot' ) { return createToThrowErrorMatchingSnapshotMatcher(matcher); } diff --git a/packages/jest-expect/src/index.ts b/packages/jest-expect/src/index.ts index b9c865613e0a..bac4d4c5c37d 100644 --- a/packages/jest-expect/src/index.ts +++ b/packages/jest-expect/src/index.ts @@ -9,8 +9,10 @@ import {expect} from 'expect'; import { addSerializer, toMatchInlineSnapshot, + toMatchNamedSnapshot, toMatchSnapshot, toThrowErrorMatchingInlineSnapshot, + toThrowErrorMatchingNamedSnapshot, toThrowErrorMatchingSnapshot, } from 'jest-snapshot'; import type {JestExpect} from './types'; @@ -29,8 +31,10 @@ export type {JestExpect} from './types'; function createJestExpect(): JestExpect { expect.extend({ toMatchInlineSnapshot, + toMatchNamedSnapshot, toMatchSnapshot, toThrowErrorMatchingInlineSnapshot, + toThrowErrorMatchingNamedSnapshot, toThrowErrorMatchingSnapshot, }); diff --git a/packages/jest-snapshot/__typetests__/matchers.test.ts b/packages/jest-snapshot/__typetests__/matchers.test.ts index 8e4dd53dcb87..c7760a314bdc 100644 --- a/packages/jest-snapshot/__typetests__/matchers.test.ts +++ b/packages/jest-snapshot/__typetests__/matchers.test.ts @@ -11,8 +11,10 @@ import { Context, SnapshotState, toMatchInlineSnapshot, + toMatchNamedSnapshot, toMatchSnapshot, toThrowErrorMatchingInlineSnapshot, + toThrowErrorMatchingNamedSnapshot, toThrowErrorMatchingSnapshot, } from 'jest-snapshot'; @@ -78,6 +80,39 @@ expectType( expectError(toMatchInlineSnapshot({received: 'value'})); +// toMatchNamedSnapshot + +expectError( + toMatchNamedSnapshot.call({} as Context, {received: 'value'}), +); + +expectType( + toMatchNamedSnapshot.call( + {} as Context, + {received: 'value'}, + 'snapshot name', + ), +); + +expectError( + toMatchNamedSnapshot.call( + {} as Context, + {received: 'value'}, + {property: 'match'}, + ), +); + +expectType( + toMatchNamedSnapshot.call( + {} as Context, + {received: 'value'}, + 'snapshot name', + {property: 'match'}, + ), +); + +expectError(toMatchNamedSnapshot({received: 'value'})); + // toThrowErrorMatchingSnapshot expectType( @@ -145,3 +180,28 @@ expectType( ); expectError(toThrowErrorMatchingInlineSnapshot({received: 'value'})); + +// toThrowErrorMatchingNamedSnapshot + +expectError( + toThrowErrorMatchingNamedSnapshot.call({} as Context, new Error('received')), +); + +expectType( + toThrowErrorMatchingNamedSnapshot.call( + {} as Context, + new Error('received'), + 'snapshot name', + ), +); + +expectType( + toThrowErrorMatchingNamedSnapshot.call( + {} as Context, + new Error('received'), + 'snapshot name', + true, // fromPromise + ), +); + +expectError(toThrowErrorMatchingSnapshot({received: 'value'})); diff --git a/packages/jest-snapshot/src/__tests__/__snapshots__/printSnapshot.test.ts.snap b/packages/jest-snapshot/src/__tests__/__snapshots__/printSnapshot.test.ts.snap index 1b1aea2e1561..3c0529649b18 100644 --- a/packages/jest-snapshot/src/__tests__/__snapshots__/printSnapshot.test.ts.snap +++ b/packages/jest-snapshot/src/__tests__/__snapshots__/printSnapshot.test.ts.snap @@ -1,5 +1,21 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`change text value 1`] = ` +expect(received).toMatchNamedSnapshot(properties) + +Snapshot name: \`with properties 1\` + +- Snapshot - 1 ++ Received + 1 + + Object { + "id": "abcdef", +- "text": "snapshot", ++ "text": "received", + "type": "ADD_ITEM", + } +`; + exports[`matcher error toMatchInlineSnapshot Expected properties must be an object (non-null) without snapshot 1`] = ` expect(received).toMatchInlineSnapshot(properties) @@ -32,6 +48,48 @@ exports[`matcher error toMatchInlineSnapshot Snapshot matchers cannot be used wi Matcher error: Snapshot matchers cannot be used with not `; +exports[`matcher error toMatchNamedSnapshot Expected properties must be an object (non-null) 1`] = ` +expect(received).toMatchNamedSnapshot(properties) + +Matcher error: Expected properties must be an object + +Expected properties has type: function +Expected properties has value: [Function] +`; + +exports[`matcher error toMatchNamedSnapshot Expected properties must be an object (null) with snapshot name 1`] = ` +expect(received).toMatchNamedSnapshot(properties) + +Matcher error: Expected properties must be an object + +Expected properties has value: null +`; + +exports[`matcher error toMatchNamedSnapshot Snapshot state must be initialized 1`] = ` +expect(received).resolves.toMatchNamedSnapshot() + +Snapshot state must be initialized + +Snapshot state has value: undefined +`; + +exports[`matcher error toMatchNamedSnapshot received value must be an object (non-null) 1`] = ` +expect(received).toMatchNamedSnapshot(properties) + +Matcher error: received value must be an object when the matcher has properties + +Received has type: string +Received has value: "string" +`; + +exports[`matcher error toMatchNamedSnapshot received value must be an object (null) 1`] = ` +expect(received).toMatchNamedSnapshot(properties) + +Matcher error: received value must be an object when the matcher has properties + +Received has value: null +`; + exports[`matcher error toMatchSnapshot Expected properties must be an object (non-null) 1`] = ` expect(received).toMatchSnapshot(properties) @@ -101,6 +159,21 @@ Snapshot state must be initialized Snapshot state has value: undefined `; +exports[`matcher error toThrowErrorMatchingNamedSnapshot Received value must be a function 1`] = ` +expect(received).toThrowErrorMatchingNamedSnapshot() + +Matcher error: received value must be a function + +Received has type: number +Received has value: 13 +`; + +exports[`matcher error toThrowErrorMatchingNamedSnapshot Snapshot matchers cannot be used with not 1`] = ` +expect(received).not.toThrowErrorMatchingNamedSnapshot() + +Matcher error: Snapshot matchers cannot be used with not +`; + exports[`matcher error toThrowErrorMatchingSnapshot Received value must be a function 1`] = ` expect(received).toThrowErrorMatchingSnapshot() @@ -166,6 +239,32 @@ Snapshot name: \`with properties 1\` } `; +exports[`pass false toMatchNamedSnapshot empty snapshot name New snapshot was not written (multi line) 1`] = ` +expect(received).toMatchNamedSnapshot() + +Snapshot name: \`New snapshot was not written 1\` + +New snapshot was not written. The update flag must be explicitly passed to write a new snapshot. + +This is likely because this test is run in a continuous integration (CI) environment in which snapshots are not written by default. + +Received: +"To write or not to write, +that is the question." +`; + +exports[`pass false toMatchNamedSnapshot empty snapshot name New snapshot was not written (single line) 1`] = ` +expect(received).toMatchNamedSnapshot() + +Snapshot name: \`New snapshot was not written 2\` + +New snapshot was not written. The update flag must be explicitly passed to write a new snapshot. + +This is likely because this test is run in a continuous integration (CI) environment in which snapshots are not written by default. + +Received: "Write me if you can!" +`; + exports[`pass false toMatchSnapshot New snapshot was not written (multi line) 1`] = ` expect(received).toMatchSnapshot(hint) @@ -638,3 +737,52 @@ exports[`printSnapshotAndReceived without serialize prettier/pull/5590 1`] = ` ================================================================================ `; + +exports[`specific-line-diff-false 1`] = ` +expect(received).toMatchNamedSnapshot(properties) + +Snapshot name: \`with properties 1\` + +Expected properties: {"name": "Error"} +Received value: [RangeError: Invalid array length] +`; + +exports[`specific-line-diff-true 1`] = ` +expect(received).toMatchNamedSnapshot(properties) + +Snapshot name: \`with properties 1\` + +- Expected properties - 1 ++ Received value + 1 + + Object { +- "id": "abcdef", ++ "id": "abcdefg", + } +`; + +exports[`specific-not-written-multi-line 1`] = ` +expect(received).toMatchNamedSnapshot() + +Snapshot name: \`New snapshot was not written 1\` + +New snapshot was not written. The update flag must be explicitly passed to write a new snapshot. + +This is likely because this test is run in a continuous integration (CI) environment in which snapshots are not written by default. + +Received: +"To write or not to write, +that is the question." +`; + +exports[`specific-not-written-single-line 1`] = ` +expect(received).toMatchNamedSnapshot() + +Snapshot name: \`New snapshot was not written 2\` + +New snapshot was not written. The update flag must be explicitly passed to write a new snapshot. + +This is likely because this test is run in a continuous integration (CI) environment in which snapshots are not written by default. + +Received: "Write me if you can!" +`; diff --git a/packages/jest-snapshot/src/__tests__/printSnapshot.test.ts b/packages/jest-snapshot/src/__tests__/printSnapshot.test.ts index 43edcd13029b..2e9bbf49dcb2 100644 --- a/packages/jest-snapshot/src/__tests__/printSnapshot.test.ts +++ b/packages/jest-snapshot/src/__tests__/printSnapshot.test.ts @@ -13,8 +13,10 @@ import format from 'pretty-format'; import { Context, toMatchInlineSnapshot, + toMatchNamedSnapshot, toMatchSnapshot, toThrowErrorMatchingInlineSnapshot, + toThrowErrorMatchingNamedSnapshot, toThrowErrorMatchingSnapshot, } from '../'; import type SnapshotState from '../State'; @@ -274,6 +276,85 @@ describe('matcher error', () => { }); }); + describe('toMatchNamedSnapshot', () => { + const received = { + id: 'abcdef', + text: 'Throw matcher error', + type: 'ADD_ITEM', + }; + + test('Expected properties must be an object (non-null)', () => { + const context = { + isNot: false, + promise: '', + } as Context; + const properties = () => {}; + const snapshotName = + 'toMatchNamedSnapshot Expected properties must be an object (non-null)'; + + expect(() => { + toMatchNamedSnapshot.call(context, received, snapshotName, properties); + }).toThrowErrorMatchingSnapshot(); + }); + + test('Expected properties must be an object (null) with snapshot name', () => { + const context = { + isNot: false, + promise: '', + } as Context; + const properties = null; + const snapshotName = 'obj-snapshot'; + + expect(() => { + // @ts-expect-error: Testing runtime error + toMatchNamedSnapshot.call(context, received, snapshotName, properties); + }).toThrowErrorMatchingSnapshot(); + }); + + describe('received value must be an object', () => { + const context = { + currentTestName: '', + isNot: false, + promise: '', + snapshotState: {}, + } as Context; + const properties = {}; + const snapshotName = + 'toMatchNamedSnapshot received value must be an object'; + + test('(non-null)', () => { + expect(() => { + toMatchNamedSnapshot.call( + context, + 'string', + snapshotName, + properties, + ); + }).toThrowErrorMatchingSnapshot(); + }); + + test('(null)', () => { + const snapshotName = 'toMatchNamedSnapshot (null)'; + + expect(() => { + toMatchNamedSnapshot.call(context, null, snapshotName, properties); + }).toThrowErrorMatchingSnapshot(); + }); + }); + + test('Snapshot state must be initialized', () => { + const context = { + isNot: false, + promise: 'resolves', + } as Context; + const snapshotName = 'initialize me'; + + expect(() => { + toMatchNamedSnapshot.call(context, received, snapshotName); + }).toThrowErrorMatchingSnapshot(); + }); + }); + describe('toMatchSnapshot', () => { const received = { id: 'abcdef', @@ -435,6 +516,45 @@ describe('matcher error', () => { // Future test: Snapshot hint must be a string }); + + describe('toThrowErrorMatchingNamedSnapshot', () => { + test('Received value must be a function', () => { + const context = { + isNot: false, + promise: '', + } as Context; + const received = 13; + const fromPromise = false; + + expect(() => { + toThrowErrorMatchingNamedSnapshot.call( + context, + received, + '', + fromPromise, + ); + }).toThrowErrorMatchingSnapshot(); + }); + + test('Snapshot matchers cannot be used with not', () => { + const snapshotName = 'to-throw-snapshot'; + const context = { + isNot: true, + promise: '', + } as Context; + const received = new Error('received'); + const fromPromise = true; + + expect(() => { + toThrowErrorMatchingNamedSnapshot.call( + context, + received, + snapshotName, + fromPromise, + ); + }).toThrowErrorMatchingSnapshot(); + }); + }); }); describe('other error', () => { @@ -554,6 +674,223 @@ describe('pass false', () => { }); }); + describe('toMatchNamedSnapshot', () => { + describe('empty snapshot name', () => { + test('New snapshot was not written (multi line)', () => { + const context = { + currentTestName: 'New snapshot was not written', + isNot: false, + promise: '', + snapshotState: { + match({received}) { + return { + actual: format(received), + count: 1, + expected: undefined, + pass: false, + }; + }, + }, + } as Context; + const received = 'To write or not to write,\nthat is the question.'; + const snapshotName = ''; + + const {message, pass} = toMatchNamedSnapshot.call( + context, + received, + snapshotName, + ) as SyncExpectationResult; + expect(pass).toBe(false); + expect(message()).toMatchNamedSnapshot(snapshotName); + }); + + test('New snapshot was not written (single line)', () => { + const context = { + currentTestName: 'New snapshot was not written', + isNot: false, + promise: '', + snapshotState: { + match({received}) { + return { + actual: format(received), + count: 2, + expected: undefined, + pass: false, + }; + }, + }, + } as Context; + const received = 'Write me if you can!'; + const snapshotName = ''; + + const {message, pass} = toMatchNamedSnapshot.call( + context, + received, + snapshotName, + ) as SyncExpectationResult; + expect(pass).toBe(false); + expect(message()).toMatchNamedSnapshot(snapshotName); + }); + }); + + describe('specific snapshot name', () => { + test('New snapshot was not written (multi line)', () => { + const context = { + currentTestName: 'New snapshot was not written', + isNot: false, + promise: '', + snapshotState: { + match({received}) { + return { + actual: format(received), + count: 1, + expected: undefined, + key: 'specific-not-written-multi-line 1', + pass: false, + }; + }, + }, + } as Context; + const received = 'To write or not to write,\nthat is the question.'; + const snapshotName = 'specific-not-written-multi-line'; + + const {message, pass} = toMatchNamedSnapshot.call( + context, + received, + snapshotName, + ) as SyncExpectationResult; + expect(pass).toBe(false); + expect(message()).toMatchNamedSnapshot(snapshotName); + }); + + test('New snapshot was not written (single line)', () => { + const context = { + currentTestName: 'New snapshot was not written', + isNot: false, + promise: '', + snapshotState: { + match({received}) { + return { + actual: format(received), + count: 2, + expected: undefined, + key: 'specific-not-written-single-line 1', + pass: false, + }; + }, + }, + } as Context; + const received = 'Write me if you can!'; + const snapshotName = 'specific-not-written-single-line'; + + const {message, pass} = toMatchNamedSnapshot.call( + context, + received, + snapshotName, + ) as SyncExpectationResult; + expect(pass).toBe(false); + expect(message()).toMatchNamedSnapshot(snapshotName); + }); + }); + + describe('with properties', () => { + const id = 'abcdef'; + const properties = {id}; + const type = 'ADD_ITEM'; + + describe('equals false', () => { + const context = { + currentTestName: 'with properties', + equals: () => false, + isNot: false, + promise: '', + snapshotState: { + fail: (fullTestName: string) => `${fullTestName} 1`, + }, + utils: { + iterableEquality: () => {}, + subsetEquality: () => {}, + }, + } as unknown as Context; + + test('isLineDiffable false', () => { + const snapshotName = 'specific-line-diff-false'; + + const {message, pass} = toMatchNamedSnapshot.call( + context, + new RangeError('Invalid array length'), + snapshotName, + {name: 'Error'}, + ) as SyncExpectationResult; + expect(pass).toBe(false); + expect(message()).toMatchNamedSnapshot(snapshotName); + }); + + test('isLineDiffable true', () => { + const snapshotName = 'specific-line-diff-true'; + const received = { + id: 'abcdefg', + text: 'Increase code coverage', + type, + }; + + const {message, pass} = toMatchNamedSnapshot.call( + context, + received, + snapshotName, + properties, + ) as SyncExpectationResult; + expect(pass).toBe(false); + expect(message()).toMatchNamedSnapshot(snapshotName); + }); + }); + + test('equals true', () => { + const context = { + currentTestName: 'with properties', + equals: () => true, + isNot: false, + promise: '', + snapshotState: { + expand: false, + match({received}) { + return { + actual: format(received), + count: 1, + expected: format({ + id, + text: 'snapshot', + type, + }), + key: 'change text value 1', + pass: false, + }; + }, + } as SnapshotState, + utils: { + iterableEquality: () => {}, + subsetEquality: () => {}, + }, + } as unknown as Context; + const received = { + id, + text: 'received', + type, + }; + const snapshotName = 'change text value'; + + const {message, pass} = toMatchNamedSnapshot.call( + context, + received, + snapshotName, + properties, + ) as SyncExpectationResult; + expect(pass).toBe(false); + expect(message()).toMatchNamedSnapshot(snapshotName); + }); + }); + }); + describe('toMatchSnapshot', () => { test('New snapshot was not written (multi line)', () => { const context = { diff --git a/packages/jest-snapshot/src/__tests__/throwMatcher.test.ts b/packages/jest-snapshot/src/__tests__/throwMatcher.test.ts index 9293e8e24762..4c639d1b19e5 100644 --- a/packages/jest-snapshot/src/__tests__/throwMatcher.test.ts +++ b/packages/jest-snapshot/src/__tests__/throwMatcher.test.ts @@ -5,7 +5,11 @@ * LICENSE file in the root directory of this source tree. */ -import {type Context, toThrowErrorMatchingSnapshot} from '../'; +import { + Context, + toThrowErrorMatchingNamedSnapshot, + toThrowErrorMatchingSnapshot, +} from '../'; const mockedMatch = jest.fn(() => ({ actual: 'coconut', @@ -20,50 +24,112 @@ afterEach(() => { jest.clearAllMocks(); }); -it('throw matcher can take func', () => { - toThrowErrorMatchingSnapshot.call( - mockedContext, - () => { - throw new Error('coconut'); - }, - undefined, - false, - ); - - expect(mockedMatch).toHaveBeenCalledTimes(1); - expect(mockedMatch).toHaveBeenCalledWith( - expect.objectContaining({received: 'coconut', testName: ''}), - ); -}); - -describe('throw matcher from promise', () => { - it('can take error', () => { +describe('throw matcher can take func', () => { + it('toThrowErrorMatchingSnapshot', () => { toThrowErrorMatchingSnapshot.call( mockedContext, - new Error('coco'), - 'testName', - true, + () => { + throw new Error('coconut'); + }, + undefined, + false, ); expect(mockedMatch).toHaveBeenCalledTimes(1); expect(mockedMatch).toHaveBeenCalledWith( - expect.objectContaining({received: 'coco', testName: ''}), + expect.objectContaining({received: 'coconut', testName: ''}), ); }); - it('can take custom error', () => { - class CustomError extends Error {} - - toThrowErrorMatchingSnapshot.call( + it('toThrowErrorMatchingNamedSnapshot', () => { + toThrowErrorMatchingNamedSnapshot.call( mockedContext, - new CustomError('nut'), - 'testName', - true, + () => { + throw new Error('coconut'); + }, + '', + false, ); expect(mockedMatch).toHaveBeenCalledTimes(1); expect(mockedMatch).toHaveBeenCalledWith( - expect.objectContaining({received: 'nut', testName: ''}), + expect.objectContaining({received: 'coconut', testName: ''}), ); }); }); + +describe('throw matcher from promise', () => { + describe('toThrowErrorMatchingSnapshot', () => { + it('can take error', () => { + toThrowErrorMatchingSnapshot.call( + mockedContext, + new Error('coco'), + 'testName', + true, + ); + + expect(mockedMatch).toHaveBeenCalledTimes(1); + expect(mockedMatch).toHaveBeenCalledWith( + expect.objectContaining({received: 'coco', testName: ''}), + ); + }); + + it('can take custom error', () => { + class CustomError extends Error {} + + toThrowErrorMatchingSnapshot.call( + mockedContext, + new CustomError('nut'), + 'testName', + true, + ); + + expect(mockedMatch).toHaveBeenCalledTimes(1); + expect(mockedMatch).toHaveBeenCalledWith( + expect.objectContaining({received: 'nut', testName: ''}), + ); + }); + }); + + describe('toThrowErrorMatchingNamedSnapshot', () => { + const mockedNamedMatch = jest.fn(() => ({ + actual: 'coconut', + expected: 'coconut', + key: 'snapshot name 1', + })); + + const mockedNamedContext = { + snapshotState: {match: mockedNamedMatch}, + } as unknown as Context; + + it('can take error', () => { + toThrowErrorMatchingNamedSnapshot.call( + mockedNamedContext, + new Error('coco'), + 'snapshot name', + true, + ); + + expect(mockedNamedMatch).toHaveBeenCalledTimes(1); + expect(mockedNamedMatch).toHaveBeenCalledWith( + expect.objectContaining({received: 'coco', testName: 'snapshot name'}), + ); + }); + + it('can take custom error', () => { + class CustomError extends Error {} + + toThrowErrorMatchingNamedSnapshot.call( + mockedNamedContext, + new CustomError('nut'), + 'snapshot name', + true, + ); + + expect(mockedNamedMatch).toHaveBeenCalledTimes(1); + expect(mockedNamedMatch).toHaveBeenCalledWith( + expect.objectContaining({received: 'nut', testName: 'snapshot name'}), + ); + }); + }); +}); diff --git a/packages/jest-snapshot/src/index.ts b/packages/jest-snapshot/src/index.ts index dd310c0773af..bf5dbe3ac95a 100644 --- a/packages/jest-snapshot/src/index.ts +++ b/packages/jest-snapshot/src/index.ts @@ -30,8 +30,18 @@ import { printReceived, printSnapshotAndReceived, } from './printSnapshot'; -import type {Context, FileSystem, MatchSnapshotConfig} from './types'; -import {deepMerge, escapeBacktickString, serialize} from './utils'; +import type { + Context, + FileSystem, + MatchSnapshotConfig, + SnapshotNameConfig, +} from './types'; +import { + deepMerge, + escapeBacktickString, + serialize, + testNameToKey, +} from './utils'; export {addSerializer, getSerializers} from './plugins'; export { @@ -67,6 +77,19 @@ const printSnapshotName = ( } ${count}\``; }; +const getSnapshotName = (config: SnapshotNameConfig): string => { + const {currentTestName, hint, snapshotName} = config; + + if (snapshotName) { + return snapshotName; + } + if (currentTestName && hint) { + return `${currentTestName}: ${hint}`; + } + // future BREAKING change: || hint + return currentTestName || ''; +}; + function stripAddedIndentation(inlineSnapshot: string) { // Find indentation if exists. const match = inlineSnapshot.match(INDENTATION_REGEX); @@ -209,6 +232,64 @@ export const toMatchSnapshot: MatcherFunctionWithContext< }); }; +export const toMatchNamedSnapshot: MatcherFunctionWithContext< + Context, + [snapshotName: string, properties?: object] +> = function (received: unknown, snapshotName: unknown, properties?: unknown) { + const matcherName = 'toMatchNamedSnapshot'; + + if (typeof snapshotName !== 'string') { + const options: MatcherHintOptions = { + isNot: this.isNot, + promise: this.promise, + }; + const printedWithType = printWithType( + 'Expected snapshotName', + snapshotName, + printExpected, + ); + + throw new Error( + matcherErrorMessage( + matcherHint(matcherName, undefined, PROPERTIES_ARG, options), + `Expected ${EXPECTED_COLOR('snapshotName')} must be a string`, + printedWithType, + ), + ); + } + + if (properties !== undefined) { + if (typeof properties !== 'object' || properties === null) { + const options: MatcherHintOptions = { + isNot: this.isNot, + promise: this.promise, + }; + const printedWithType = printWithType( + 'Expected properties', + properties, + printExpected, + ); + + throw new Error( + matcherErrorMessage( + matcherHint(matcherName, undefined, PROPERTIES_ARG, options), + `Expected ${EXPECTED_COLOR('properties')} must be an object`, + printedWithType, + ), + ); + } + } + + return _toMatchSnapshot({ + context: this, + isInline: false, + matcherName, + properties, + received, + snapshotName, + }); +}; + export const toMatchInlineSnapshot: MatcherFunctionWithContext< Context, [propertiesOrSnapshot?: object | string, inlineSnapshot?: string] @@ -273,8 +354,15 @@ export const toMatchInlineSnapshot: MatcherFunctionWithContext< }; const _toMatchSnapshot = (config: MatchSnapshotConfig) => { - const {context, hint, inlineSnapshot, isInline, matcherName, properties} = - config; + const { + context, + hint, + inlineSnapshot, + isInline, + matcherName, + properties, + snapshotName, + } = config; let {received} = config; context.dontThrow && context.dontThrow(); @@ -301,10 +389,7 @@ const _toMatchSnapshot = (config: MatchSnapshotConfig) => { ); } - const fullTestName = - currentTestName && hint - ? `${currentTestName}: ${hint}` - : currentTestName || ''; // future BREAKING change: || hint + const fullTestName = getSnapshotName({currentTestName, hint, snapshotName}); if (typeof properties === 'object') { if (typeof received !== 'object' || received === null) { @@ -359,7 +444,15 @@ const _toMatchSnapshot = (config: MatchSnapshotConfig) => { received, testName: fullTestName, }); - const {actual, count, expected, pass} = result; + const {actual, count, expected, key, pass} = result; + + if (snapshotName && key != testNameToKey(fullTestName, 1)) { + throw new Error( + 'The specific snapshot name was duplicate with the other snapshot.\n\n' + + `Snapshot name: ${fullTestName}\n` + + `Snapshot Key: ${key}`, + ); + } if (pass) { return {message: () => '', pass: true}; @@ -463,12 +556,57 @@ export const toThrowErrorMatchingInlineSnapshot: MatcherFunctionWithContext< ); }; +export const toThrowErrorMatchingNamedSnapshot: MatcherFunctionWithContext< + Context, + [snapshotName: string, fromPromise?: boolean] +> = function (received: unknown, snapshotName: unknown, fromPromise) { + const matcherName = 'toThrowErrorMatchingNamedSnapshot'; + + if (typeof snapshotName !== 'string') { + const options: MatcherHintOptions = { + isNot: this.isNot, + promise: this.promise, + }; + const printedWithType = printWithType( + 'Expected snapshotName', + snapshotName, + printExpected, + ); + + throw new Error( + matcherErrorMessage( + matcherHint(matcherName, undefined, PROPERTIES_ARG, options), + `Expected ${EXPECTED_COLOR('snapshotName')} must be a string`, + printedWithType, + ), + ); + } + + return _toThrowErrorMatchingSnapshot( + { + context: this, + isInline: false, + matcherName, + received, + snapshotName, + }, + fromPromise, + ); +}; + const _toThrowErrorMatchingSnapshot = ( config: MatchSnapshotConfig, fromPromise?: boolean, ) => { - const {context, hint, inlineSnapshot, isInline, matcherName, received} = - config; + const { + context, + hint, + inlineSnapshot, + isInline, + matcherName, + received, + snapshotName, + } = config; context.dontThrow && context.dontThrow(); @@ -523,5 +661,6 @@ const _toThrowErrorMatchingSnapshot = ( isInline, matcherName, received: error.message, + snapshotName, }); }; diff --git a/packages/jest-snapshot/src/types.ts b/packages/jest-snapshot/src/types.ts index 541c191193a7..aafaa6abf161 100644 --- a/packages/jest-snapshot/src/types.ts +++ b/packages/jest-snapshot/src/types.ts @@ -29,6 +29,13 @@ export type MatchSnapshotConfig = { matcherName: string; properties?: object; received: any; + snapshotName?: string; +}; + +export type SnapshotNameConfig = { + currentTestName?: string; + hint?: string; + snapshotName?: string; }; export type SnapshotData = Record; @@ -47,6 +54,14 @@ export interface SnapshotMatchers, T> { propertyMatchers: Partial, hint?: string, ): R; + /** + * This ensures that a value matches the specific snapshot with property matchers. + * Instead of use current test name in global state, it will use the specific name to find the snapshot. + */ + toMatchNamedSnapshot>( + snapshotName: string, + propertyMatchers?: Partial, + ): R; /** * This ensures that a value matches the most recent snapshot with property matchers. * Instead of writing the snapshot value to a .snap file, it will be written into the source code automatically. @@ -71,6 +86,11 @@ export interface SnapshotMatchers, T> { * Instead of writing the snapshot value to a .snap file, it will be written into the source code automatically. */ toThrowErrorMatchingInlineSnapshot(snapshot?: string): R; + /** + * Used to test that a function throws a error matching the specific snapshot. + * Instead of use current test name in global state, it will use the specific name to find the snapshot. + */ + toThrowErrorMatchingNamedSnapshot(snapshotName: string): R; } export type SnapshotFormat = Omit; diff --git a/packages/jest-types/__typetests__/expect.test.ts b/packages/jest-types/__typetests__/expect.test.ts index dbed505d98df..8dac94b2a5f2 100644 --- a/packages/jest-types/__typetests__/expect.test.ts +++ b/packages/jest-types/__typetests__/expect.test.ts @@ -415,6 +415,39 @@ expectError( }), ); +expectType(expect('abc').toMatchNamedSnapshot('snapshot name')); +expectError(expect('abc').toMatchNamedSnapshot()); + +expectType( + expect({ + date: new Date(), + name: 'John Doe', + }).toMatchNamedSnapshot('snapshot name', { + date: expect.any(Date), + name: expect.any(String), + }), +); + +expectType( + expect({ + date: new Date(), + name: 'John Doe', + }).toMatchNamedSnapshot('snapshot name', { + date: expect.any(Date), + name: expect.any(String), + }), +); + +expectError( + expect({ + date: new Date(), + name: 'John Doe', + }).toMatchNamedSnapshot('snapshot name', { + date: expect.any(Date), + time: expect.any(Date), + }), +); + expectType(expect(jest.fn()).toThrowErrorMatchingSnapshot()); expectType(expect(jest.fn()).toThrowErrorMatchingSnapshot('hint')); expectError(expect(jest.fn()).toThrowErrorMatchingSnapshot(true)); @@ -425,6 +458,11 @@ expectType( ); expectError(expect(jest.fn()).toThrowErrorMatchingInlineSnapshot(true)); +expectType( + expect(jest.fn()).toThrowErrorMatchingNamedSnapshot('snapshot-name'), +); +expectError(expect(jest.fn()).toThrowErrorMatchingNamedSnapshot()); + // extend type MatcherUtils = typeof jestMatcherUtils & {