跳至主要內容

9. 接入测试框架


9. 接入测试框架

摘要

  • 实现 test-utils
  • 接入测试框架
  • 编写测试用例

相关代码可在 git tag v1.9open in new window 查看

之前我们实现了 npm linkvite serve demos 两种调试方式,本节我们将实现第三种调试方式 -- 测试用例。

1. 实现 test-utils

test-utils 是 React 提供的一个官方测试工具库open in new window,用于帮助开发者编写和执行 React 组件的单元测试。该工具库提供了一组用于测试 React 组件的工具和实用函数,使得编写测试变得更加容易和高效。

test-utils 主要用于测试 React 组件的渲染和行为,而 React DOM 是负责处理 React 组件的渲染和底层 DOM 操作的核心库。因此可以将测试工具放在 react-dom 包中,使得测试工具与渲染过程密切相关,更容易与 React DOM 的功能集成。

新建 packages/react-dom/test-utils.ts 文件,它对外暴露一个 renderIntoContainer 方法,然后在 scripts/rollup/react-dom.config.js 增加 test-utils.ts 的打包配置:

test-utils.ts
// packages/react-dom/test-utils.ts
import { ReactElementType } from 'shared/ReactTypes';
import { createRoot } from 'react-dom/client';

export function renderIntoDocument(element: ReactElementType) {
	const div = document.createElement('div');
	return createRoot(div).render(element);
}

再给项目增加 babel 编译,将 jsx 编译成 javascript。

先安装 babel 相关的包:

pnpm i -D -w @babel/core @babel/preset-env @babel/plugin-transform-react-jsx
  • @babel/core: 是 Babel 工具链的核心模块,负责整体的转换流程和对代码进行 AST(抽象语法树)转换;
  • @babel/preset-env: 是一个 Babel 预设(preset),用于根据目标环境(浏览器、Node.js 等)自动确定需要应用的转换插件。它根据配置中的目标环境,选择并启用一组插件,以确保代码能在目标环境中正常运行;
  • @babel/plugin-transform-react-jsx:是用于将 JSX 语法转换为普通的 JavaScript 函数调用的插件。JSX 是 React 中用于声明 UI 的语法糖,而这个插件使得浏览器或 Node.js 可以理解并执行 JSX 语法;

然后在根目录新建 babel.config.js 文件,里面是 babel 的配置:

// babel.config.js
const { defaults } = require('jest-config');

module.exports = {
	...defaults,
	rootDir: process.cwd(),
	// 寻找测试用例忽略的文件夹
	modulePathIgnorePatterns: ['<rootDir>/.history'],
	// 依赖包的解析地址
	moduleDirectories: [
		// React 和 ReactDOM 包的地址
		'dist/node_modules',
		// 第三方依赖的地址
		...defaults.moduleDirectories
	],
	testEnvironment: 'jsdom'
};

2. 接入测试框架

测试环境使用 Jestopen in new window 测试框架搭建,使用包管理器安装 Jest 及其相关的包:

pnpm i -D -w jest
pnpm i -D -w jest-config
pnpm i -D -w jest-enviroment-jsdom
  • jest-config:提供了 Jest 的默认配置,以及一些常用的配置选项和预设;
  • jest-enviroment-jsdom:提供了一个使用 jsdom 实现的模拟 DOM 环境,用于运行测试时模拟浏览器环境;

再新建 scripts/jest/jest.config.js 文件,为 Jest 添加配置参数:

// scripts/jest/jest.config.js
const { defaults } = require('jest-config');

module.exports = {
	...defaults,
	rootDir: process.cwd(),
	// 寻找测试用例忽略的文件夹
	modulePathIgnorePatterns: ['<rootDir>/.history'],
	// 依赖包的解析地址
	moduleDirectories: [
		// React 和 ReactDOM 包的地址
		'dist/node_modules',
		// 第三方依赖的地址
		...defaults.moduleDirectories
	],
	testEnvironment: 'jsdom'
};

并在 package.json"scripts" 中增加 jest 的执行脚本:

"test": "jest --config scripts/jest/jest.config.js"

这时执行 pnpm test 就可以运行 Jest 进行测试。

3. 编写测试用例

React 官方的测试用例都保存在 packages/react/src/__tests__/open in new window,可以自行查看。

下面,我们来实现几个 ReactElement 的测试用例,新建 packages/react/__tests__/ReactElement-test.js 文件,内容如下:

👉 查看代码 👈
// packages/react/__tests__/ReactElement-test.js
/**
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @emails react-core
 */

'use strict';

let React;
let ReactDOM;
let ReactTestUtils;

describe('ReactElement', () => {
	let ComponentFC;
	let originalSymbol;

	beforeEach(() => {
		jest.resetModules();

		// Delete the native Symbol if we have one to ensure we test the
		// unpolyfilled environment.
		originalSymbol = global.Symbol;
		global.Symbol = undefined;

		React = require('react');
		ReactDOM = require('react-dom');
		ReactTestUtils = require('react-dom/test-utils');

		// NOTE: We're explicitly not using JSX here. This is intended to test
		// classic JS without JSX.
		ComponentFC = () => {
			return React.createElement('div');
		};
	});

	afterEach(() => {
		global.Symbol = originalSymbol;
	});

	it('uses the fallback value when in an environment without Symbol', () => {
		expect((<div />).$$typeof).toBe(0xeac7);
	});

	it('returns a complete element according to spec', () => {
		const element = React.createElement(ComponentFC);
		expect(element.type).toBe(ComponentFC);
		expect(element.key).toBe(null);
		expect(element.ref).toBe(null);

		expect(element.props).toEqual({});
	});

	it('allows a string to be passed as the type', () => {
		const element = React.createElement('div');
		expect(element.type).toBe('div');
		expect(element.key).toBe(null);
		expect(element.ref).toBe(null);
		expect(element.props).toEqual({});
	});

	it('returns an immutable element', () => {
		const element = React.createElement(ComponentFC);
		expect(() => (element.type = 'div')).not.toThrow();
	});

	it('does not reuse the original config object', () => {
		const config = { foo: 1 };
		const element = React.createElement(ComponentFC, config);
		expect(element.props.foo).toBe(1);
		config.foo = 2;
		expect(element.props.foo).toBe(1);
	});

	it('does not fail if config has no prototype', () => {
		const config = Object.create(null, { foo: { value: 1, enumerable: true } });
		const element = React.createElement(ComponentFC, config);
		expect(element.props.foo).toBe(1);
	});

	it('extracts key and ref from the config', () => {
		const element = React.createElement(ComponentFC, {
			key: '12',
			ref: '34',
			foo: '56'
		});
		expect(element.type).toBe(ComponentFC);
		expect(element.key).toBe('12');
		expect(element.ref).toBe('34');
		expect(element.props).toEqual({ foo: '56' });
	});

	it('extracts null key and ref', () => {
		const element = React.createElement(ComponentFC, {
			key: null,
			ref: null,
			foo: '12'
		});
		expect(element.type).toBe(ComponentFC);
		expect(element.key).toBe('null');
		expect(element.ref).toBe(null);
		expect(element.props).toEqual({ foo: '12' });
	});

	it('ignores undefined key and ref', () => {
		const props = {
			foo: '56',
			key: undefined,
			ref: undefined
		};
		const element = React.createElement(ComponentFC, props);
		expect(element.type).toBe(ComponentFC);
		expect(element.key).toBe(null);
		expect(element.ref).toBe(null);
		expect(element.props).toEqual({ foo: '56' });
	});

	it('ignores key and ref warning getters', () => {
		const elementA = React.createElement('div');
		const elementB = React.createElement('div', elementA.props);
		expect(elementB.key).toBe(null);
		expect(elementB.ref).toBe(null);
	});

	it('coerces the key to a string', () => {
		const element = React.createElement(ComponentFC, {
			key: 12,
			foo: '56'
		});
		expect(element.type).toBe(ComponentFC);
		expect(element.key).toBe('12');
		expect(element.ref).toBe(null);
		expect(element.props).toEqual({ foo: '56' });
	});

	it('merges an additional argument onto the children prop', () => {
		const a = 1;
		const element = React.createElement(
			ComponentFC,
			{
				children: 'text'
			},
			a
		);
		expect(element.props.children).toBe(a);
	});

	it('does not override children if no rest args are provided', () => {
		const element = React.createElement(ComponentFC, {
			children: 'text'
		});
		expect(element.props.children).toBe('text');
	});

	it('overrides children if null is provided as an argument', () => {
		const element = React.createElement(
			ComponentFC,
			{
				children: 'text'
			},
			null
		);
		expect(element.props.children).toBe(null);
	});

	it('merges rest arguments onto the children prop in an array', () => {
		const a = 1;
		const b = 2;
		const c = 3;
		const element = React.createElement(ComponentFC, null, a, b, c);
		expect(element.props.children).toEqual([1, 2, 3]);
	});

	// // NOTE: We're explicitly not using JSX here. This is intended to test
	// // classic JS without JSX.
	it('allows static methods to be called using the type property', () => {
		function StaticMethodComponent() {
			return React.createElement('div');
		}
		StaticMethodComponent.someStaticMethod = () => 'someReturnValue';

		const element = React.createElement(StaticMethodComponent);
		expect(element.type.someStaticMethod()).toBe('someReturnValue');
	});

	// // NOTE: We're explicitly not using JSX here. This is intended to test
	// // classic JS without JSX.
	it('identifies valid elements', () => {
		function Component() {
			return React.createElement('div');
		}

		expect(React.isValidElement(React.createElement('div'))).toEqual(true);
		expect(React.isValidElement(React.createElement(Component))).toEqual(true);

		expect(React.isValidElement(null)).toEqual(false);
		expect(React.isValidElement(true)).toEqual(false);
		expect(React.isValidElement({})).toEqual(false);
		expect(React.isValidElement('string')).toEqual(false);
		expect(React.isValidElement(Component)).toEqual(false);
		expect(React.isValidElement({ type: 'div', props: {} })).toEqual(false);

		const jsonElement = JSON.stringify(React.createElement('div'));
		expect(React.isValidElement(JSON.parse(jsonElement))).toBe(true);
	});

	// // NOTE: We're explicitly not using JSX here. This is intended to test
	// // classic JS without JSX.
	it('is indistinguishable from a plain object', () => {
		const element = React.createElement('div', { className: 'foo' });
		const object = {};
		expect(element.constructor).toBe(object.constructor);
	});

	it('does not warn for NaN props', () => {
		function Test() {
			return <div />;
		}

		const test = ReactTestUtils.renderIntoDocument(<Test value={+undefined} />);
		expect(test.props.value).toBeNaN();
	});

	// // NOTE: We're explicitly not using JSX here. This is intended to test
	// // classic JS without JSX.
	it('identifies elements, but not JSON, if Symbols are supported', () => {
		// Rudimentary polyfill
		// @eslint-
		// Once all jest engines support Symbols natively we can swap this to test
		// WITH native Symbols by default.
		/*eslint-disable */
		const REACT_ELEMENT_TYPE = function () {}; // fake Symbol
		// eslint-disable-line no-use-before-define
		const OTHER_SYMBOL = function () {}; // another fake Symbol
		/*eslint-enable */
		global.Symbol = function (name) {
			return OTHER_SYMBOL;
		};
		global.Symbol.for = function (key) {
			if (key === 'react.element') {
				return REACT_ELEMENT_TYPE;
			}
			return OTHER_SYMBOL;
		};

		jest.resetModules();

		React = require('react');

		function Component() {
			return React.createElement('div');
		}

		expect(React.isValidElement(React.createElement('div'))).toEqual(true);
		expect(React.isValidElement(React.createElement(Component))).toEqual(true);

		expect(React.isValidElement(null)).toEqual(false);
		expect(React.isValidElement(true)).toEqual(false);
		expect(React.isValidElement({})).toEqual(false);
		expect(React.isValidElement('string')).toEqual(false);

		expect(React.isValidElement(Component)).toEqual(false);
		expect(React.isValidElement({ type: 'div', props: {} })).toEqual(false);

		const jsonElement = JSON.stringify(React.createElement('div'));
		expect(React.isValidElement(JSON.parse(jsonElement))).toBe(false);
	});
});

至此,我们就完成了测试环境搭建,并编写了 20 个 ReactElement 的测试用例。现在执行 pnpm build-dev 打包最新代码,再执行 pnpm test 即可运行测试,如果发现测试用例运行失败,可以查看测试输出以获取失败的测试用例的详细信息和堆栈跟踪,然后分析出现问题的原因。

搭建测试环境、编写测试用例以及定期运行测试,可以帮开发者发现代码中潜在的问题,对于提高代码质量、稳定性和可维护性有着积极的影响。

在测试过程中,关注测试覆盖率、运行时性能等方面也是重要的。使用持续集成(CI)工具,确保测试在每次提交或拉取请求时都能运行,有助于及时发现问题。

相关代码可在 git tag v1.9 查看,地址:https://github.com/2xiao/my-react/tree/v1.9open in new window