心法
写测试的目的是让我们重构代码和提测时更有信心,而不是为了写测试而写测试,甚至是花时间维护测试代码。
如果项目还在需求频繁变更,界面 UI 还不稳定,还在尝试不同的调整,写测试用例的收益是小于成本的。如果项目已经比较稳定,或者本来就是一个比较稳定的界面,比如工具类的 APP,UI 一般都是小调整,变更不会频繁,这种时候可以配上大量全面的测试用例。
什么时候写单元测试?
工具类这种高度与逻辑挂钩的,或者单一的公共组件,可以写单元测试。
什么时候写集成测试?
高度与业务挂钩的,比如 UI 组件中加了一堆业务代码,重点关注交互的,可以写集成测试。
实践
我们写一个 todo list,然后再补齐集成测试,最后进行重构并确保通过所有测试用例。
每条测试用例都遵循 given when then 的格式,即模拟数据、测试、检查断言。每条测试用例只关注输入输出,不包含逻辑。
这是我们要写的 todo list:
交互逻辑如下:
- 初始时,屏幕上有三个 todo
- 每个 todo 都有 Toggle 和 Remove 按钮,用于完成和删除 todo
- 如果点击 Toggle,则划去 todo,如果再点击一次 Toggle,则重置 todo
- 如果点击 Remove,则删除 todo
- 在 input box 输入 todo,按回车新增 todo
todolist 代码:
// App.tsx
import React, { useState } from "react";
import "./App.css";
interface TodoType {
text: string;
isCompleted?: boolean;
}
function App() {
const mockTodos: TodoType[] = [
{
text: "Go to gym",
isCompleted: false,
},
{
text: "Buy milk",
isCompleted: true,
},
{
text: "Sleep early",
isCompleted: false,
},
];
const [todos, setTodos] = useState<TodoType[]>(mockTodos);
const [currentInput, setCurrentInput] = useState<string>("");
const toggleTodo = (index: number) => {
const todo = todos[index];
todo.isCompleted = !todo.isCompleted;
setTodos([...todos]);
};
const removeTodo = (index: number) => {
todos.splice(index, 1);
setTodos([...todos]);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!currentInput) {
return;
}
const newTodo: TodoType = {
text: currentInput,
};
setTodos([...todos, newTodo]);
setCurrentInput("");
};
return (
<div>
{todos.map((todo, index) => {
return (
<div data-testid="todo-item" key={index}>
<span
style={{ textDecoration: todo.isCompleted ? "line-through" : "" }}
>
{todo.text}
<button
onClick={() => toggleTodo(index)}
data-testid="toggle-todo"
>
Toggle
</button>
<button
onClick={() => removeTodo(index)}
data-testid="remove-todo"
>
Remove
</button>
</span>
</div>
);
})}
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="What's your plan?"
value={currentInput}
onChange={(e) => setCurrentInput(e.target.value)}
/>
</form>
</div>
);
}
export default App;
写集成测试
针对上面的交互逻辑,可以写出下面的集成测试用例。我们跳过了单元测试只写集成测试,这是因为后面还要进行重构,而重构可能会破坏单元测试。
注意,下面的集成测试都是停留在 App 这个容器组件层面的,没有涉及到任何的代码实现细节,这样我们后面进行代码重构的时候,就不需要再修改测试用例了。
import { cleanup, render, screen, within } from "@testing-library/react";
import App from "./App";
import userEvent from "@testing-library/user-event";
import '@testing-library/jest-dom/extend-expect';
describe("todo list", () => {
afterEach(() => {
// reset the DOM after each test
cleanup();
});
test("should render default todo lists", () => {
/**
* render 组件,然后获取页面上相应的元素,最后进行判断
*/
// given
render(<App />);
// when
const todos = screen.getAllByTestId("todo-item");
// then
expect(todos).toHaveLength(3);
});
test("should add todo item", () => {
/**
* render 组件,模拟用户输入,获取页面上相应的元素,最后进行判断
*/
// given
render(<App />);
// when
userEvent.type(
screen.getByPlaceholderText("What's your plan?"),
"Call mom{enter}"
);
// then
const todos = screen.getAllByTestId("todo-item");
expect(todos).toHaveLength(4);
});
test("should remove todo item", () => {
/**
* render 组件,模拟用户按钮点击,获取页面上相应的元素,最后进行判断
*/
// given
render(<App />);
// then
userEvent.click(
within(screen.getByText("Go to gym")).getByTestId("remove-todo")
);
// when
const todos = screen.getAllByTestId("todo-item");
expect(todos).toHaveLength(2);
});
test("should toggle todo item", () => {
/**
* render 组件,模拟用户按钮点击,获取页面上相应的元素,最后判断 CSS 样式
*/
// given
render(<App />);
// when
const todoItem = within(screen.getByText("Go to gym"));
userEvent.click(todoItem.getByText("Toggle"));
// then
const todoItemAfterClick = screen.getByText("Go to gym");
expect(todoItemAfterClick).toHaveStyle("text-decoration: line-through");
// toggle again should remove the line-through style
userEvent.click(todoItem.getByText("Toggle"));
const todoItemAfterClickAgain = screen.getByText("Go to gym");
expect(todoItemAfterClickAgain).not.toHaveStyle("text-decoration: line-through");
});
});
可以看到测试用例全部通过
进行重构
有了上面的测试用例,我们再进行重构的时候就有很大的信心了。 现在我们可以开启 npm test,尽情的重构了~
重构完的 todo app 大概是这样
检查测试用例
重构完成后记得检查测试用例是否都通过了
Jest 配置踩坑
坑:jest 不识别 jsx 语法
解决方法:配置 babel 来转译 jsx 语法
// babel.config.json
{
"presets": ["@babel/preset-react"]
}
坑:jest 不识别 es6 的 import 语法
解决:配置 jest.config.js 来告诉 jest 用 babel 转译器
// jest.config.js
module.exports = {
preset: 'ts-jest',
transform: {
'^.+\\.(ts|tsx)?$': 'ts-jest',
"^.+\\.(js|jsx)$": "babel-jest",
}
};
坑:jest 不识别静态文件
解决:mock 静态文件
// jest.config.js
moduleNameMapper: {
"\\.(css|less)$": "identity-obj-proxy",
},
坑:wrong test environment
解决:jest 默认运行环境为 node,把运行环境改为 dom 环境
// jest.config.js
testEnvironment: 'jest-environment-jsdom'