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:
Andrew Datsenko
2024-12-09 19:09:57 -08:00
committed by Facebook GitHub Bot
parent a8a136f3ef
commit a298ccabe2
7 changed files with 284 additions and 76 deletions
+15 -10
View File
@@ -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,
+100
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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', () => {