发布于 2026-01-06 9 阅读
0

使用 React Testing Library 测试简单组件 DEV 的全球展示与分享挑战赛,由 Mux 呈现:展示你的项目!

使用 React Testing Library 测试一个简单的组件

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

在这篇文章中,我将使用 React Testing Library 来测试一个简单的 React 组件。除了展示测试库的功能之外,我还希望在此过程中分享一些我自己的测试方法。和往常一样,我的示例将基于我正在开发的WordSearch 游戏。

我选择的组件是AddWord——一个简单的组件,用于向单词搜索游戏中添加新单词。
该组件提供了一个输入框、一个“添加”按钮,当然还有一些用于单词验证和与外部应用程序交互的内部逻辑。
我想使用React Testing 库来测试这些逻辑,该库的核心理念是模拟用户交互,并专注于此而非实现细节。

虽然我并不太喜欢(至少可以说)事后测试的方法,但这次我会在实现完成后再编写测试。说不定还能顺便发现一些 bug 呢……

让我们开始吧!
这是我们的组件,是用 TypeScript 编写的,但别担心 :) 你可以在这篇文章中看到我是如何把它从 JS 转换过来的:

import React, {ChangeEventHandler, MouseEventHandler, RefObject, useRef, useState} from 'react';
import Add from '@material-ui/icons/Add';

interface IAddWordProps {
   onWordAdd?: (value: string | undefined) => void;
}

const AddWord = ({onWordAdd}: IAddWordProps) => {
   const inputEl: RefObject<HTMLInputElement> | null = useRef(null);
   const [newWord, setNewWord] = useState('');
   const [disable, setDisable] = useState(true);

   const onAddClicked: MouseEventHandler<HTMLButtonElement> = () => {
       onWordAdd?.(newWord);
       setNewWord('');
   };

   const onChange: ChangeEventHandler<HTMLInputElement> = ({currentTarget: {value}}) => {
       setNewWord(value);
       // A word is valid if it has more than a single char and has no spaces
       const isInvalidWord: boolean = value.length < 2 || /\s/.test(value);
       setDisable(isInvalidWord);
   };

   return (
       <>
           <input
               type="text"
               name="new"
               required
               pattern="[Bb]anana|[Cc]herry"
               ref={inputEl}
               placeholder="Add word..."
               value={newWord}
               onChange={onChange}
           />
           <button onClick={onAddClicked} disabled={disable}>
               <Add />
           </button>
       </>
   );
};

export default AddWord;
Enter fullscreen mode Exit fullscreen mode

我们有两个组件状态:newWorddisabled。disabled状态可能确实是多余的,不过,等我完成测试后,我会尝试进行重构,并确保没有出现任何问题。

在开始之前,我想启动测试运行器并将其设置为监听模式,这样我就不需要不断刷新页面了。由于这个项目是使用create-reach-app创建的,Jest 运行器和 React 测试库已经安装并配置好了,所以我只需要运行一下npm run test就可以了(但如果你还没有安装,只需按照 React 测试库的说明操作即可)。

首先,我会渲染组件,确认它确实显示在屏幕上。然后,
我会创建一个名为AddWord.test.js 的新测试文件,并编写第一个包含简单断言的测试,以确保一切运行正常。请注意,该测试文件并非使用 TypeScript 编写,因为我目前想专注于实际的测试工作。

describe('AddWord component', () => {
   it('should render the component onto the screen', () => {
       expect(true).toBeTruthy();
   });
});
Enter fullscreen mode Exit fullscreen mode

很好,测试正在运行,全部通过。

现在我将尝试渲染组件,我的断言将检查输入框和按钮是否在屏幕上。在我看来,进行这类屏幕查询的更好方法之一是为组件添加一个测试 ID,这在实际组件及其测试表示之间提供了一个抽象层。您可以通过向组件添加“data-testid”属性来实现这一点。
我将为组件中的不同控件分配一个唯一的 ID:

<>
           <input
               type="text"
               name="new"
               required
               pattern="[Bb]anana|[Cc]herry"
               ref={inputEl}
               placeholder="Add word..."
               value={newWord}
               onChange={onChange}
               data-testid="add-word-input"
           />
           <button onClick={onAddClicked} disabled={disable} data-testid="add-word-button">
               <Add />
           </button>
       </>
Enter fullscreen mode Exit fullscreen mode

注:我知道根据建议,testId 是最后的选择,但我认为紧密耦合基于语言的查询(例如 byText、byLabelText、byAltText、byPlaceholderText 等)在测试其他语言时会更加脆弱且限制更多。此外
,编写端到端自动化时,你会发现使用非依赖标识符会方便得多。因此,如果你打算进行端到端测试,为什么不使用同一个 data-testId 来同时用于这两种用途呢?我之前在Twitter 上和 Kent C. Dodds
也 讨论过这个问题,我可能会向他提出一个“请求”,以确保我理解他建议的解决方案,因为重复测试是不可行的。

我通常不喜欢在单个测试中使用多个断言,因为我认为这会增加维护难度,并且会模糊单个单元测试的目的。但在这里,我们可以同时使用两个断言,因为这样做并没有什么坏处。我的测试现在看起来是这样的:

it('should render the component onto the screen', () => {
       render(<AddWord />);
       expect(screen.getByTestId('add-word-input')).toBeInTheDocument();
       expect(screen.getByTestId('add-word-button')).toBeInTheDocument();
   });
Enter fullscreen mode Exit fullscreen mode

顺便一提,这种 DOM 断言功能得益于 @testing-library/jest-dom 的支持。你之所以在文件本身中看不到导入语句,是因为 create-react-app 会在 setupTests.js 文件中为所有测试导入它。
(感谢 Matan Borenkraout 👍)

现在我们知道,AddWord 组件的初始状态下“添加”按钮是禁用的,因为不能添加空词,对吧?我们再检查一下——
为了确保我的测试不会“欺骗”我,我喜欢断言与我感兴趣的相反的情况,以确定我的测试不是由于其他被忽略的原因而通过的。类似这样:

it('should have the "Add" button disabled when initialized', () => {
       render(<AddWord />);

       expect(screen.getByTestId('add-word-button')).toBeEnabled();
   });
Enter fullscreen mode Exit fullscreen mode

请注意,虽然我知道它应该被禁用,但我却期望它被启用,而 Jest 会很快通知我这一点:

expect(element).toBeEnabled()

    Received element is not enabled:
      <button data-testid="add-word-button" disabled="" />
Enter fullscreen mode Exit fullscreen mode

好了,既然我们知道测试是可靠的,那么让我们做出正确的断言:

it('should have the "Add" button disabled when initialized', () => {
       render(<AddWord />);

       expect(screen.getByTestId('add-word-button')).toBeDisabled();
   });
Enter fullscreen mode Exit fullscreen mode

现在我已经测试过了,我想测试一下当我输入内容时,“添加”按钮是否会启用。这里我也用了两个断言——第一个断言确保组件首次渲染时按钮处于禁用状态,第二个断言确保当有有效输入时按钮会启用。我这样做是因为我想确保按钮不会因为任何意外原因在启动时处于禁用状态:

it('should enable the "Add" button when a valid input is entered', () => {
       render(<AddWord />);

       expect(screen.getByTestId('add-word-button')).toBeDisabled();

       const input = screen.getByTestId('add-word-input');
       fireEvent.change(input, {target: {value: 'matti'}});

       expect(screen.getByTestId('add-word-button')).toBeEnabled();
   });
Enter fullscreen mode Exit fullscreen mode

这里我们模拟了一个“更改”事件,我认为这足以进行本次测试,但也可以模拟实际的打字过程。

好的。接下来是组件的验证部分。由于这是“后测”,我们需要读取已实现的逻辑并从中导出测试用例。
首先我们要检查的是,当输入少于 2 个字符时,“添加”按钮是否仍然处于禁用状态:

it('should have the "Add" button disabled if the input is less than 2 chars', () => {
       render(<AddWord />);

       const input = screen.getByTestId('add-word-input');
       fireEvent.change(input, {target: {value: 'm'}});

       expect(screen.getByTestId('add-word-button')).toBeDisabled();
   });
Enter fullscreen mode Exit fullscreen mode

我们还要检查一点,如果输入的单词包含空格,“添加”按钮应该禁用:

it('should have the "Add" button disabled if the input contains spaces', () => {
       render(<AddWord />);

       const input = screen.getByTestId('add-word-input');
       fireEvent.change(input, {target: {value: 'm atti'}});

       expect(screen.getByTestId('add-word-button')).toBeDisabled();
   });
Enter fullscreen mode Exit fullscreen mode

好的 :)

我认为到目前为止,我们已经涵盖了组件的全部逻辑。你知道吗?我们来运行代码覆盖率检查,看看情况如何npm run test -- --coverage
哦,糟糕,看来我漏掉了什么:

图片描述

这些标记告诉我,第 14-15 行没有被覆盖,也就是这个方法内部的行:

const onAddClicked: MouseEventHandler<HTMLButtonElement> = () => {
       onWordAdd?.(newWord);
       setNewWord('');
   };
Enter fullscreen mode Exit fullscreen mode

没错,我之前没检查点击“添加”按钮后会发生什么。现在我们就来检查一下。
我们将创建一个 spy 方法,这是 Jest 的一个特殊方法,你可以“监视”它,查看它被调用了多少次以及传递了哪些参数(以及其他功能)。然后,我们将输入一个有效值,点击“添加”按钮,并期望被监视的处理方法会被调用,并将我们输入的值传递给它。代码如下:

it('should call the onWordAdd handler (if exists) with the new word upon clicking the "Add" button', () => {
       const onWordsAddSpy = jest.fn();
       const inputValue = 'matti';

       render(<AddWord onWordAdd={onWordsAddSpy} />);

       const input = screen.getByTestId('add-word-input');
       const addButton = screen.getByTestId('add-word-button');

       fireEvent.change(input, {target: {value: inputValue}});
       fireEvent.click(addButton);

       expect(onWordsAddSpy).toHaveBeenCalledWith(inputValue);
   });
Enter fullscreen mode Exit fullscreen mode

我们还需要检查一点,就是按钮点击后,输入框中的值是否应该被清除。你猜对了——这又是另一个测试:

it('should clear the input upon clicking the "Add" button', () => {
       render(<AddWord />);

       const input = screen.getByTestId('add-word-input');
       const addButton = screen.getByTestId('add-word-button');

       fireEvent.change(input, {target: {value: 'matti'}});
       fireEvent.click(addButton);

       expect(input.value).toBe('');
   });
Enter fullscreen mode Exit fullscreen mode

太好了。现在我们再来看一下覆盖范围:

图片描述

100%覆盖率 :)

奖金

还记得我之前提到过“禁用”状态可能是多余的吗?现在我可以放心地开始重构它了,因为我的测试已经证明了这一点。很简单!修改后的组件现在看起来是这样的:

import React, {ChangeEventHandler, MouseEventHandler, RefObject, useRef, useState} from 'react';
import Add from '@material-ui/icons/Add';

interface IAddWordProps {
   onWordAdd?: (value: string | undefined) => void;
}

const AddWord = ({onWordAdd}: IAddWordProps) => {
   const inputEl: RefObject<HTMLInputElement> | null = useRef(null);
   const [newWord, setNewWord] = useState('');

   const onAddClicked: MouseEventHandler<HTMLButtonElement> = () => {
       onWordAdd?.(newWord);
       setNewWord('');
   };

   const onChange: ChangeEventHandler<HTMLInputElement> = ({currentTarget: {value}}) => {
       setNewWord(value);
   };

   // A word is valid if it has more than a single char and has no spaces
   const isInvalidWord: boolean = newWord.length < 2 || /\s/.test(newWord);

   return (
       <>
           <input
               type="text"
               name="new"
               required
               pattern="[Bb]anana|[Cc]herry"
               ref={inputEl}
               placeholder="Add word..."
               value={newWord}
               onChange={onChange}
               data-testid="add-word-input"
           />
           <button onClick={onAddClicked} disabled={isInvalidWord} data-testid="add-word-button">
               <Add />
           </button>
       </>
   );
};

export default AddWord;
Enter fullscreen mode Exit fullscreen mode

和往常一样,如果您有任何改进建议或其他技巧,请务必与我们分享!

干杯

嘿!如果你喜欢我刚才写的内容,也别忘了在推特上关注我哦 :)关注 @mattibarzeev 🍻

照片由Unsplash上的ThisisEngineering RAEng拍摄

文章来源:https://dev.to/mbarzeev/testing-a-simple-component-with-react-testing-library-5bc6