mirror of
https://github.com/facebook/react-native.git
synced 2025-11-01 09:14:26 +00:00
Add snapshot update / delete capability (#48096)
Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/48096 Changelog: [Internal] Managing snapshot state internally by adding / updating snapshot data. Reviewed By: christophpurrer Differential Revision: D66707175 fbshipit-source-id: 0366d834eafa0ca702f03de0210392181fd90a58
This commit is contained in:
committed by
Facebook GitHub Bot
parent
a8a136f3ef
commit
a298ccabe2
+15
-10
@@ -14,6 +14,10 @@ import type {TestSuiteResult} from '../runtime/setup';
|
||||
import entrypointTemplate from './entrypoint-template';
|
||||
import getFantomTestConfig from './getFantomTestConfig';
|
||||
import {FantomTestConfigMode} from './getFantomTestConfig';
|
||||
import {
|
||||
getInitialSnapshotData,
|
||||
updateSnapshotsAndGetJestSnapshotResult,
|
||||
} from './snapshotUtils';
|
||||
import {
|
||||
getBuckModeForPlatform,
|
||||
getDebugInfoFromCommandResult,
|
||||
@@ -135,7 +139,7 @@ module.exports = async function runTest(
|
||||
featureFlags: testConfig.flags.jsOnly,
|
||||
snapshotConfig: {
|
||||
updateSnapshot: snapshotState._updateSnapshot,
|
||||
data: snapshotState._initialData,
|
||||
data: getInitialSnapshotData(snapshotState),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -221,6 +225,15 @@ module.exports = async function runTest(
|
||||
),
|
||||
})) ?? [];
|
||||
|
||||
const snapshotResults = nullthrows(
|
||||
rnTesterParsedOutput.testResult.testResults,
|
||||
).map(testResult => testResult.snapshotResults);
|
||||
|
||||
const snapshotResult = updateSnapshotsAndGetJestSnapshotResult(
|
||||
snapshotState,
|
||||
snapshotResults,
|
||||
);
|
||||
|
||||
return {
|
||||
testFilePath: testPath,
|
||||
failureMessage: formatResultsErrors(
|
||||
@@ -238,15 +251,7 @@ module.exports = async function runTest(
|
||||
runtime: endTime - startTime,
|
||||
slow: false,
|
||||
},
|
||||
snapshot: {
|
||||
added: 0,
|
||||
fileDeleted: false,
|
||||
matched: 0,
|
||||
unchecked: 0,
|
||||
uncheckedKeys: [],
|
||||
unmatched: 0,
|
||||
updated: 0,
|
||||
},
|
||||
snapshot: snapshotResult,
|
||||
numTotalTests: testResults.length,
|
||||
numPassingTests: testResults.filter(test => test.status === 'passed')
|
||||
.length,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @flow strict-local
|
||||
* @format
|
||||
* @oncall react_native
|
||||
*/
|
||||
|
||||
import type {TestSnapshotResults} from '../runtime/snapshotContext';
|
||||
import type {SnapshotState} from 'jest-snapshot';
|
||||
|
||||
type JestSnapshotResult = {
|
||||
added: number,
|
||||
fileDeleted: boolean,
|
||||
matched: number,
|
||||
unchecked: number,
|
||||
uncheckedKeys: string[],
|
||||
unmatched: number,
|
||||
updated: number,
|
||||
};
|
||||
|
||||
// Add extra line breaks at beginning and end of multiline snapshot
|
||||
// to make the content easier to read.
|
||||
const addExtraLineBreaks = (string: string): string =>
|
||||
string.includes('\n') ? `\n${string}\n` : string;
|
||||
|
||||
// Remove extra line breaks at beginning and end of multiline snapshot.
|
||||
// Instead of trim, which can remove additional newlines or spaces
|
||||
// at beginning or end of the content from a custom serializer.
|
||||
const removeExtraLineBreaks = (string: string): string =>
|
||||
string.length > 2 && string.startsWith('\n') && string.endsWith('\n')
|
||||
? string.slice(1, -1)
|
||||
: string;
|
||||
|
||||
export const getInitialSnapshotData = (
|
||||
snapshotState: SnapshotState,
|
||||
): {[key: string]: string} => {
|
||||
const initialData: {[key: string]: string} = {};
|
||||
|
||||
for (const key in snapshotState._initialData) {
|
||||
initialData[key] = removeExtraLineBreaks(snapshotState._initialData[key]);
|
||||
}
|
||||
|
||||
return initialData;
|
||||
};
|
||||
|
||||
export const updateSnapshotsAndGetJestSnapshotResult = (
|
||||
snapshotState: SnapshotState,
|
||||
testSnapshotResults: Array<TestSnapshotResults>,
|
||||
): JestSnapshotResult => {
|
||||
for (const snapshotResults of testSnapshotResults) {
|
||||
for (const [key, result] of Object.entries(snapshotResults)) {
|
||||
if (result.pass) {
|
||||
snapshotState.matched++;
|
||||
snapshotState._uncheckedKeys.delete(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (snapshotState._snapshotData[key] === undefined) {
|
||||
if (snapshotState._updateSnapshot === 'none') {
|
||||
snapshotState.unmatched++;
|
||||
continue;
|
||||
}
|
||||
|
||||
snapshotState._dirty = true;
|
||||
snapshotState._snapshotData[key] = addExtraLineBreaks(result.value);
|
||||
snapshotState.added++;
|
||||
snapshotState.matched++;
|
||||
snapshotState._uncheckedKeys.delete(key);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
snapshotState._dirty = true;
|
||||
snapshotState._snapshotData[key] = addExtraLineBreaks(result.value);
|
||||
snapshotState.updated++;
|
||||
snapshotState._uncheckedKeys.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
const uncheckedCount = snapshotState.getUncheckedCount();
|
||||
const uncheckedKeys = snapshotState.getUncheckedKeys();
|
||||
if (uncheckedCount) {
|
||||
snapshotState.removeUncheckedKeys();
|
||||
}
|
||||
|
||||
const status = snapshotState.save();
|
||||
return {
|
||||
added: snapshotState.added,
|
||||
fileDeleted: status.deleted,
|
||||
matched: snapshotState.matched,
|
||||
unchecked: status.deleted ? 0 : snapshotState.getUncheckedCount(),
|
||||
uncheckedKeys: [...uncheckedKeys],
|
||||
unmatched: snapshotState.unmatched,
|
||||
updated: snapshotState.updated,
|
||||
};
|
||||
};
|
||||
+5
-13
@@ -15,8 +15,6 @@ import deepEqual from 'deep-equal';
|
||||
import {diff} from 'jest-diff';
|
||||
import {format, plugins} from 'pretty-format';
|
||||
|
||||
const COMPARISON_EQUALS_STRING = 'Compared values have no visual difference.';
|
||||
|
||||
class ErrorWithCustomBlame extends Error {
|
||||
// Initially 5 to ignore all the frames from Babel helpers to instantiate this
|
||||
// custom error class.
|
||||
@@ -260,20 +258,14 @@ class Expect {
|
||||
).blameToPreviousFrame();
|
||||
}
|
||||
|
||||
const [err, currentSnapshot] = snapshotContext.getSnapshot(expected);
|
||||
if (err != null) {
|
||||
throw new ErrorWithCustomBlame(err).blameToPreviousFrame();
|
||||
}
|
||||
|
||||
const receivedValue = format(this.#received, {
|
||||
plugins: [plugins.ReactElement],
|
||||
});
|
||||
const result =
|
||||
diff(currentSnapshot, receivedValue) ?? 'Failed to compare outputs';
|
||||
if (result !== COMPARISON_EQUALS_STRING) {
|
||||
throw new ErrorWithCustomBlame(
|
||||
`Expected to match snapshot.\n${result}`,
|
||||
).blameToPreviousFrame();
|
||||
|
||||
try {
|
||||
snapshotContext.toMatchSnapshot(receivedValue, expected);
|
||||
} catch (err) {
|
||||
throw new ErrorWithCustomBlame(err.message).blameToPreviousFrame();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-1
@@ -9,7 +9,7 @@
|
||||
* @oncall react_native
|
||||
*/
|
||||
|
||||
import type {SnapshotConfig} from './snapshotContext';
|
||||
import type {SnapshotConfig, TestSnapshotResults} from './snapshotContext';
|
||||
|
||||
import expect from './expect';
|
||||
import {createMockFunction} from './mocks';
|
||||
@@ -24,6 +24,7 @@ export type TestCaseResult = {
|
||||
duration: number,
|
||||
failureMessages: Array<string>,
|
||||
numPassingAsserts: number,
|
||||
snapshotResults: TestSnapshotResults,
|
||||
// location: string,
|
||||
};
|
||||
|
||||
@@ -38,6 +39,13 @@ export type TestSuiteResult =
|
||||
},
|
||||
};
|
||||
|
||||
type SnapshotState = {
|
||||
name: string,
|
||||
snapshotResults: TestSnapshotResults,
|
||||
};
|
||||
|
||||
let currentSnapshotState: SnapshotState;
|
||||
|
||||
const tests: Array<{
|
||||
title: string,
|
||||
ancestorTitles: Array<string>,
|
||||
@@ -152,6 +160,7 @@ function executeTests() {
|
||||
duration: 0,
|
||||
failureMessages: [],
|
||||
numPassingAsserts: 0,
|
||||
snapshotResults: {},
|
||||
};
|
||||
|
||||
test.result = result;
|
||||
@@ -177,6 +186,8 @@ function executeTests() {
|
||||
status === 'failed' && error
|
||||
? [error.stack ?? error.message ?? String(error)]
|
||||
: [];
|
||||
|
||||
result.snapshotResults = snapshotContext.getSnapshotResults();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+79
-40
@@ -9,61 +9,100 @@
|
||||
* @oncall react_native
|
||||
*/
|
||||
|
||||
import {diff} from 'jest-diff';
|
||||
|
||||
export type SnapshotConfig = {
|
||||
updateSnapshot: 'all' | 'new' | 'none',
|
||||
data: {[key: string]: string},
|
||||
};
|
||||
|
||||
export type TestSnapshotResults = {
|
||||
[key: string]:
|
||||
| {
|
||||
pass: true,
|
||||
}
|
||||
| {
|
||||
pass: false,
|
||||
value: string,
|
||||
},
|
||||
};
|
||||
|
||||
const COMPARISON_EQUALS_STRING = 'Compared values have no visual difference.';
|
||||
|
||||
let snapshotConfig: ?SnapshotConfig;
|
||||
|
||||
// Destructure [err, value] from the return value of getSnapshot
|
||||
type SnapshotResponse = [null, string] | [string, void];
|
||||
|
||||
class SnapshotState {
|
||||
#callCount: number = 0;
|
||||
#testFullName: string;
|
||||
|
||||
constructor(name: string) {
|
||||
this.#testFullName = name;
|
||||
}
|
||||
|
||||
getSnapshot(label: ?string): SnapshotResponse {
|
||||
const snapshotKey = `${this.#testFullName}${
|
||||
label != null ? `: ${label}` : ''
|
||||
} ${++this.#callCount}`;
|
||||
|
||||
if (snapshotConfig == null) {
|
||||
return [
|
||||
'Snapshot config is not set. Did you forget to call `setupSnapshotConfig`?',
|
||||
undefined,
|
||||
];
|
||||
}
|
||||
|
||||
if (snapshotConfig.data[snapshotKey] == null) {
|
||||
return [
|
||||
`Expected to have snapshot \`${snapshotKey}\` but it was not found.`,
|
||||
undefined,
|
||||
];
|
||||
}
|
||||
|
||||
return [null, snapshotConfig.data[snapshotKey]];
|
||||
}
|
||||
}
|
||||
type SnapshotState = {
|
||||
callCount: number,
|
||||
testFullName: string,
|
||||
snapshotResults: TestSnapshotResults,
|
||||
};
|
||||
|
||||
class SnapshotContext {
|
||||
#snapshotState: ?SnapshotState = null;
|
||||
|
||||
setTargetTest(testFullName: string) {
|
||||
this.#snapshotState = new SnapshotState(testFullName);
|
||||
this.#snapshotState = {
|
||||
callCount: 0,
|
||||
testFullName,
|
||||
snapshotResults: {},
|
||||
};
|
||||
}
|
||||
|
||||
getSnapshot(label: ?string): SnapshotResponse {
|
||||
return (
|
||||
this.#snapshotState?.getSnapshot(label) ?? [
|
||||
toMatchSnapshot(received: string, label: ?string): void {
|
||||
const snapshotState = this.#snapshotState;
|
||||
if (snapshotState == null) {
|
||||
throw new Error(
|
||||
'Snapshot state is not set, call `setTargetTest()` first',
|
||||
undefined,
|
||||
]
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
const snapshotKey = `${snapshotState.testFullName}${
|
||||
label != null ? `: ${label}` : ''
|
||||
} ${++snapshotState.callCount}`;
|
||||
|
||||
if (snapshotConfig == null) {
|
||||
throw new Error(
|
||||
'Snapshot config is not set. Did you forget to call `setupSnapshotConfig`?',
|
||||
);
|
||||
}
|
||||
|
||||
const updateSnapshot = snapshotConfig.updateSnapshot;
|
||||
const snapshot = snapshotConfig.data[snapshotKey];
|
||||
|
||||
if (snapshot == null) {
|
||||
snapshotState.snapshotResults[snapshotKey] = {
|
||||
pass: false,
|
||||
value: received,
|
||||
};
|
||||
|
||||
if (updateSnapshot === 'none') {
|
||||
throw new Error(
|
||||
`Expected to have snapshot \`${snapshotKey}\` but it was not found.`,
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const result = diff(snapshot, received) ?? 'Failed to compare output';
|
||||
if (result !== COMPARISON_EQUALS_STRING) {
|
||||
snapshotState.snapshotResults[snapshotKey] = {
|
||||
pass: false,
|
||||
value: received,
|
||||
};
|
||||
|
||||
if (updateSnapshot !== 'all') {
|
||||
throw new Error(`Expected to match snapshot.\n${result}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
snapshotState.snapshotResults[snapshotKey] = {pass: true};
|
||||
}
|
||||
|
||||
getSnapshotResults(): TestSnapshotResults {
|
||||
return {...this.#snapshotState?.snapshotResults};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,68 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`expect toMatchSnapshot() primitive types 1`] = `null`
|
||||
|
||||
exports[`expect toMatchSnapshot() primitive types 2`] = `1`
|
||||
|
||||
exports[`expect toMatchSnapshot() primitive types 3`] = `"foo"`
|
||||
|
||||
exports[`expect toMatchSnapshot() complex types 1`] = `Object {
|
||||
exports[`expect toMatchSnapshot() complex types 1`] = `
|
||||
Object {
|
||||
"foo": "bar",
|
||||
}`
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`expect toMatchSnapshot() complex types 2`] = `<span>
|
||||
exports[`expect toMatchSnapshot() complex types 2`] = `
|
||||
<span>
|
||||
hello
|
||||
</span>`
|
||||
</span>
|
||||
`;
|
||||
|
||||
exports[`expect toMatchSnapshot() named snapshots: named snapshot 1`] = `Object {
|
||||
exports[`expect toMatchSnapshot() complex types 3`] = `[Function foo]`;
|
||||
|
||||
exports[`expect toMatchSnapshot() complex types 4`] = `
|
||||
Map {
|
||||
"foo" => "bar",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`expect toMatchSnapshot() complex types 5`] = `
|
||||
Set {
|
||||
1,
|
||||
2,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`expect toMatchSnapshot() complex types 6`] = `2025-01-02T00:00:00.000Z`;
|
||||
|
||||
exports[`expect toMatchSnapshot() complex types 7`] = `[Error]`;
|
||||
|
||||
exports[`expect toMatchSnapshot() complex types 8`] = `/asd/`;
|
||||
|
||||
exports[`expect toMatchSnapshot() complex types 9`] = `
|
||||
Promise {
|
||||
"_h": 0,
|
||||
"_i": 0,
|
||||
"_j": null,
|
||||
"_k": null,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`expect toMatchSnapshot() named snapshots: named snapshot 1`] = `
|
||||
Object {
|
||||
"a": "b",
|
||||
}`
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`expect toMatchSnapshot() primitive types 1`] = `undefined`;
|
||||
|
||||
exports[`expect toMatchSnapshot() primitive types 2`] = `null`;
|
||||
|
||||
exports[`expect toMatchSnapshot() primitive types 3`] = `true`;
|
||||
|
||||
exports[`expect toMatchSnapshot() primitive types 4`] = `1`;
|
||||
|
||||
exports[`expect toMatchSnapshot() primitive types 5`] = `1n`;
|
||||
|
||||
exports[`expect toMatchSnapshot() primitive types 6`] = `"foo"`;
|
||||
|
||||
exports[`expect toMatchSnapshot() primitive types 8`] = `Symbol(foo)`;
|
||||
|
||||
exports[`expect toMatchSnapshot() primitive types: multiline 7`] = `
|
||||
"foo
|
||||
bar"
|
||||
`;
|
||||
|
||||
@@ -517,14 +517,26 @@ describe('expect', () => {
|
||||
|
||||
describe('toMatchSnapshot()', () => {
|
||||
test('primitive types', () => {
|
||||
expect(undefined).toMatchSnapshot();
|
||||
expect(null).toMatchSnapshot();
|
||||
expect(true).toMatchSnapshot();
|
||||
expect(1).toMatchSnapshot();
|
||||
expect(BigInt(1)).toMatchSnapshot();
|
||||
expect('foo').toMatchSnapshot();
|
||||
expect('foo\nbar').toMatchSnapshot('multiline');
|
||||
expect(Symbol('foo')).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('complex types', () => {
|
||||
expect({foo: 'bar'}).toMatchSnapshot();
|
||||
expect(<span>hello</span>).toMatchSnapshot();
|
||||
expect(function foo() {}).toMatchSnapshot();
|
||||
expect(new Map([['foo', 'bar']])).toMatchSnapshot();
|
||||
expect(new Set([1, 2])).toMatchSnapshot();
|
||||
expect(new Date('2025-01-02')).toMatchSnapshot();
|
||||
expect(new Error()).toMatchSnapshot();
|
||||
expect(new RegExp('asd')).toMatchSnapshot();
|
||||
expect(new Promise(() => {})).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('named snapshots', () => {
|
||||
|
||||
Reference in New Issue
Block a user