gutenbergdocs/docs/contributors/code/testing-overview.md
2025-10-22 01:40:18 +08:00

27 KiB
Raw Blame History

最佳实践

若你正着手重构,快照会是个不错的选择。你可以将其作为分支的首个提交,并观察其演变过程。

快照本身并不体现我们的预期目标。快照最适合与描述期望的其他测试结合使用,如下例所示:

test( '当启用planets属性时应包含mars', () => {
	const { container } = render( <SolarSystem planets /> );

	// 快照将捕获意外变更
	expect( container ).toMatchSnapshot();

	// 这才是我们测试中真正期望发现的内容
	expect( screen.getByText( /mars/i ) ).toBeInTheDocument();
} );

另一项实用技巧是使用 toMatchDiffSnapshot 函数(由 snapshot-diff提供该函数支持仅对DOM两种状态间的差异生成快照。这种方法适用于测试属性变更对DOM产生的影响同时生成更精简的快照示例如下

test( '当启用isShady属性时应渲染更深背景色', () => {
	const { container } = render( <CardBody>正文</CardBody> );
	const { container: containerShady } = render(
		<CardBody isShady>正文</CardBody>
	);
	expect( container ).toMatchDiffSnapshot( containerShady );
} );

类似地,toMatchStyleDiffSnapshot 函数支持仅记录组件两种状态间关联样式的差异,如下例所示:

test( '应渲染外边距', () => {
	const { container: spacer } = render( <Spacer /> );
	const { container: spacerWithMargin } = render( <Spacer margin={ 5 } /> );
	expect( spacerWithMargin ).toMatchStyleDiffSnapshot( spacer );
} );

故障排查

某些使用refs的组件场景需要进行模拟。查阅以下文档了解更多

遇到此类情况时,可能会在尝试访问 ref.current 属性的代码行看到Jest报出的测试失败及 TypeError

调试Jest单元测试

执行 npm run test:unit:debug 将启动调试模式的测试,以便节点检查器客户端能够连接至进程并监控执行情况。在wp-scripts文档中可查看使用Google Chrome或Visual Studio Code作为检查器客户端的指南。

原生移动端测试

单元测试套件包含一组基于React Native开发、用于验证原生移动代码路径的Jest测试。由于这些测试运行在Node环境可直接在开发机本地运行无需安装特定的Android或iOS原生开发工具或SDK。这也意味着可以使用常规开发工具进行调试。具体调试指南如下

调试原生移动端单元测试

本地调试模式运行测试需遵循以下步骤:

  1. 确保已执行 npm ci 安装所有依赖包
  2. 在CLI中进入Gutenberg根目录运行 npm run test:native:debug。此时Node正在等待调试器连接
  3. 在Chrome浏览器中打开 chrome://inspect
  4. 在“Remote Target”区域找到 ../../node_modules/.bin/jest 目标项点击“inspect”链接。这将打开附接到进程的新Chrome DevTools调试窗口并暂停在 jest.js 文件起始处。若未显示目标项,可点击同一页面中的 Open dedicated DevTools for Node 链接
  5. 可在代码(包括测试代码)中设置断点或 debugger; 语句来暂停执行并检查
  6. 点击“Play”按钮继续执行
  7. 尽享原生移动端单元测试的调试过程!

测试概述

Gutenberg 项目包含 PHP 和 JavaScript 代码,并鼓励对两者进行测试和代码规范检查。

为何需要测试?

除了测试将为生活带来的乐趣之外,测试的重要性不仅在于确保应用程序按预期运行,还在于它们提供了如何使用代码的简明示例。

测试也是代码库的一部分,这意味着我们对测试代码采用与应用程序代码相同的标准。

与所有代码一样,测试也需要维护。为了测试而编写测试并非目标——我们应努力在覆盖预期与非预期行为、执行速度与代码维护之间找到平衡点。

编写测试时请考虑以下问题:

  • 我们正在测试什么行为?
  • 运行此代码时可能出现哪些错误?
  • 测试是否验证了我们想要验证的内容?还是产生了误报/漏报?
  • 测试是否具备可读性?其他贡献者能否通过查看对应测试来理解代码行为?

JavaScript 测试

JavaScript 测试使用 Jest 作为测试运行器,并采用其提供的 全局 APIdescribetestbeforeEach 等)、断言方法模拟函数监视器模拟函数 API。如需测试 React 组件,还可使用 React Testing Library

需要注意的是,过去我们使用 Enzyme 进行 React 组件单元测试。但现在所有现有及新增测试均已改用 React Testing Library (RTL)。

若已按照 指南 安装 Node 和项目依赖,可通过 NPM 在命令行中运行测试:

npm test

代码规范检查是通过静态代码分析来强制执行编码标准并避免潜在错误。本项目使用 ESLintTypeScript 的 JavaScript 类型检查 来发现此类问题。虽然上述 npm test 会同时执行单元测试和代码检查,但也可通过运行 npm run lint 单独进行代码规范验证。部分 JavaScript 问题可通过运行 npm run lint:js:fix 自动修复。

为提升开发效率,建议配置编辑器集成检查功能。详细信息请参阅 入门文档

若需仅运行单元测试而不执行规范检查,请使用 npm run test:unit

目录结构

请将测试文件保存在工作目录的 test 文件夹中。测试文件应与被测试文件同名。

+-- test
|   +-- bar.js
+-- bar.js

只有测试文件(至少包含一个测试用例)应直接存放在 /test 目录下。如需添加外部模拟数据或固定装置,请将其置于子文件夹中,例如:

  • test/mocks/[文件名].js
  • test/fixtures/[文件名].js

测试导入

根据上述目录结构,在导入 被测试代码 时请尽量使用相对路径,而非项目路径。

推荐做法

import { bar } from '../bar';

不推荐做法

import { bar } from 'components/foo/bar';

这将使您在决定将代码迁移至应用目录其他位置时更加轻松。

测试描述

使用 describe 代码块对测试用例进行分组。每个测试用例理想情况下应仅描述一种行为。

在测试用例中,请尝试用通俗语言描述预期行为。对于 UI 组件,这可能需要从用户角度描述预期行为,而非解释代码内部实现。

推荐写法

describe( 'CheckboxWithLabel', () => {
    test( '勾选复选框应禁用表单提交按钮', () => {
        ...
    } );
} );

不推荐写法

describe( 'CheckboxWithLabel', () => {
    test( '勾选复选框应将 this.state.disableButton 设置为 `true`', () => {
        ...
    } );
} );

快照测试

本文概述了快照测试及其最佳实践方法。

快速指南:快照失败处理

当快照测试失败时,仅表示组件的渲染结果发生了变化。若这是意外变动,则快照测试成功拦截了一个程序缺陷 😊

但若属于预期变更,请按以下步骤更新快照:

# 使用 --testPathPattern 可加速测试过程(仅运行匹配的测试用例)
npm run test:unit -- --updateSnapshot --testPathPattern 测试路径

# 更新端到端测试的快照
npm run test:e2e -- --update-snapshots 测试规范路径
  1. 仔细核对差异内容,确认变更符合预期
  2. 提交更新后的快照文件

快照本质解析

快照是测试生成数据结构的序列化记录。这些快照文件与测试代码共同存储在版本库中。执行测试时,系统会将实时生成的数据结构与存储的快照版本进行比对。

创建快照非常简单:

test( 'foobar测试示例', () => {
	const foobar = { foo: 'bar' };

	expect( foobar ).toMatchSnapshot();
} );

对应生成的快照文件内容:

exports[ `测试foobar测试示例 1` ] = `
  对象 {
    "foo": "bar",
  }
`;

注意:严禁手动创建或修改快照文件,它们必须通过测试流程自动生成和更新。

优势分析

  • 测试代码编写极其简洁
  • 有效预防意外变更
  • 操作流程简单直观
  • 无需启动应用即可探查内部结构

局限说明

  • 缺乏表达能力
  • 仅能检测发生变更的问题
  • 对非确定性场景支持不佳

适用场景

快照测试主要应用于组件测试领域。它能敏锐感知组件结构变化,因此特别适合重构场景。若能在系列提交中持续维护快照,其差异记录将清晰展现组件结构的演进历程,堪称精妙 😎

import { render, screen } from '@testing-library/react';
import SolarSystem from 'solar-system';

describe( '太阳系组件', () => {
	test( '基础渲染测试', () => {
		const { container } = render( <SolarSystem /> );

		expect( container ).toMatchSnapshot();
	} );

	test( '启用行星参数时应包含火星', () => {
		const { container } = render( <SolarSystem planets /> );

		expect( container ).toMatchSnapshot();
		expect( screen.getByText( /mars/i ) ).toBeInTheDocument();
	} );
} );

Reducer测试同样适合快照方案。这类测试常涉及大规模复杂数据结构且不应发生意外变动——这正是快照技术的优势所在

实践技巧

持续集成环境可能因快照不匹配而中断测试。若变更为预期修改,请按指南更新快照。最快捷的方式是使用Jest的--updateSnapshot参数:

npm run test:unit -- --updateSnapshot --test路径模式

虽然--testPathPattern非必选参数,但指定路径能通过限定测试范围显著提升效率。

建议开发时在后台持续运行npm run test:unit:watch。Jest会自动运行与变更文件相关的测试当快照测试失败时仅需按下u键即可即时更新快照!

注意事项

非确定性测试可能产生不一致的快照,需要特别警惕。在处理随机数、时间相关或其他非确定性因素时,快照测试会面临挑战。

连接组件的测试需要特殊处理。要对已连接的组件进行快照测试,建议导出未连接状态的原始组件:

// 组件文件 my-component.js
export { MyComponent };
export default connect( mapStateToProps )( MyComponent );

// 测试文件 test/my-component.js
import { MyComponent } from '..';
// 对原始MyComponent执行测试...

需要手动提供连接所需的props参数这恰是审查连接状态的良机。

原生移动端端到端测试

Gutenberg的贡献者会发现PR中包含了在Android和iOS上运行原生移动端E2E测试的持续集成流程。若需排查测试失败问题请查阅我们的持续集成中的原生移动端测试指南。关于在本地运行这些测试的更多信息,可在此处获取。

原生移动端集成测试

我们正在持续推进为原生移动项目添加集成测试的工作,使用的是react-native-testing-library库。编写集成测试的指南可在此处找到。

端到端测试

目前大多数现有的端到端测试使用Puppeteer作为无头Chromium驱动packages/e2e-tests中运行测试,并仍由Jest测试运行器执行。

我们正在推进一个项目将这些测试从Puppeteer迁移到Playwright。建议尽可能使用Playwright编写新的端到端测试。以下部分主要适用于旧的Jest + Puppeteer框架。若使用Playwright编写测试请参阅专用指南

使用wp-env环境

如果使用内置的本地环境,可通过以下命令在本地运行端到端测试:

npm run test:e2e

或交互式运行:

npm run test:e2e:watch

有时在运行测试时观察浏览器行为会很有帮助。此时可使用:

npm run test:e2e:watch -- --puppeteer-interactive

可通过--puppeteer-slowmo控制执行速度:

npm run test:e2e:watch -- --puppeteer-interactive --puppeteer-slowmo=200

还可启用开发者工具,在浏览器中进行交互式调试:

npm run test:e2e:watch -- --puppeteer-devtools

使用其他环境

若使用wp-env之外的其他设置,需要先将端到端测试插件软链接到测试站点。从站点的插件目录运行:

ln -s gutenberg/packages/e2e-tests/plugins/* .

运行测试时需指定站点的基础URL、用户名和密码。例如若测试站点为http://wp.test,则使用:

WP_BASE_URL=http://wp.test npm run test:e2e -- --wordpress-username=admin --wordpress-password=password

场景测试

若发现端到端测试在本地通过但在GitHub Actions中失败可通过模拟低速CPU或网络来隔离CPU或网络相关的竞态条件

THROTTLE_CPU=4 npm run test:e2e

THROTTLE_CPU为减速系数此示例中为4倍减速

参阅Chrome文档setCPUThrottlingRate

SLOW_NETWORK=true npm run test:e2e

SLOW_NETWORK模拟相当于Chrome开发者工具中"快速3G"的网络速度。

参阅Chrome文档emulateNetworkConditionsNetworkManager.js

OFFLINE=true npm run test:e2e

OFFLINE模拟网络断开情况。

参阅Chrome文档emulateNetworkConditions

核心区块测试

每个核心区块必须至少包含一套用于主要保存功能的测试固件文件,以及针对每个废弃功能的独立测试固件。这些测试固件用于验证区块的解析与序列化功能。更多详细信息及操作指南请参阅集成测试固件说明文档

不稳定性测试

当某个测试在未经代码修改的情况下,经过多次重试运行时出现时而过时而不通过的情况,该测试即被视为不稳定性测试。我们在持续集成环境中最多会自动重试失败测试两次,通过report-flaky-tests GitHub Action自动检测这类测试并将其提交至GitHub问题区标记为[Type] Flaky Test标签。请注意,连续失败三次的测试不会被判定为不稳定性测试,也不会被提交至问题区。

PHP测试

PHP测试采用PHPUnit作为测试框架。若使用内置的本地环境可通过以下命令在本地运行PHP测试

npm run test:php

若需在文件变更时自动重新运行测试类似Jest功能请执行

npm run test:php:watch

注意phpunit命令需要wp-env处于运行状态且composer依赖已安装。若wp-env未运行包脚本将自动启动该环境。

在其他环境中,请运行composer run testcomposer run test:watch命令。

PHP代码规范通过PHP_CodeSniffer进行校验。建议通过Composer安装PHP_CodeSniffer及WordPress PHP编码标准规则集。安装Composer后在项目目录下运行composer install即可安装依赖。前述的npm run test:php命令将同时执行单元测试和代码规范检查。若需单独验证代码规范,可运行npm run lint:php

若需仅运行单元测试(不包含规范检查),请使用npm run test:unit:php命令。

性能测试

为确保编辑器在功能增补过程中始终保持高性能,我们持续监控拉取请求和版本发布对以下关键指标的影响:

  • 编辑器加载时长
  • 输入时浏览器响应时长
  • 区块选中时长

性能测试通过端到端测试运行编辑器并采集这些指标。执行前请确保已配置好端到端测试环境。

配置端到端测试环境时请先检出Gutenberg代码库并切换至待测试分支随后运行以下命令完成环境准备

nvm use && npm install
npm run build

执行测试请运行以下命令:

npm run test:performance

此命令将输出当前分支/代码在运行环境中的测试结果。

此外,您还可以通过./bin/plugin/cli.js perf [分支名]命令对比不同分支(或标签/提交)间的指标差异,例如:

./bin/plugin/cli.js perf trunk v8.1.0 v8.0.0

最后,您可通过--tests-branch参数指定要运行的性能测试文件所属分支。这在修改/扩展性能测试时特别有用:

./bin/plugin/cli.js perf trunk v8.1.0 v8.0.0 --tests-branch add/perf-tests-coverage

注意 此基准测试可能需要较长时间。运行期间请避免操作计算机或运行过多后台进程,以尽量减少可能影响跨分支测试结果的外部因素。

设置与清理方法

Jest API 包含一些实用的设置与清理方法,允许您在每项测试前后、所有测试前后或特定 describe 代码块内的测试前后执行任务。

这些方法支持异步代码,可实现常规行内代码无法完成的设置。与独立测试用例类似,您可以返回 Promise 对象Jest 将等待其状态落定:

// 为*所有*测试执行一次性设置
beforeAll( () =>
  someAsyncAction().then( ( resp ) => {
    window.someGlobal = resp;
  } )
);

// 为*所有*测试执行一次性清理
afterAll( () => {
  window.someGlobal = null;
} );

afterEachafterAll 提供了在测试后进行「清理」的完美(且推荐)方式,例如通过重置状态数据来实现。

请避免在断言之后放置清理代码,因为如果其中任何测试失败,清理操作将不会执行,并可能导致无关测试出现故障。

模拟依赖项

依赖注入

将依赖项作为参数传递给函数通常能使代码更易于测试。在可能的情况下,请避免引用更高作用域中的依赖项。

欠佳实践

import VALID_VALUES_LIST from './constants';

function isValueValid( value ) {
  return VALID_VALUES_LIST.includes( value );
}

此时我们需要导入并使用 VALID_VALUES_LIST 中的值才能通过测试:

expect( isValueValid( VALID_VALUES_LIST[ 0 ] ) ).toBe( true );

上述断言同时测试了两个行为1函数能检测列表中的项目2函数能检测 VALID_VALUES_LIST 中的项目。

但如果我们不关心 VALID_VALUES_LIST 中存储的内容,或者该列表是通过 HTTP 请求获取的,而我们只想测试 isValueValid 能否检测列表中的项目呢?

推荐实践

function isValueValid( value, validValuesList = [] ) {
  return validValuesList.includes( value );
}

通过将列表作为参数传递,我们可以在测试中传入模拟的 validValuesList 值,同时还能额外测试更多场景:

expect( isValueValid( 'hulk', [ 'batman', 'superman' ] ) ).toBe( false );

expect( isValueValid( 'hulk', null ) ).toBe( false );

expect( isValueValid( 'hulk', [] ) ).toBe( false );

expect( isValueValid( 'hulk', [ 'iron man', 'hulk' ] ) ).toBe( true );

导入的依赖项

当代码在多个位置使用来自外部和内部库的方法及属性时,通过参数传递会显得混乱且不切实际。对于这种情况,jest.mock 提供了一种简洁的存根化解决方案。

例如,假设我们有一个通过特性标志控制大量功能的 config 模块:

// bilbo.js
import config from 'config';
export const isBilboVisible = () =>
  config.isEnabled( 'the-ring' ) ? false : true;

为了测试不同条件下的行为,我们存根化配置对象并使用 Jest 模拟函数来控制 isEnabled 的返回值:

// test/bilbo.js
import { isEnabled } from 'config';
import { isBilboVisible } from '../bilbo';

jest.mock( 'config', () => ( {
  // 比尔博默认可见
  isEnabled: jest.fn( () => false ),
} ) );

describe( '比尔博模块', () => {
  test( '比尔博默认应可见', () => {
    expect( isBilboVisible() ).toBe( true );
  } );

  test( '当启用 `the-ring` 配置特性标志时,比尔博应不可见', () => {
    isEnabled.mockImplementationOnce( ( name ) => name === 'the-ring' );
    expect( isBilboVisible() ).toBe( false );
  } );
} );

测试全局对象

我们可以使用 Jest 监控器来测试调用全局方法的代码:

import { myModuleFunctionThatOpensANewWindow } from '../my-module';

describe( '我的模块', () => {
  beforeAll( () => {
    jest.spyOn( global, 'open' ).mockImplementation( () => true );
  } );

  test( '特定功能', () => {
    myModuleFunctionThatOpensANewWindow();
    expect( global.open ).toHaveBeenCalled();
  } );
} );

用户交互模拟

通过模拟用户交互来从用户视角编写测试是绝佳实践,能够有效避免测试实现细节。

使用 Testing Library 编写测试时,主要有两种模拟用户交互的方式:

  1. fireEvent APITesting Library 核心 API 中用于触发 DOM 事件的工具
  2. user-eventTesting Library 的配套库,通过模拟浏览器中真实交互时触发的事件来模拟用户操作

内置的 fireEvent 是用于派发 DOM 事件的工具。它会精确触发测试规范中描述的事件——即使这些事件在真实浏览器交互中从未被触发过。

user-event 库则提供了更高级的方法(如 typeselectOptionscleardoubleClick...),这些方法会像真实用户与文档交互时那样派发事件,并处理所有 React 相关的特殊行为。

基于以上原因,在为用户交互编写测试时,推荐使用 user-event

不够理想:使用 fireEvent 派发 DOM 事件

import { render, screen } from '@testing-library/react';

test( '输入新值时触发 onChange 事件', () => {
	const spyOnChange = jest.fn();

	// 包含一个 input 和一个 select 的组件
	render( <MyComponent onChange={ spyOnChange } /> );

	const input = screen.getByRole( 'textbox' );
	input.focus();
	// 没有点击事件,没有按键事件
	fireEvent.change( input, { target: { value: 62 } } );

	// onChange 回调被调用一次,参数为 '62'
	expect( spyOnChange ).toHaveBeenCalledTimes( 1 );

	const select = screen.getByRole( 'listbox' );
	select.focus();
	// 未派发指针事件
	fireEvent.change( select, { target: { value: 'optionValue' } } );

	// ...

推荐做法:使用 user-event 模拟用户事件

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

test( '输入新值时触发 onChange 事件', async () => {
	const user = userEvent.setup();

	const spyOnChange = jest.fn();

	// 包含一个 input 和一个 select 的组件
	render( <MyComponent onChange={ spyOnChange } /> );

	const input = screen.getByRole( 'textbox' );
	// 聚焦元素,选中并清空所有内容
	await user.clear( input );
	// 点击元素,逐个字符输入(生成 keydown、keypress 和 keyup 事件)
	await user.type( input, '62' );

	// onChange 回调被调用 3 次,参数依次为:
	// - 1: clear ('')
	// - 2: '6'
	// - 3: '62'
	expect( spyOnChange ).toHaveBeenCalledTimes( 3 );

	const select = screen.getByRole( 'listbox' );
	// 派发聚焦、指针、鼠标、点击和变更事件
	await user.selectOptions( select, [ 'optionValue' ] );

	// ...
} );

区块界面集成测试

集成测试是指将不同部件作为整体进行测试的方法。在此场景下,我们需要测试的是特定区块或编辑器逻辑需要渲染的各个组件。最终这些测试与单元测试非常相似,因为它们都使用 Jest 库通过相同命令运行。主要区别在于集成测试中的区块运行在特殊的区块编辑器实例中。

这种方法的优势在于,无需启动完整的端到端测试框架即可测试区块编辑器的大部分功能(如区块工具栏和检查器面板交互等)。这意味着测试运行速度更快、可靠性更高。建议尽可能使用集成测试覆盖区块的界面功能,而将对完整浏览器环境有需求的交互(例如文件上传、拖放操作等)留给端到端测试。

封面区块就是运用此级别测试的典型范例,该测试覆盖了编辑器绝大部分的交互场景。

配置集成测试的 Jest 文件:

import { initializeEditor } from 'test/integration/helpers/integration-test-editor';

async function setup( attributes ) {
	const testBlock = { name: 'core/cover', attributes };
	return initializeEditor( testBlock );
}

initializeEditor 函数返回 @testing-library/reactrender 方法输出结果。该函数也支持接收区块元数据对象数组,允许您设置包含多个区块的编辑器。

集成测试编辑器模块还导出了 selectBlock 方法,可通过区块包装器上的 aria-label例如“区块: 封面”)来选择需要测试的区块。