Add .toMatchSnapshot() (#48029)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/48029

Changelog: [Internal]

Adding snapshot support for rendered output only for now.
This will only work if snapshot is created beforehand by hand.

# Next steps
* Create snapshot when no prior snapshot is available
* Pass and update if instructed

Reviewed By: christophpurrer

Differential Revision: D66601387

fbshipit-source-id: fe528cded43c5ba36d314bd9af8e3fb84b98ac3e
This commit is contained in:
Andrew Datsenko
2024-12-09 19:09:57 -08:00
committed by Facebook GitHub Bot
parent a4a2c2867a
commit a8a136f3ef
8 changed files with 179 additions and 4 deletions
+1
View File
@@ -85,6 +85,7 @@
"inquirer": "^7.1.0",
"jest": "^29.6.3",
"jest-diff": "^29.7.0",
"jest-snapshot": "^29.7.0",
"jest-junit": "^10.0.0",
"jscodeshift": "^0.14.0",
"metro-babel-register": "^0.81.0",
+4 -1
View File
@@ -9,6 +9,7 @@
* @oncall react_native
*/
import type {SnapshotConfig} from '../runtime/snapshotContext';
import type {FantomTestConfigJsOnlyFeatureFlags} from './getFantomTestConfig';
module.exports = function entrypointTemplate({
@@ -16,11 +17,13 @@ module.exports = function entrypointTemplate({
setupModulePath,
featureFlagsModulePath,
featureFlags,
snapshotConfig,
}: {
testPath: string,
setupModulePath: string,
featureFlagsModulePath: string,
featureFlags: FantomTestConfigJsOnlyFeatureFlags,
snapshotConfig: SnapshotConfig,
}): string {
return `/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
@@ -47,6 +50,6 @@ ${Object.entries(featureFlags)
: ''
}
registerTest(() => require('${testPath}'));
registerTest(() => require('${testPath}'), ${JSON.stringify(snapshotConfig)});
`;
};
+24 -2
View File
@@ -24,6 +24,7 @@ import {
import fs from 'fs';
// $FlowExpectedError[untyped-import]
import {formatResultsErrors} from 'jest-message-util';
import {SnapshotState, buildSnapshotResolver} from 'jest-snapshot';
import Metro from 'metro';
import nullthrows from 'nullthrows';
import path from 'path';
@@ -90,12 +91,29 @@ function generateBytecodeBundle({
}
module.exports = async function runTest(
globalConfig: {...},
config: {...},
globalConfig: {
updateSnapshot: 'all' | 'new' | 'none',
...
},
config: {
rootDir: string,
prettierPath: string,
snapshotFormat: {...},
...
},
environment: {...},
runtime: {...},
testPath: string,
): mixed {
const snapshotResolver = await buildSnapshotResolver(config);
const snapshotPath = snapshotResolver.resolveSnapshotPath(testPath);
const snapshotState = new SnapshotState(snapshotPath, {
updateSnapshot: globalConfig.updateSnapshot,
snapshotFormat: config.snapshotFormat,
prettierPath: config.prettierPath,
rootDir: config.rootDir,
});
const startTime = Date.now();
const testConfig = getFantomTestConfig(testPath);
@@ -115,6 +133,10 @@ module.exports = async function runTest(
setupModulePath: `${path.relative(BUILD_OUTPUT_PATH, setupModulePath)}`,
featureFlagsModulePath: `${path.relative(BUILD_OUTPUT_PATH, featureFlagsModulePath)}`,
featureFlags: testConfig.flags.jsOnly,
snapshotConfig: {
updateSnapshot: snapshotState._updateSnapshot,
data: snapshotState._initialData,
},
});
const entrypointPath = path.join(
+28
View File
@@ -10,8 +10,12 @@
*/
import {ensureMockFunction} from './mocks';
import {snapshotContext} from './snapshotContext';
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
@@ -249,6 +253,30 @@ class Expect {
}
}
toMatchSnapshot(expected?: string): void {
if (this.#isNot) {
throw new ErrorWithCustomBlame(
'Snapshot matchers cannot be used with not.',
).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();
}
}
#isExpectedResult(pass: boolean): boolean {
return this.#isNot ? !pass : pass;
}
+10 -1
View File
@@ -9,8 +9,11 @@
* @oncall react_native
*/
import type {SnapshotConfig} from './snapshotContext';
import expect from './expect';
import {createMockFunction} from './mocks';
import {setupSnapshotConfig, snapshotContext} from './snapshotContext';
import nullthrows from 'nullthrows';
export type TestCaseResult = {
@@ -152,6 +155,7 @@ function executeTests() {
};
test.result = result;
snapshotContext.setTargetTest(result.fullName);
if (!test.isSkipped && (!hasFocusedTests || test.isFocused)) {
let status;
@@ -189,7 +193,12 @@ global.$$RunTests$$ = () => {
executeTests();
};
export function registerTest(setUpTest: () => void) {
export function registerTest(
setUpTest: () => void,
snapshotConfig: SnapshotConfig,
) {
setupSnapshotConfig(snapshotConfig);
runWithGuard(() => {
setUpTest();
});
+74
View File
@@ -0,0 +1,74 @@
/**
* 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
*/
export type SnapshotConfig = {
updateSnapshot: 'all' | 'new' | 'none',
data: {[key: string]: string},
};
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]];
}
}
class SnapshotContext {
#snapshotState: ?SnapshotState = null;
setTargetTest(testFullName: string) {
this.#snapshotState = new SnapshotState(testFullName);
}
getSnapshot(label: ?string): SnapshotResponse {
return (
this.#snapshotState?.getSnapshot(label) ?? [
'Snapshot state is not set, call `setTargetTest()` first',
undefined,
]
);
}
}
export const snapshotContext: SnapshotContext = new SnapshotContext();
export function setupSnapshotConfig(config: SnapshotConfig) {
snapshotConfig = config;
}
@@ -0,0 +1,19 @@
// 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 {
"foo": "bar",
}`
exports[`expect toMatchSnapshot() complex types 2`] = `<span>
hello
</span>`
exports[`expect toMatchSnapshot() named snapshots: named snapshot 1`] = `Object {
"a": "b",
}`
@@ -9,6 +9,8 @@
* @oncall react_native
*/
import * as React from 'react';
function ensureError(fn: () => void): void {
try {
fn();
@@ -512,4 +514,21 @@ describe('expect', () => {
expect(1).not.toBeGreaterThanOrEqual('string value');
}).toThrow();
});
describe('toMatchSnapshot()', () => {
test('primitive types', () => {
expect(null).toMatchSnapshot();
expect(1).toMatchSnapshot();
expect('foo').toMatchSnapshot();
});
test('complex types', () => {
expect({foo: 'bar'}).toMatchSnapshot();
expect(<span>hello</span>).toMatchSnapshot();
});
test('named snapshots', () => {
expect({a: 'b'}).toMatchSnapshot('named snapshot');
});
});
});