React 组件测试应该有趣、直观,并且易于人类构建和维护。
然而,当前的测试库生态系统不足以激励开发人员为 React 组件编写一致的 JavaScript 测试。测试 React 组件(以及 DOM 本身)通常需要在流行的测试运行器(如 Jest 或 Mocha)之上进行某种更高层的包装。
问题所在
使用当今可用的工具编写组件测试很无聊,即使你开始编写,它也需要很多麻烦。遵循类似 jQuery 风格(链式调用)的测试逻辑表达式很混乱。它与 React 组件的常见构建方式不一致。
下面的 Enzyme 代码可读性很好,但有点过于笨拙,因为它使用太多词语来表达最终只是简单标记的内容。
expect(screen.find(".view").hasClass("technologies")).to.equal(true);
expect(screen.find("h3").text()).toEqual("Technologies:");
expect(screen.find("ul").children()).to.have.lengthOf(4);
expect(screen.contains([
<li>JavaScript</li>,
<li>ReactJs</li>,
<li>NodeJs</li>,
<li>Webpack</li>
])).to.equal(true);
expect(screen.find("button").text()).toEqual("Back");
expect(screen.find("button").hasClass("small")).to.equal(true);
DOM 表示只是这样
<div className="view technologies">
<h3>Technologies:</h3>
<ul>
<li>JavaScript</li>
<li>ReactJs</li>
<li>NodeJs</li>
<li>Webpack</li>
</ul>
<button className="small">Back</button>
</div>
如果你需要测试更重的组件怎么办?虽然语法仍然可以忍受,但它并没有帮助你理解结构和逻辑。阅读和编写几个这样的测试肯定会让你精疲力尽——它确实让我精疲力尽。这是因为 React 组件遵循某些原则,最终生成 HTML 代码。另一方面,表达相同原则的测试并不直观。从长远来看,仅仅使用 JavaScript 链式调用无济于事。
在 React 中测试有两个主要问题
- 如何才能以一种专门针对组件的方式编写测试
- 如何避免所有不必要的噪音
让我们在深入实际示例之前进一步扩展这些问题。
React 组件测试方法
一个简单的 React 组件可能看起来像这样
function Welcome(props) {
return <h1>Hello, {props.name}</h1>;
}
这是一个接受 props
对象并使用 JSX 语法返回 DOM 节点的函数。
由于组件可以用函数表示,所以关键是测试函数。我们需要考虑参数以及它们如何影响返回的结果。将此逻辑应用于 React 组件,测试的重点应该是设置 props 并测试 UI 中呈现的 DOM。由于用户操作(如 mouseover
、click
、输入等)也可能导致 UI 变化,因此你需要找到一种方法以编程方式触发这些操作。
隐藏测试中的不必要噪音
测试需要一定程度的可读性,这可以通过简化措辞和遵循特定模式来描述每个场景来实现。
组件测试会经过三个阶段
- 准备: 准备组件 props。
- 执行: 组件需要将 DOM 渲染到 UI,并注册任何要以编程方式触发的用户操作(事件)。
- 断言: 设置预期结果,验证组件标记上的某些副作用。
这种单元测试模式被称为 准备-执行-断言。
以下是一个例子
it("should click a large button", () => {
// 1️⃣ Arrange
// Prepare component props
props.size = "large";
// 2️⃣ Act
// Render the Button's DOM and click on it
const component = mount(<Button {...props}>Send</Button>);
simulate(component, { type: "click" });
// 3️⃣ Assert
// Verify a .clicked class is added
expect(component, "to have class", "clicked");
});
对于更简单的测试,这些阶段可以合并
it("should render with a custom text", () => {
// Mixing up all three phases into a single expect() call
expect(
// 1️⃣ Preparation
<Button>Send</Button>,
// 2️⃣ Render
"when mounted",
// 3️⃣ Validation
"to have text",
"Send"
);
});
编写当今的组件测试
上面这两个例子看起来合乎逻辑,但实际上并非微不足道。大多数测试工具没有提供这种抽象级别,因此我们必须自己处理。也许下面的代码看起来更熟悉。
it("should display the technologies view", () => {
const container = document.createElement("div");
document.body.appendChild(container);
act(() => {
ReactDOM.render(<ProfileCard {...props} />, container);
});
const button = container.querySelector("button");
act(() => {
button.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
const details = container.querySelector(".details");
expect(details.classList.contains("technologies")).toBe(true);
});
将它与同一个测试进行比较,只是添加了一层抽象
it("should display the technologies view", () => {
const component = mount(<ProfileCard {...props} />);
simulate(component, {
type: "click",
target: "button",
});
expect(
component,
"queried for test id",
"details",
"to have class",
"technologies"
);
});
看起来确实更好。代码更少,流程更明显。这不是一个虚构的测试,而是你今天可以使用UnexpectedJS实现的测试。
以下部分将深入探讨测试 React 组件,但不会过于深入 UnexpectedJS。它的文档已经足够详细。相反,我们将专注于用法、示例和可能性。
使用 UnexpectedJS 编写 React 测试
UnexpectedJS 是一个可扩展的断言工具包,与所有测试框架兼容。它可以扩展插件,以下测试项目中使用了一些插件。这个库最棒的地方可能在于它提供了一个方便的语法,用于描述 React 中的组件测试用例。
示例:个人资料卡片组件
测试的主题是一个个人资料卡片组件。

以下是 ProfileCard.js
的完整组件代码
// ProfileCard.js
export default function ProfileCard({
data: {
name,
posts,
isOnline = false,
bio = "",
location = "",
technologies = [],
creationDate,
onViewChange,
},
}) {
const [isBioVisible, setIsBioVisible] = useState(true);
const handleBioVisibility = () => {
setIsBioVisible(!isBioVisible);
if (typeof onViewChange === "function") {
onViewChange(!isBioVisible);
}
};
return (
<div className="ProfileCard">
<div className="avatar">
<h2>{name}</h2>
<i className="photo" />
<span>{posts} posts</span>
<i className={`status ${isOnline ? "online" : "offline"}`} />
</div>
<div className={`details ${isBioVisible ? "bio" : "technologies"}`}>
{isBioVisible ? (
<>
<h3>Bio</h3>
<p>{bio !== "" ? bio : "No bio provided yet"}</p>
<div>
<button onClick={handleBioVisibility}>View Skills</button>
<p className="joined">Joined: {creationDate}</p>
</div>
</>
) : (
<>
<h3>Technologies</h3>
{technologies.length > 0 && (
<ul>
{technologies.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
)}
<div>
<button onClick={handleBioVisibility}>View Bio</button>
{!!location && <p className="location">Location: {location}</p>}
</div>
</>
)}
</div>
</div>
);
}
我们将使用组件的桌面版本。你可以阅读更多关于 设备驱动型代码拆分在 React 中的应用 的信息,但请注意,测试移动组件仍然非常直观。
设置示例项目
本文并未涵盖所有测试,但我们一定会查看最有趣的测试。如果你想跟着做,在浏览器中查看此组件,或检查其所有测试,请 克隆 GitHub 仓库。
## 1. Clone the project:
git clone [email protected]:moubi/profile-card.git
## 2. Navigate to the project folder:
cd profile-card
## 3. Install the dependencies:
yarn
## 4. Start and view the component in the browser:
yarn start
## 5. Run the tests:
yarn test
以下是如何在项目启动后构建 <ProfileCard />
组件和 UnexpectedJS 测试
/src
└── /components
├── /ProfileCard
| ├── ProfileCard.js
| ├── ProfileCard.scss
| └── ProfileCard.test.js
└── /test-utils
└── unexpected-react.js
组件测试
让我们看看一些组件测试。这些测试位于 src/components/ProfileCard/ProfileCard.test.js
中。请注意每个测试是如何按照我们之前介绍的三个阶段进行组织的。
- 为每个测试设置所需的组件 props。
beforeEach(() => {
props = {
data: {
name: "Justin Case",
posts: 45,
creationDate: "01.01.2021",
},
};
});
在每个测试之前,都会创建一个包含所需 <ProfileCard />
props 的 props
对象,其中 props.data
包含组件渲染所需的最小信息。
- 使用在线状态渲染。
现在我们检查个人资料是否使用“在线”状态图标进行渲染。

以及相应的测试用例
it("should display online icon", () => {
// Set the isOnline prop
props.data.isOnline = true;
// The minimum to test for is the presence of the .online class
expect(
<ProfileCard {...props} />,
"when mounted",
"queried for test id",
"status",
"to have class",
"online"
);
});
- 使用生物文本渲染。
<ProfileCard />
接受任何任意字符串作为其生物。

因此,让我们编写一个相应的测试用例
it("should display bio text", () => {
// Set the bio prop
props.data.bio = "This is a bio text";
// Testing if the bio string is rendered in the DOM
expect(
<ProfileCard {...props} />,
"when mounted",
"queried for test id",
"bio-text",
"to have text",
"This is a bio text"
);
});
- 使用空列表渲染“技术”视图。
点击“查看技能”链接应切换到该用户技术的列表。如果未传递任何数据,则该列表应为空。

以下是该测试用例
it("should display the technologies view", () => {
// Mount <ProfileCard /> and obtain a ref
const component = mount(<ProfileCard {...props} />);
// Simulate a click on the button element ("View Skills" link)
simulate(component, {
type: "click",
target: "button",
});
// Check if the details element contains a .technologies className
expect(
component,
"queried for test id",
"details",
"to have class",
"technologies"
);
});
- 渲染技术列表。
如果传递了技术列表,则在点击“查看技能”链接后,它将显示在 UI 中。

没错,另一个测试用例
it("should display list of technologies", () => {
// Set the list of technologies
props.data.technologies = ["JavaScript", "React", "NodeJs"];
// Mount ProfileCard and obtain a ref
const component = mount(<ProfileCard {...props} />);
// Simulate a click on the button element ("View Skills" link)
simulate(component, {
type: "click",
target: "button",
});
// Check if the list of technologies is present and matches the prop values
expect(
component,
"queried for test id",
"technologies-list",
"to satisfy",
{
children: [
{ children: "JavaScript" },
{ children: "React" },
{ children: "NodeJs" },
]
}
);
});
- 渲染用户位置。
该信息应仅在作为 props 传递的情况下才在 DOM 中渲染。

相应的测试用例
it("should display location", () => {
// Set the location
props.data.location = "Copenhagen, Denmark";
// Mount <ProfileCard /> and obtain a ref
const component = mount(<ProfileCard {...props} />);
// Simulate a click on the button element ("View Skills" link)
// Location render only as part of the Technologies view
simulate(component, {
type: "click",
target: "button",
});
// Check if the location string matches the prop value
expect(
component,
"queried for test id",
"location",
"to have text",
"Location: Copenhagen, Denmark"
);
});
- 在切换视图时调用回调函数。
此测试不会比较 DOM 节点,但会检查传递给 <ProfileCard />
的函数 prop 是否在切换生物和技术视图时使用正确的参数执行。
it("should call onViewChange prop", () => {
// Create a function stub (dummy)
props.data.onViewChange = sinon.stub();
// Mount ProfileCard and obtain a ref
const component = mount(<ProfileCard {...props} />);
// Simulate a click on the button element ("View Skills" link)
simulate(component, {
type: "click",
target: "button",
});
// Check if the stub function prop is called with false value for isBioVisible
// isBioVisible is part of the component's local state
expect(
props.data.onViewChange,
"to have a call exhaustively satisfying",
[false]
);
});
- 使用一组默认 props 渲染。
关于 DOM 比较的一点说明
在大多数情况下,你应该避免在测试中使用 DOM 细节。改为使用测试 ID。
如果出于某种原因你需要断言 DOM 结构,请参考以下示例。
此测试检查传递 name
、posts
和 creationDate
字段时组件生成的整个 DOM。
以下是结果在 UI 中的呈现方式

这是它的测试用例
it("should render default", () => {
// "to exhaustively satisfy" ensures all classes/attributes are also matching
expect(
<ProfileCard {...props} />,
"when mounted",
"to exhaustively satisfy",
<div className="ProfileCard">
<div className="avatar">
<h2>Justin Case</h2>
<i className="photo" />
<span>45{" posts"}</span>
<i className="status offline" />
</div>
<div className="details bio">
<h3>Bio</h3>
<p>No bio provided yet</p>
<div>
<button>View Skills</button>
<p className="joined">{"Joined: "}01.01.2021</p>
</div>
</div>
</div>
);
});
运行所有测试
现在,<ProfileCard />
的所有测试都可以通过一个简单的命令执行
yarn test

请注意,测试是分组的。每个 <ProfileCard />
视图(生物信息和技术)有两个独立的测试和两组测试。分组使测试套件更容易理解,并且是组织逻辑相关的 UI 单元的好方法。
一些最后的话
再说一次,这只是一个关于如何处理 React 组件测试的简单示例。关键是将组件视为接受道具并返回 DOM 的简单函数。从那时起,选择测试库应该基于它提供的用于处理组件渲染和 DOM 比较的工具的有用性。 根据我的经验,UnexpectedJS 在这方面非常出色。
你的下一步是什么? 如果您还没有,请查看 GitHub 项目并试一试!检查 ProfileCard.test.js
中的所有测试,并尝试编写一些自己的测试。您还可以查看 src/test-utils/unexpected-react.js
,这是一个从第三方测试库导出功能的简单辅助函数。
最后,这里还有一些我建议您查看的其他资源,以便更深入地了解 React 组件测试
- UnexpectedJS – UnexpectedJS 的官方页面和文档。也请查看插件部分。
- UnexpectedJS Gitter 房间 – 当您需要帮助或对维护者有特定问题时,这非常适合。
- 测试概述 – 您可以像测试其他 JavaScript 代码一样测试 React 组件。
- React Testing Library – 在 React 中编写组件测试的推荐工具。
- 函数组件与类组件有何不同 – Dan Abramov 描述了创建 React 组件的两种编程模型。
语法很好,但是鼓励比较 HTML 会导致 testing-library 试图阻止的事情——实现细节并不重要。如果我将
<ul>
替换为<ol>
,该组件仍然可以工作,并且如果数据呈现给用户,则测试应该通过。否则,我们不妨使用快照测试。
但是,您并不真正受限于使用该库测试 HTML 输出。坚持使用 testing-library 的方法是完全可以的。
但是,如果您交换
与
测试可能会通过,但 UI 仍然会损坏。我同意,我们可以使用快照来涵盖这种情况。我只是想知道——这是否值得不必要的麻烦……
您个人认为以下几点是否有价值?或者您如何以更好的方式实现同样的效果?
我真的不喜欢将字符串文字用作测试代码。
我的 IDE 无法帮助我自动完成它!
+1 支持 Dustin 的 IDE 评论。
随着维护的进行,优势会越来越大。
想象一下,快速浏览一个文件,其中包含许多以以下格式编写的断言
expect(screen.getByRole('alert')).toHaveTextContent('Oops, failed to fetch!')
或expect(screen.getByRole('button')).not.toHaveAttribute('disabled')
……简直是噩梦 :)但是,如果您交换
<ul />
与<ol />
。抱歉造成混淆。
在生物信息文本案例中,与在线状态测试中显示的代码相同。
眼睛尖锐。已修复。
我无法理解对标记顶部的这种繁重的断言进行的价值。感觉就像我编写了两个组件:一个在组件本身,另一个在测试中。
我同意更改标签可能会破坏 UI,但就此而言,快照测试似乎更容易且更简洁。
但我确实同意,不必在
act
上包装事件似乎相当不错——非常像我们在 VueJS 测试中所做的那样。我明白你的意思。这种混合了标记的测试应该很少使用(我主要将它用于默认情况),并尽量避免实现细节。
似乎人们都在追求
testing-library
,但一切都有权衡取舍。我们将看看它是否是一个稳定的趋势。到目前为止,我个人更喜欢上面的语法,因为它感觉比大多数库中类似 jQuery 的语法更自然。
我认为您不小心将
4. Render with bio text
的代码部分与3. Render with status online
的代码部分相同。同意前面的评论,即针对预期 DOM 树进行测试既脆弱又对确定组件是否“工作”没有特别的帮助。
至于它“看起来”如何,对我有用的唯一解决方案是在 Storybook 中开发组件。编写良好的故事可以快速发现视觉回归,并且有一些工具可以执行自动视觉比较,虽然我还没有采取这一步。
对于与 DOM 结构无关的测试,您可以使用以下语法