使用 React、Typescript 和 react-testing-library 编写单元测试

2025-06-04

使用 React、Typescript 和 react-testing-library 编写单元测试

我所在的公司开始采用 TypeScript 作为编写 React 的首选解决方案。在代码审查期间,我注意到很多人在测试组件时遇到了问题。在查看代码时,我注意到它的编写方式让 TypeScript 看起来更像是一个负担,而不是一个辅助你编写代码的工具。

凭借一些使用 Typescript 的经验,我想到了一种编写测试的模式,在我看来,这种模式可以避免不必要的重复并使其清晰。

示例组件

jest这是我们要测试的组件。它非常简单,但包含足够的逻辑,以便我们可以使用和 的一些功能react-testing-library

import React from "react";
import { Todo } from "./Todo";

type Props = {
  id: number;
  onClick: (todo: Todo) => void;
};

type State = {
  fetchState: "loading" | "error" | "success";
  todo: Todo | undefined;
};

function Todo({ id, onClick }: Props) {
  const [state, setState] = React.useState<State>({
    fetchState: "loading",
    todo: undefined
  });

  React.useEffect(() => {
    function fetchTodo() {
      fetch(`https://jsonplaceholder.typicode.com/todos/${id}`)
        .then<Todo>(response => response.json())
         // Normally we would probably check if the component 
         // is still mounted here, before using `setState`
        .then(todo => setState({ todo, fetchState: "success" }))
        .catch(() => setState({ todo: undefined, fetchState: "error" }));
    }
    fetchTodo();
  }, [id]);

  if (state.fetchState == "loading" || !state.todo) return <p>loading ...</p>;
  if (state.fetchState == "error") return <p>error!...</p>;

  return (
    <div onClick={() => onClick(state.todo as Todo)}>
      <p>{state.todo.title}</p>
      <p>{state.todo.id}</p>
    </div>
  );
}

就像我说的,这里的代码其实并不重要。它只是为了让我们有东西可以测试。

测试

您的测试用例可能看起来像这样:

import { render } from "@testing-library/react";
it("fetches a todo", () => {
  const {/* selectors */} = render(<Todo onClick={() => {}} id={1} />);
  // rest of the test
});

it("handles non-existing id", () => {
  const {/* selectors */} = render(<Todo onClick={() => {}} id={420} />);
  // rest of the test
});

// more test cases

这没有什么不对

但是,当你编写第四、第五个测试用例时,你可能会厌倦所有这些重复。注意到我必须明确提供onClick函数,即使该函数在测试中不会用到(例如handles non-existing id)?

renderUI我们可以通过创建或函数来消除所有这些重复setup(这些只是命题,随便你怎么称呼它)。

renderUI功能

让我们创建renderUI负责渲染组件并返回react-testing-library选择器和实用程序的函数。

function renderUI(props: ?) {
  return render(<Todo {...props}/>)
}

现在,我故意在这里留下了问号。你可能想直接导入propsfrom的类型./App(也就是包含我们要测试的组件的文件)。

import { render } from "@testing-library/react";
import { Todo, Props } from "./App";

function renderUI(props: Props) {
  return render(<Todo {...props} />);
}

虽然你当然可以这样做,但我个人不建议这样做

  • 除非您使用像这样的详细名称TodoComponentProps,否则导出组件道具的类型可能会与其他导出的类型发生冲突,这在使用代码完成时尤其痛苦。

  • 导出组件 props 的类型可能会让以后阅读代码的人感到困惑。我可以更改类型的名称吗?这些名称在其他地方有用吗

考虑到这一点,让我们利用 Typescript 功能并获取组件道具的类型,而无需导出/导入它们。

import { render } from "@testing-library/react";
import { Todo } from "./App";

type ComponentProps = React.ComponentProps<typeof Todo>;

function renderUI(props: ComponentProps) {
  return render(<Todo {...props} />);
}

我使用React.ComponentProps内部定义的泛型@types/react来获取所需的类型。无需导出/导入 props 类型!

这样,在我们的测试中,我们摆脱了一些重复:

it("fetches a todo", () => {
  const { /* selectors */ } = renderUI({ onClick: () => {}, id: 1 });
  // rest of the test
});

但是,我们仍然必须包含对于给定测试用例(onClick在本例中)并不真正重要的属性。TypescriptParial<T>实用程序类型可以帮助实现这一点。

import { Todo } from "./App";

type ComponentProps = React.ComponentProps<typeof Todo>;

const baseProps: ComponentProps = {
  onClick: () => {},
  id: 1
};

function renderUI(props: Partial<ComponentProps> = {}) {
  return render(<Todo {...baseProps} {...props} />);
}

请注意,我必须创建baseProps。这些属性应该以组件能够实际使用它们进行渲染的方式指定。basePropsprops组合允许我们仅将这些属性传递给renderUI在给定测试上下文中重要的函数。

it("handles non-existing id", () => {
  const {/* selectors */} = render(<Todo id={420} />);
  // rest of the test
});

测试用例handles non-existing id确实测试了响应用户点击的能力,因此它没有指定onClick函数。这是可能的,因为我们在函数baseProps中包含了它renderUI

重新渲染

有时,您需要使用rerenderreact-testing-library render函数返回的函数来测试当给定的 prop 发生变化时(更改之前和更改后)组件的行为。

查看函数的签名rerender

rerender: (ui: React.ReactElement) => void;

它需要一个类型的参数React.ReactElement。这意味着我们的renderUI函数,就目前情况而言,不会切断它。

it("reacts to id change", () => {
  const { rerender } = renderUI({ id: 1 });
  // assert
  rerender(<Todo {...baseProps} id={2} />);
  // assert
});

我们可以rerender按照抽象的同样的方式抽象该函数render

function renderUI(props: Partial<ComponentProps> = {}) {
  const rtlProps = render(<Todo {...baseProps} {...props} />);

  return {
    ...rtlProps,
    rerender: (newProps: Partial<ComponentProps>) =>
      rtlProps.rerender(<Todo {...baseProps} {...props} {...newProps} />)
  };
}

我替换了返回的rerender函数。它不再直接返回原始函数,而是将组件的渲染抽象出来,这使得我们的测试更加清晰。

it("reacts to id change", () => {
  const { rerender } = renderUI({ id: 1 });
  // assert
  rerender({ id: 2 });
  // assert
});

警告

我只是想指出,有时候重复并不一定是坏事。草率地创建抽象肯定比props多次传递更糟糕。

这就是为什么我仅当您觉得有必要时才建议您遵循我在此处给出的建议。

有一篇很棒的文章,在测试中(以及一般情况下)创建任何类型的抽象之前,你一定要阅读并考虑它。

概括

总的来说,我认为这种模式可以帮助您更快地编写测试并减少重复。

请记住,我不是测试和/或 Typescript 领域的专家,因此如果您感觉有什么不对或不正确,请联系我!

你可以在 Twitter 上关注我:@wm_matuszewski

谢谢👋

文章来源:https://dev.to/wojciechmatuszewski/writing-unit-tests-with-react-typescript-and-react-testing-library-1nmg
PREV
Firefox 开发者工具可以做到这一点吗?
NEXT
Continue Using .env Files As Usual. 1) I can genuinely say I don't think I understand your first paragraph. 2) Sure, it takes some guts to say anything, I suppose. Although, I don't think it takes that much guts to type something on the Internet. That aside, the post would have been great if it had just been to the point, but instead it included a level of toxicity that wasn't necessary to make the point. That is what I commented on. I love sharing ideas and learning new things - I really do, but I can also do without the toxicity that is already so prevalent elsewhere on the Internet. It'd be nice if people as smart as software developers could get rid of the ape mind and just have an academic discussion or debate without taking pot shots that don't add anything.