React 前端中的 TDD
如今,只有少数专业开发人员对测试驱动开发和测试驱动设计(TDD)的价值抱有严重怀疑。但我见过的许多代码库的实际情况是,TDD通常局限于后端,也就是“业务逻辑”所在的地方。
部分原因在于人们普遍认为前端开发并非“真正的软件开发”,尽管在大多数情况下,一个功能齐全的后端如果没有匹配的前端是完全无法使用的。但另一部分原因在于缺乏在前端进行测试驱动开发 (TDD) 的技能。本文正是探讨这个问题。
我以 React 为例,因为它是我最熟悉的框架,而且它的声明式风格比使用纯 JavaScript、HTML 和 CSS 更容易进行某些测试。但本文中的大多数想法也适用于其他场景。
如果您对更多有关网络产品开发和创业的文章和新闻感兴趣,请随时在 Twitter 上关注我。
为什么前端测试比后端测试更难?
让前端工程师远离 TDD 的并非总是懒惰。这一点尤其体现在,那些全栈工程师虔诚地在后端代码中实践 TDD,却不为前端编写任何测试。
根据我的经验,差异可以归结为三点:
- 在前端,功能通常具有更大的接口。最简单的后端 API 可能由一个简单的 JSON 结构定义,但即使是最简单的前端功能,其定义不仅在于功能本身,还在于通常渲染到屏幕上的数千个像素。
- 更糟糕的是,我们还没有找到一个好的方法向机器解释哪些像素重要。对于某些机器来说,改变像素本身并没有什么区别,但如果改错了像素,功能就会完全无法使用。
- 长期以来,工具无法实现在几秒钟内运行的集成测试。相反,测试要么仅限于纯业务逻辑,要么在浏览器中运行,通常需要几分钟的设置时间。
那么我们该如何解决这个问题呢?
编写可测试的前端代码
就像你经常需要拆分后端代码并引入依赖注入才能测试一样,前端代码也应该拆分以便于测试。前端代码大致分为三类,每类都有不同的测试方法。
我们以一个经典的 React 待办事项应用为例。我建议在第二个屏幕上打开代码仓库并继续操作。为了方便使用手机阅读或无法访问代码仓库的用户,我在本文中添加了代码摘录。
胶水代码
App 组件和useTodos hooks就是我所说的胶水代码。它把其余代码“粘合”在一起,使功能得以实现:
const TodoApp: FunctionComponent = () => {
const { todos, addTodo, completeTodo, deleteTodo } = useTodos([]);
return (
<>
<TodoList
todos={todos}
onCompleteTodo={completeTodo}
onDeleteTodo={deleteTodo}
/>
<AddTodo onAdd={addTodo} />
</>
);
};
export function useTodos(initialTodos: Todo[]) {
const [todos, dispatch] = useReducer(todosReducer, initialTodos);
return {
todos,
addTodo: (description: string) =>
dispatch(createAddTodoAction(description)),
completeTodo: (id: Todo["id"]) => dispatch(createCompleteTodoAction(id)),
deleteTodo: (id: Todo["id"]) => dispatch(createDeleteTodoAction(id)),
};
}
与后端的控制器类似,最好使用集成测试进行测试:
describe("TodoApp", () => {
it("shows an added todo", async () => {
render(<App />);
const todoInput = screen.getByLabelText("New todo");
const todoDescription = "My new todo";
userEvent.type(todoInput, todoDescription);
const addTodoButton = screen.getByText("Add todo");
userEvent.click(addTodoButton);
expect(await screen.findByText(todoDescription)).toBeInTheDocument();
});
});
我之所以首先讨论这些测试,是因为这通常是我编写的第一类测试。Web 应用和落地页的区别在于,如果 Web 应用没有任何功能,只有外观,那么它就没有任何价值。这些测试描述了应用的行为,让我能够集中精力,只实现需要的部分。
这类集成测试应该尽可能独立于所使用的技术。上面的测试示例依赖于 React(如果我不使用 React 重写应用程序,我也必须更改测试),但仅此而已。无论我使用函数式组件、类组件、Redux 状态管理、外部表单库,还是使用 3 个或 300 个组件来构建待办事项应用程序,相同的测试都能正常工作。这一点非常重要,因为这意味着我可以安全地重构代码而无需修改测试。
原因是测试是从用户的角度编写的:找到标有“新待办事项”的内容,在其中输入新待办事项,按“添加待办事项”按钮,然后检查我刚刚写的待办事项是否显示在屏幕上。
业务逻辑
这些是后端测试人员最熟悉的测试。我们的待办事项应用的业务逻辑负责创建、删除待办事项,以及将待办事项标记为已完成。同样的操作也可以在后端使用。
export function todosReducer(todos: Todo[], action: TodoAction) {
switch (action.type) {
case TodoActionType.AddTodo:
return [...todos, action.payload];
case TodoActionType.CompleteTodo:
return todos.map((todo) =>
todo.id === action.payload.id ? { ...todo, completed: true } : todo
);
case TodoActionType.DeleteTodo:
return todos.filter((todo) => todo.id !== action.payload.id);
}
}
对这种代码的测试看似简单:
describe("todo reducer", () => {
describe("addTodoAction", () => {
it("adds a new todo to the list", () => {
const description = "This is a todo";
expect(todosReducer([], createAddTodoAction(description))).toContainEqual(
expect.objectContaining({ description })
);
});
it("does not remove an existing todo", () => {
const existingTodo = new TodoMock();
expect(
todosReducer([existingTodo], createAddTodoAction("This is a todo"))
).toContainEqual(existingTodo);
});
});
});
测试业务逻辑的难点不在于编写测试,而在于将业务逻辑与其余代码分离。我们来看看useTodos,它是将这个 Reducer 引入 React 的胶水代码:
export function useTodos(initialTodos: Todo[]) {
const [todos, dispatch] = useReducer(todosReducer, initialTodos);
return {
todos,
addTodo: (description: string) =>
dispatch(createAddTodoAction(description)),
completeTodo: (id: Todo["id"]) => dispatch(createCompleteTodoAction(id)),
deleteTodo: (id: Todo["id"]) => dispatch(createDeleteTodoAction(id)),
};
}
这里的危险在于,编写的业务逻辑必须通过测试完整的 hook 才能进行测试。使用 hook 只需将 Reducer 和 Action Creator 与 React 逻辑粘合在一起,就能免去所有这些麻烦。
展示组件
最后,但同样重要的是,让我们看一下展示代码。这些组件定义了面向用户的界面,但本身不包含任何业务逻辑。这就是我在文章开头提到的大多数问题产生的地方。而且,说实话,我还没有找到一个完美的解决方案。但有一个概念可以接近它:
故事相当于单元测试的视觉版本。它的主要缺点是断言测试是否成功的步骤必须手动完成。
这是一个按钮的故事:
const Template: Story<Props> = (args) => <Button {...args} />;
const actionArgs = {
onClick: action("onClick"),
};
export const Default = Template.bind({});
Default.args = {
...actionArgs,
children: "Click me!",
color: ButtonColor.Success,
};
这是按钮本身:
export enum ButtonColor {
Alert = "Alert",
Success = "Success",
}
export enum ButtonType {
Submit = "submit",
Reset = "reset",
Button = "button",
}
export interface Props {
children: ReactNode;
color: ButtonColor;
onClick?: () => void;
type?: ButtonType;
}
export const Button: FunctionComponent<Props> = ({
children,
color,
onClick,
type,
}) => {
const colorStyles = {
[ButtonColor.Alert]: {
border: "#b33 solid 1px",
borderRadius: "4px",
boxShadow: "2px 2px 2px rgba(100,0,0,0.8)",
color: "white",
backgroundColor: "#a00",
},
[ButtonColor.Success]: {
border: "#3b3 solid 1px",
borderRadius: "4px",
boxShadow: "2px 2px 2px rgba(0,100,0,0.8)",
color: "white",
backgroundColor: "#0a0",
},
};
return (
<button
style={{
...colorStyles[color],
padding: "0.2rem 0.5rem",
}}
onClick={onClick}
type={type}
>
{children}
</button>
);
};
故事会单独渲染按钮。我可以先编写故事,这样我就可以思考这个组件的预期接口,然后再实现组件本身。如果任何实现细节发生变化,只要接口保持不变,我就无需更改故事。而且,每当我想验证渲染后的故事是否仍然符合预期时,我都可以单独查看它(这就是我上面提到的“手动”部分)。一旦我获得了满意的版本,我甚至可以借助可视化回归工具设置自动回归测试。
大家在一起
在实践中,以 TDD 风格开发这个待办事项应用程序会是什么样子?
- 编写一个集成测试,如果没有待办事项,则应显示“无待办事项”文本
- 通过实现 App 组件来完成测试,以便它只返回“无待办事项”
- 将“No todos”提取到其自己的组件中
- 为其添加一个故事
- 使用故事来推动视觉变化,直到“No todos”部分看起来应该如此
- 添加关于添加待办事项的集成测试
- 开始实施测试并意识到我需要某种状态管理
- 注释掉集成测试
- 为状态减速器编写单元测试
- 通过编写一个简单的 Reducer 第一版本来完成测试
- 写一个故事来显示待办事项列表
- 使用故事来推动 TodoList 组件的实现
- 重新评论集成测试
- 通过将 Reducer 和组件粘合在一起来完成集成测试
- ...
显然,还有很多其他方法可以实现这一点。但希望本文能展示出一种在前端使用 TDDD 的潜在工作流程。
如果你对更多关于 Web 产品开发和创业的文章和新闻感兴趣,欢迎在 Twitter 上关注我。也请给我发一条推文,分享你在前端使用 TDDD 的经验!
文章来源:https://dev.to/the_startup_cto/tdd-in-a-react-frontend-1g4n