React 前端中的 TDD

2025-05-28

React 前端中的 TDD

如今,只有少数专业开发人员对测试驱动开发和测试驱动设计(TDD)的价值抱有严重怀疑。但我见过的许多代码库的实际情况是,TDD通常局限于后端,也就是“业务逻辑”所在的地方。

部分原因在于人们普遍认为前端开发并非“真正的软件开发”,尽管在大多数情况下,一个功能齐全的后端如果没有匹配的前端是完全无法使用的。但另一部分原因在于缺乏在前端进行测试驱动开发 (TDD) 的技能。本文正是探讨这个问题。

我以 React 为例,因为它是我最熟悉的框架,而且它的声明式风格比使用纯 JavaScript、HTML 和 CSS 更容易进行某些测试。但本文中的大多数想法也适用于其他场景。

如果您对更多有关网络产品开发和创业的文章和新闻感兴趣,请随时在 Twitter 上关注我

为什么前端测试比后端测试更难?

让前端工程师远离 TDD 的并非总是懒惰。这一点尤其体现在,那些全栈工程师虔诚地在后端代码中实践 TDD,却不为前端编写任何测试。

根据我的经验,差异可以归结为三点:

  1. 在前端,功能通常具有更大的接口。最简单的后端 API 可能由一个简单的 JSON 结构定义,但即使是最简单的前端功能,其定义不仅在于功能本身,还在于通常渲染到屏幕上的数千个像素。
  2. 更糟糕的是,我们还没有找到一个好的方法向机器解释哪些像素重要。对于某些机器来说,改变像素本身并没有什么区别,但如果改错了像素,功能就会完全无法使用。
  3. 长期以来,工具无法实现在几秒钟​​内运行的集成测试。相反,测试要么仅限于纯业务逻辑,要么在浏览器中运行,通常需要几分钟的设置时间。

那么我们该如何解决这个问题呢?

编写可测试的前端代码

就像你经常需要拆分后端代码并引入依赖注入才能测试一样,前端代码也应该拆分以便于测试。前端代码大致分为三类,每类都有不同的测试方法。

我们以一个经典的 React 待办事项应用为例。我建议在第二个屏幕上打开代码仓库并继续操作。为了方便使用手机阅读或无法访问代码仓库的用户,我在本文中添加了代码摘录。

胶水代码

App 组件useTodos hooks就是我所说的胶水代码。它把其余代码“粘合”在一起,使功能得以实现:

const TodoApp: FunctionComponent = () => {
  const { todos, addTodo, completeTodo, deleteTodo } = useTodos([]);

  return (
    <>
      <TodoList
        todos={todos}
        onCompleteTodo={completeTodo}
        onDeleteTodo={deleteTodo}
      />
      <AddTodo onAdd={addTodo} />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode
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)),
  };
}
Enter fullscreen mode Exit fullscreen mode

与后端的控制器类似,最好使用集成测试进行测试:

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();
  });
});
Enter fullscreen mode Exit fullscreen mode

我之所以首先讨论这些测试,是因为这通常是我编写的第一类测试。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);
  }
}
Enter fullscreen mode Exit fullscreen mode

对这种代码的测试看似简单:

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);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

测试业务逻辑的难点不在于编写测试,而在于将业务逻辑与其余代码分离。我们来看看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)),
  };
}
Enter fullscreen mode Exit fullscreen mode

这里的危险在于,编写的业务逻辑必须通过测试完整的 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,
};
Enter fullscreen mode Exit fullscreen mode

这是按钮本身

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

故事会单独渲染按钮。我可以先编写故事,这样我就可以思考这个组件的预期接口,然后再实现组件本身。如果任何实现细节发生变化,只要接口保持不变,我就无需更改故事。而且,每当我想验证渲染后的故事是否仍然符合预期时,我都可以单独查看它(这就是我上面提到的“手动”部分)。一旦我获得了满意的版本,我甚至可以借助可视化回归工具设置自动回归测试。

故事书

大家在一起

在实践中,以 TDD 风格开发这个待办事项应用程序会是什么样子?

  1. 编写一个集成测试,如果没有待办事项,则应显示“无待办事项”文本
  2. 通过实现 App 组件来完成测试,以便它只返回“无待办事项”
  3. 将“No todos”提取到其自己的组件中
  4. 为其添加一个故事
  5. 使用故事来推动视觉变化,直到“No todos”部分看起来应该如此
  6. 添加关于添加待办事项的集成测试
  7. 开始实施测试并意识到我需要某种状态管理
  8. 注释掉集成测试
  9. 为状态减速器编写单元测试
  10. 通过编写一个简单的 Reducer 第一版本来完成测试
  11. 写一个故事来显示待办事项列表
  12. 使用故事来推动 TodoList 组件的实现
  13. 重新评论集成测试
  14. 通过将 Reducer 和组件粘合在一起来完成集成测试
  15. ...

显然,还有很多其他方法可以实现这一点。但希望本文能展示出一种在前端使用 TDDD 的潜在工作流程。

如果你对更多关于 Web 产品开发和创业的文章和新闻感兴趣,欢迎在 Twitter 上关注我。也请给我发一条推文,分享你在前端使用 TDDD 的经验!

文章来源:https://dev.to/the_startup_cto/tdd-in-a-react-frontend-1g4n
PREV
几乎所有内容都采用深色主题
NEXT
使用 Github Actions 像专业人士一样部署到 Github Pages