mirror of
https://github.com/facebook/react-native.git
synced 2025-11-01 09:14:26 +00:00
Add react-native-test-renderer package (#42644)
Summary: The goal is to provide testing utilities that use Fabric and concurrent rendering by default to support the new RN architecture. Currently most testing is done through ReactTestRenderer, which is an overly-simplified rendering environment, non-concurrent by default, and over exposes internals. A dedicated RN environment in JS can allow for more realistic test execution. This is the initial commit to create the `react-native-test-renderer` package. It currently only offers a simple toJSON() method on the root of a test, which is used for snapshot unit tests. We will be iterating here to add a query interface, event handling, and more. ## Changelog: [GENERAL] [ADDED] - Added react-native-test-renderer package for Fabric rendered integration tests Pull Request resolved: https://github.com/facebook/react-native/pull/42644 Test Plan: ``` $> cd packages/react-native-test-renderer $> yarn jest ``` Output: ``` PASS src/renderer/__tests__/render-test.js render toJSON ✓ returns expected JSON output based on renderer component (7 ms) Test Suites: 1 passed, 1 total 1 passed, 1 total Snapshots: 1 passed, 1 total Time: 2.869 s ``` Reviewed By: yungsters Differential Revision: D53183101 Pulled By: jackpope fbshipit-source-id: 8e29ba35f55f6c4eb2613ab106bc669d72f33d1d
This commit is contained in:
committed by
Facebook GitHub Bot
parent
2f818b464f
commit
c7479b07ae
@@ -34,6 +34,7 @@ module.exports = {
|
||||
'<rootDir>/packages/react-native/sdks',
|
||||
'<rootDir>/packages/react-native/Libraries/Renderer',
|
||||
'<rootDir>/packages/rn-tester/e2e',
|
||||
'<rootDir>/packages/react-native-test-renderer/src',
|
||||
],
|
||||
transformIgnorePatterns: ['node_modules/(?!@react-native/)'],
|
||||
haste: {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
presets: [
|
||||
['@babel/preset-env', {targets: {node: 'current'}}],
|
||||
'@babel/preset-flow',
|
||||
],
|
||||
plugins: ['@babel/plugin-transform-react-jsx'],
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
haste: {
|
||||
defaultPlatform: 'ios',
|
||||
platforms: ['android', 'ios', 'native'],
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.(js|ts|tsx)$': 'babel-jest',
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)/)',
|
||||
],
|
||||
setupFilesAfterEnv: ['./src/jest/setup-files-after-env'],
|
||||
testEnvironment: './src/jest/environment',
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@react-native/test-renderer",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"description": "A Test rendering library for React Native",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@babel/plugin-transform-react-jsx": "^7.0.0",
|
||||
"@babel/preset-env": "^7.20.0",
|
||||
"@babel/preset-flow": "^7.20.0"
|
||||
},
|
||||
"dependencies": {},
|
||||
"exports": {
|
||||
".": "./index.js",
|
||||
"./jest-environment": "./dist/jest-environment/index.js",
|
||||
"./jest-setup": "./dist/jest-setup/index.js"
|
||||
},
|
||||
"peerDependencies": { "jest": "^29.7.0" }
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const NodeEnv = require('jest-environment-node').TestEnvironment;
|
||||
|
||||
module.exports = class ReactNativeEnvironment extends NodeEnv {
|
||||
customExportConditions = ['require', 'react-native'];
|
||||
|
||||
constructor(config, context) {
|
||||
super(config, context);
|
||||
}
|
||||
|
||||
async setup() {
|
||||
await super.setup();
|
||||
this.assignGlobals();
|
||||
this.initializeTurboModuleRegistry();
|
||||
}
|
||||
|
||||
assignGlobals() {
|
||||
Object.defineProperties(this.global, {
|
||||
__DEV__: {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: true,
|
||||
writable: true,
|
||||
},
|
||||
});
|
||||
this.global.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
}
|
||||
|
||||
initializeTurboModuleRegistry() {
|
||||
const dims = {width: 100, height: 100, scale: 1, fontScale: 1};
|
||||
const DIMS = {
|
||||
screen: {
|
||||
...dims,
|
||||
},
|
||||
window: {
|
||||
...dims,
|
||||
},
|
||||
};
|
||||
this.global.nativeModuleProxy = name => ({})[name];
|
||||
this.global.__turboModuleProxy = name =>
|
||||
({
|
||||
SourceCode: {getConstants: () => ({scriptURL: ''})},
|
||||
WebSocketModule: {connect: () => {}},
|
||||
FileReaderModule: {},
|
||||
AppState: {getConstants: () => ({}), getCurrentAppState: () => ({})},
|
||||
DeviceInfo: {getConstants: () => ({Dimensions: DIMS})},
|
||||
UIManager: {getConstants: () => ({})},
|
||||
Timing: {},
|
||||
DevSettings: {},
|
||||
PlatformConstants: {
|
||||
getConstants: () => ({reactNativeVersion: '1000.0.0'}),
|
||||
},
|
||||
Networking: {},
|
||||
ImageLoader: {},
|
||||
NativePerformanceCxx: {},
|
||||
NativePerformanceObserverCxx: {},
|
||||
LogBox: {},
|
||||
SettingsManager: {
|
||||
getConstants: () => ({settings: {}}),
|
||||
},
|
||||
LinkingManager: {},
|
||||
})[name];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
jest.requireActual('@react-native/js-polyfills/error-guard');
|
||||
|
||||
jest
|
||||
.mock('react-native/Libraries/ReactNative/UIManager', () => ({
|
||||
AndroidViewPager: {
|
||||
Commands: {
|
||||
setPage: jest.fn(),
|
||||
setPageWithoutAnimation: jest.fn(),
|
||||
},
|
||||
},
|
||||
blur: jest.fn(),
|
||||
createView: jest.fn(),
|
||||
customBubblingEventTypes: {},
|
||||
customDirectEventTypes: {},
|
||||
getConstants: () => ({
|
||||
ViewManagerNames: [],
|
||||
}),
|
||||
getDefaultEventTypes: jest.fn(),
|
||||
dispatchViewManagerCommand: jest.fn(),
|
||||
focus: jest.fn(),
|
||||
getViewManagerConfig: jest.fn(name => {
|
||||
if (name === 'AndroidDrawerLayout') {
|
||||
return {
|
||||
Constants: {
|
||||
DrawerPosition: {
|
||||
Left: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {NativeProps: {}};
|
||||
}),
|
||||
hasViewManagerConfig: jest.fn(name => {
|
||||
return name === 'AndroidDrawerLayout';
|
||||
}),
|
||||
measure: jest.fn(),
|
||||
manageChildren: jest.fn(),
|
||||
removeSubviewsFromContainerWithID: jest.fn(),
|
||||
replaceExistingNonRootView: jest.fn(),
|
||||
setChildren: jest.fn(),
|
||||
updateView: jest.fn(),
|
||||
AndroidDrawerLayout: {
|
||||
Constants: {
|
||||
DrawerPosition: {
|
||||
Left: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
AndroidTextInput: {
|
||||
Commands: {},
|
||||
},
|
||||
ScrollView: {
|
||||
Constants: {},
|
||||
},
|
||||
View: {
|
||||
Constants: {},
|
||||
},
|
||||
}))
|
||||
// Mock modules defined by the native layer (ex: Objective-C, Java)
|
||||
.mock('react-native/Libraries/BatchedBridge/NativeModules', () => ({
|
||||
AlertManager: {
|
||||
alertWithArgs: jest.fn(),
|
||||
},
|
||||
AsyncLocalStorage: {
|
||||
multiGet: jest.fn((keys, callback) =>
|
||||
process.nextTick(() => callback(null, [])),
|
||||
),
|
||||
multiSet: jest.fn((entries, callback) =>
|
||||
process.nextTick(() => callback(null)),
|
||||
),
|
||||
multiRemove: jest.fn((keys, callback) =>
|
||||
process.nextTick(() => callback(null)),
|
||||
),
|
||||
multiMerge: jest.fn((entries, callback) =>
|
||||
process.nextTick(() => callback(null)),
|
||||
),
|
||||
clear: jest.fn(callback => process.nextTick(() => callback(null))),
|
||||
getAllKeys: jest.fn(callback =>
|
||||
process.nextTick(() => callback(null, [])),
|
||||
),
|
||||
},
|
||||
DeviceInfo: {
|
||||
getConstants() {
|
||||
return {
|
||||
Dimensions: {
|
||||
window: {
|
||||
fontScale: 2,
|
||||
height: 1334,
|
||||
scale: 2,
|
||||
width: 750,
|
||||
},
|
||||
screen: {
|
||||
fontScale: 2,
|
||||
height: 1334,
|
||||
scale: 2,
|
||||
width: 750,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
DevSettings: {
|
||||
addMenuItem: jest.fn(),
|
||||
reload: jest.fn(),
|
||||
},
|
||||
ImageLoader: {
|
||||
getSize: jest.fn(url => Promise.resolve([320, 240])),
|
||||
prefetchImage: jest.fn(),
|
||||
},
|
||||
ImageViewManager: {
|
||||
getSize: jest.fn((uri, success) =>
|
||||
process.nextTick(() => success(320, 240)),
|
||||
),
|
||||
prefetchImage: jest.fn(),
|
||||
},
|
||||
KeyboardObserver: {
|
||||
addListener: jest.fn(),
|
||||
removeListeners: jest.fn(),
|
||||
},
|
||||
Networking: {
|
||||
sendRequest: jest.fn(),
|
||||
abortRequest: jest.fn(),
|
||||
addListener: jest.fn(),
|
||||
removeListeners: jest.fn(),
|
||||
},
|
||||
PlatformConstants: {
|
||||
getConstants() {
|
||||
return {
|
||||
reactNativeVersion: {
|
||||
major: 1000,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
PushNotificationManager: {
|
||||
presentLocalNotification: jest.fn(),
|
||||
scheduleLocalNotification: jest.fn(),
|
||||
cancelAllLocalNotifications: jest.fn(),
|
||||
removeAllDeliveredNotifications: jest.fn(),
|
||||
getDeliveredNotifications: jest.fn(callback =>
|
||||
process.nextTick(() => []),
|
||||
),
|
||||
removeDeliveredNotifications: jest.fn(),
|
||||
setApplicationIconBadgeNumber: jest.fn(),
|
||||
getApplicationIconBadgeNumber: jest.fn(callback =>
|
||||
process.nextTick(() => callback(0)),
|
||||
),
|
||||
cancelLocalNotifications: jest.fn(),
|
||||
getScheduledLocalNotifications: jest.fn(callback =>
|
||||
process.nextTick(() => callback()),
|
||||
),
|
||||
requestPermissions: jest.fn(() =>
|
||||
Promise.resolve({alert: true, badge: true, sound: true}),
|
||||
),
|
||||
abandonPermissions: jest.fn(),
|
||||
checkPermissions: jest.fn(callback =>
|
||||
process.nextTick(() =>
|
||||
callback({alert: true, badge: true, sound: true}),
|
||||
),
|
||||
),
|
||||
getInitialNotification: jest.fn(() => Promise.resolve(null)),
|
||||
addListener: jest.fn(),
|
||||
removeListeners: jest.fn(),
|
||||
},
|
||||
StatusBarManager: {
|
||||
setColor: jest.fn(),
|
||||
setStyle: jest.fn(),
|
||||
setHidden: jest.fn(),
|
||||
setNetworkActivityIndicatorVisible: jest.fn(),
|
||||
setBackgroundColor: jest.fn(),
|
||||
setTranslucent: jest.fn(),
|
||||
getConstants: () => ({
|
||||
HEIGHT: 42,
|
||||
}),
|
||||
},
|
||||
Timing: {
|
||||
createTimer: jest.fn(),
|
||||
deleteTimer: jest.fn(),
|
||||
},
|
||||
UIManager: {},
|
||||
BlobModule: {
|
||||
getConstants: () => ({BLOB_URI_SCHEME: 'content', BLOB_URI_HOST: null}),
|
||||
addNetworkingHandler: jest.fn(),
|
||||
enableBlobSupport: jest.fn(),
|
||||
disableBlobSupport: jest.fn(),
|
||||
createFromParts: jest.fn(),
|
||||
sendBlob: jest.fn(),
|
||||
release: jest.fn(),
|
||||
},
|
||||
WebSocketModule: {
|
||||
connect: jest.fn(),
|
||||
send: jest.fn(),
|
||||
sendBinary: jest.fn(),
|
||||
ping: jest.fn(),
|
||||
close: jest.fn(),
|
||||
addListener: jest.fn(),
|
||||
removeListeners: jest.fn(),
|
||||
},
|
||||
I18nManager: {
|
||||
allowRTL: jest.fn(),
|
||||
forceRTL: jest.fn(),
|
||||
swapLeftAndRightInRTL: jest.fn(),
|
||||
getConstants: () => ({
|
||||
isRTL: false,
|
||||
doLeftAndRightSwapInRTL: true,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`render toJSON returns expected JSON output based on renderer component 1`] = `
|
||||
"<>
|
||||
<RCTView>
|
||||
<RCTText accessible=true allowFontScaling=true ellipsizeMode="tail" isHighlighted=false selectionColor=null>
|
||||
<RCTRawText text="Hello" />
|
||||
</RCTText>
|
||||
<RCTView />
|
||||
</RCTView>
|
||||
</>"
|
||||
`;
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @format
|
||||
* @flow
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import * as ReactNativeTestRenderer from '../index';
|
||||
import * as React from 'react';
|
||||
import {Text, View} from 'react-native';
|
||||
import 'react-native/Libraries/Components/View/ViewNativeComponent';
|
||||
|
||||
function TestComponent() {
|
||||
return (
|
||||
<View>
|
||||
<Text>Hello</Text>
|
||||
<View />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
describe('render', () => {
|
||||
describe('toJSON', () => {
|
||||
it('returns expected JSON output based on renderer component', () => {
|
||||
const result = ReactNativeTestRenderer.render(<TestComponent />);
|
||||
expect(result.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @format
|
||||
* @flow
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import type {Element, ElementType} from 'react';
|
||||
|
||||
import * as FabricUIManager from 'react-native/Libraries/ReactNative/__mocks__/FabricUIManager';
|
||||
import ReactFabric from 'react-native/Libraries/Renderer/shims/ReactFabric';
|
||||
import {act} from 'react-test-renderer';
|
||||
|
||||
type ReactNode = {
|
||||
children: Array<ReactNode>,
|
||||
props: {...},
|
||||
viewName: string,
|
||||
instanceHandle: {
|
||||
memoizedProps: {testID: ?string, ...},
|
||||
...
|
||||
},
|
||||
};
|
||||
|
||||
type RootReactNode = $ReadOnlyArray<ReactNode>;
|
||||
|
||||
type RenderResult = {
|
||||
toJSON: () => string,
|
||||
};
|
||||
|
||||
function buildRenderResult(rootNode: RootReactNode): RenderResult {
|
||||
return {
|
||||
toJSON: () => stringify(rootNode),
|
||||
};
|
||||
}
|
||||
|
||||
export function render(element: Element<ElementType>): RenderResult {
|
||||
const manager = FabricUIManager.getFabricUIManager();
|
||||
if (!manager) {
|
||||
throw new Error('No FabricUIManager found');
|
||||
}
|
||||
const containerTag = Math.round(Math.random() * 1000000);
|
||||
act(() => {
|
||||
ReactFabric.render(element, containerTag, () => {}, true);
|
||||
});
|
||||
|
||||
// $FlowFixMe
|
||||
const root: RootReactNode = manager.getRoot(containerTag);
|
||||
|
||||
if (root == null) {
|
||||
throw new Error('No root found for containerTag ' + containerTag);
|
||||
}
|
||||
|
||||
return buildRenderResult(root);
|
||||
}
|
||||
|
||||
function stringify(
|
||||
node: RootReactNode | ReactNode,
|
||||
indent: string = '',
|
||||
): string {
|
||||
const nextIndent = ' ' + indent;
|
||||
if (Array.isArray(node)) {
|
||||
return `<>
|
||||
${node.map(n => nextIndent + stringify(n, nextIndent)).join('\n')}
|
||||
</>`;
|
||||
}
|
||||
const children = node.children;
|
||||
const props = node.props
|
||||
? Object.entries(node.props)
|
||||
.map(([k, v]) => ` ${k}=${JSON.stringify(v) ?? ''}`)
|
||||
.join('')
|
||||
: '';
|
||||
|
||||
if (children.length > 0) {
|
||||
return `<${node.viewName}${props}>
|
||||
${children.map(c => nextIndent + stringify(c, nextIndent)).join('\n')}
|
||||
${indent}</${node.viewName}>`;
|
||||
} else {
|
||||
return `<${node.viewName}${props} />`;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import type {
|
||||
Spec as FabricUIManager,
|
||||
} from '../FabricUIManager';
|
||||
|
||||
import {createRootTag} from '../RootTag.js';
|
||||
|
||||
export type NodeMock = {
|
||||
children: NodeSet,
|
||||
instanceHandle: InternalInstanceHandle,
|
||||
@@ -160,6 +162,7 @@ function hasDisplayNone(node: Node): boolean {
|
||||
}
|
||||
|
||||
interface IFabricUIManagerMock extends FabricUIManager {
|
||||
getRoot(rootTag: RootTag | number): NodeSet;
|
||||
__getInstanceHandleFromNode(node: Node): InternalInstanceHandle;
|
||||
__addCommitHook(commitHook: UIManagerCommitHook): void;
|
||||
__removeCommitHook(commitHook: UIManagerCommitHook): void;
|
||||
@@ -638,6 +641,15 @@ const FabricUIManagerMock: IFabricUIManagerMock = {
|
||||
return 'RN:' + fromNode(node).viewName;
|
||||
}),
|
||||
|
||||
getRoot(containerTag: RootTag | number): NodeSet {
|
||||
const tag = createRootTag(containerTag);
|
||||
const root = roots.get(tag);
|
||||
if (!root) {
|
||||
throw new Error('No root found for containerTag ' + Number(tag));
|
||||
}
|
||||
return root;
|
||||
},
|
||||
|
||||
__getInstanceHandleFromNode(node: Node): InternalInstanceHandle {
|
||||
return fromNode(node).instanceHandle;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user