[TDD] 如何测试 React 异步组件?

简介: 前言 本文承接上文 如何测试驱动开发 React 组件?,这次我将继续使用 @testing-library/react 来测试我们的 React 应用,并简要简要说明如何测试异步组件。

前言

本文承接上文 如何测试驱动开发 React 组件?,这次我将继续使用 @testing-library/react 来测试我们的 React 应用,并简要简要说明如何测试异步组件。

异步组件的测试内容

我们知道异步请求主要用于从服务器上获取数据,这个异步请求可能是主动触发的,也可能是(鼠标)事件响应,本文主要包含 2 方面内容:

  1. 如何测试在 componentDidMount 生命周期中发出的异步请求 ?
  2. 如何测试(鼠标)事件发出的异步请求 ?

对于异步组件,有两件步骤需要进行测试:

第一:测试异步方法本身有没有被调用,并且传了正确的参数。

第二:在调用之后,应用程序应该做出响应。

一起来看看代码中该如何实现?

假设你有一个用 React 编写的小型博客应用程序。有一个登录页面,还有有一个文章列表页面,内容就跟我的博客一样。

登录测试

先来实现登录页,先脑补一个效果图吧

image.png

我们先来写下测试用例

  1. 界面包含账号和密码输入框
  2. 接口请求包含 username 和 password
  3. 防止登录重复点击
  4. 登录成功跳转页面
  5. 登录失败显示错误信息

测试渲染

代码未动,测试先行,先确保我们的组件可以渲染。

import React from "react";
import { render } from "@testing-library/react";
import Login from "./index";

describe("Login component", () => {
  it("should render", () => {
    const { getByText } = render(<Login onSubmit={() => {}} />);
    expect(getByText(/账号/)).toBeInTheDocument();
    expect(getByText(/密码/)).toBeInTheDocument();
  });
});

登录组件实现

为了保证是一个纯组件,将提交方法onSubmit作为一个 props 传入,接下来我们实现下组件代码

import React from "react";

function Login({ onSubmit }) {
  function handleSubmit(event) {
    event.preventDefault();
    const { username, password } = event.target.elements;

    onSubmit({
      username: username.value,
      password: password.value
    });
  }
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="username-field">账号:</label>
        <input id="username-field" name="username" type="text" />
      </div>
      <div>
        <label htmlFor="password-field">密码:</label>
        <input id="password-field" name="password" type="password" />
      </div>
      <div>
        <button type="submit">登录</button>
      </div>
    </form>
  );
}

export default Login;

为了方便理解我们这边没有使用其他三方库,若在生产环境中,我推荐使用 react-hook-form

测试提交

接下来测试下 onSubmit 方法必须包含 usernamepassword,

我们需要模拟用户输入,这个时候我们需要安装 @test-library/user-event

import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Login from "./index";

test("onSubmit calls  with username and password", () => {
    let submittedData;
    const handleSubmit = data => (submittedData = data);
    render(
      <Login onSubmit={handleSubmit} />
    );
    const username = "user";
    const password = "password";

    userEvent.type(screen.getByPlaceholderText(/username/i), username);
    userEvent.type(screen.getByPlaceholderText(/password/i), password);
    userEvent.click(screen.getByRole("button", { name: /登录/ }));

    expect(submittedData).toEqual({
      username,
      password
    });
  });

我们可以选用 @testing-libraryget*By* 函数获取dom 中的元素, 这里使用 getByPlaceholderText

image.png

以上测试用例只测试了登录函数,但是我们并未写登录成功或者失败的逻辑,接下来来我们通过 jest 的 mock 函数功能来模拟登录。

测试登录成功

由于测试登录成功的例子已经包含了"测试提交"和"测试渲染"的功能,所以,可以将前面2个单元测试删除。登录后,按钮改成 loading 状态 disabled。

const fakeData = {
  username: "username",
  password: "username",
};

test("onSubmit success", async () => {
    // mock 登录函数
    const login = jest.fn().mockResolvedValueOnce({ data: { success: true } });
    render(<Login onSubmit={login} />);

    userEvent.type(screen.getByPlaceholderText(/username/i), fakeData.username);
    userEvent.type(screen.getByPlaceholderText(/password/i), fakeData.password);
    userEvent.click(screen.getByRole("button", { name: /登录/ }));
    //登录后,按钮改成 loading 状态 disabled
    const button = screen.getByRole("button", { name: /loading/ });

    expect(button).toBeInTheDocument();
    expect(button).toBeDisabled();

    await waitFor(() => expect(login).toHaveBeenCalledWith(fakeData));

    expect(login).toHaveBeenCalledTimes(1);
    
    // 文档中没有 loading 按钮
    expect(
      screen.queryByRole("button", { name: "loading" })
    ).not.toBeInTheDocument();
});

接下来我们修改组件, 顺便把登录失败的逻辑也写了,登录失败在登录框下显示服务端返回信息。

import React, { useState } from "react";

function Login({ onSubmit }) {
  const [loading, setLoading] = useState(false);
  const [message, setMessage] = useState("");
  function handleSubmit(event) {
    event.preventDefault();
    const { username, password } = event.target.elements;
    setLoading(true);

    onSubmit({
      username: username.value,
      password: password.value,
    })
      .then((res) => {
        setLoading(false);
      })
      .catch((res) => {
        setLoading(false);
        setMessage(res.data.message);
      });
  }
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="username-field">账号:</label>
        <input
          id="username-field"
          name="username"
          placeholder="username"
          type="text"
        />
      </div>
      <div>
        <label htmlFor="password-field">密码:</label>
        <input
          id="password-field"
          name="password"
          placeholder="password"
          type="password"
        />
      </div>
      <div>
        <button disabled={loading} type="submit">
          {loading ? "loading" : "登录"}
        </button>
      </div>
      <div>{message}</div>
    </form>
  );
}

export default Login;

测试登录失败

我们直接复制成功的测试用例,修改失败的逻辑。测试用例:

  • 失败后文档中显示服务端的消息
  • 失败后按钮又显示登录并且可以点击
test("onSubmit failures", async () => {
    const message = "账号或密码错误!";
    // mock 登录函数失败
    const login = jest.fn().mockRejectedValueOnce({
      data: { message },
    });
    render(<Login onSubmit={login} />);

    userEvent.type(screen.getByPlaceholderText(/username/i), fakeData.username);
    userEvent.type(screen.getByPlaceholderText(/password/i), fakeData.password);
    userEvent.click(screen.getByRole("button", { name: /登录/ }));

    const button = screen.getByRole("button", { name: /loading/ });

    expect(button).toBeInTheDocument();
    expect(button).toBeDisabled();

    await waitFor(() => expect(login).toHaveBeenCalledWith(fakeData));

    expect(login).toHaveBeenCalledTimes(1);
    
    // 确保文档中有服务端返回的消息
    expect(screen.getByText(message)).toBeInTheDocument();

    // 文档中没有 loading 按钮
    expect(
      screen.queryByRole("button", { name: "loading" })
    ).not.toBeInTheDocument();
    
    expect(screen.getByRole("button", { name: /登录/ })).not.toBeDisabled();
  });

博客列表测试

相信经过登录的测试,我们在来写博客列表的测试已经不难了,我们先来写下测试用例:

  • 接口请求中页面显示 loading
  • 请求成功显示博客列表
  • 列表为空显示暂无数据
  • 请求失败显示服务端错误

博客列表代码

下面的代码中, 使用了 react-use,首先我们先要安装这个包

import React from "react";
import { fetchPosts } from "./api/posts";
import { useAsync } from "react-use";

export default function Posts() {
  const posts = useAsync(fetchPosts, []);

  if (posts.loading) return "Loading...";
  if (posts.error) return "服务端错误";
  if (posts.value && posts.value.length===0) return "暂无数据";
  

  return (
    <>
      <h1>My Posts</h1>
      <ul>
        {posts.value.map((post) => (
          <li key={post.id}>
            <a href={post.url}>{post.title}</a>
          </li>
        ))}
      </ul>
    </>
  );
}

当然我们也可以写 class 的实现方式,代码如下:

import React from "react";
import { fetchPosts } from "./api/posts";

export default class Posts extends React.Component {
  state = { loading: true, error: null, posts: null };

  async componentDidMount() {
    try {
      this.setState({ loading: true });
      const posts = await fetchPosts();
      this.setState({ loading: false, posts });
    } catch (error) {
      this.setState({ loading: false, error });
    }
  }

  render() {
    if (this.state.loading) return "Loading...";
    if (this.state.error) return "服务端错误";
    if (this.state.posts && this.state.posts.length===0) return "暂无数据";

    return (
      <>
        <h1>My Posts</h1>
        <ul>
          {this.state.posts.map((post) => (
            <li key={post.id}>
              <a href={post.url}>{post.title}</a>
            </li>
          ))}
        </ul>
      </>
    );
  }
}

Mock 接口

jest.mock("./api/posts");

我们可以在官方文档中阅读关于 jest.mock 的更多信息。

它所做的就是告诉 Jest 替换/api/posts 模块。

现在我们已经有了 mock,让我们来渲染组件,并且界面显示 loading:

import React from "react";
import { render, screen } from "@testing-library/react";
import Post from "./index";

jest.mock("./api/posts");

test("should show loading", () => {
  render(<Posts />);
  expect(screen.getByText("Loading...")).toBeInTheDocument();
});

这是第一步,现在我们需要确保我们的 fetchPosts 方法被正确调用:

import React from "react";
import { render, screen } from "@testing-library/react";
import Posts from "./index";
import { fetchPosts } from "./api/posts"; 

jest.mock("./api/posts");

test("should show a list of posts", () => {
  render(<Posts />);
  expect(screen.getByText("Loading...")).toBeInTheDocument();
  
  expect(fetchPosts).toHaveBeenCalledTimes(1);
  expect(fetchPosts).toHaveBeenCalledWith();
  
});

通过 toHaveBeenCalledTimes 测试调用次数,通过 toHaveBeenCalledWith 测试调用方法的参数,虽然这边是空数据,但是我们也可以写,确保调用参数是空。

此时我们的测试代码会报错,因为我们没有 Mock fetchPosts 方法

import React from "react";
import { render, screen, wait } from "@testing-library/react";
import Posts from "./index";
import { fetchPosts } from "./api/posts";

jest.mock("./api/posts");

test("should show a list of posts", async () => {
    // mock 接口
    const posts = [{ id: 1, title: "My post", url: "/1" }];
    fetchPosts.mockResolvedValueOnce(posts);

    render(<Posts />);
    expect(screen.getByText("Loading...")).toBeInTheDocument();
    expect(fetchPosts).toHaveBeenCalledWith();
    expect(fetchPosts).toHaveBeenCalledTimes(1);
    
    //等待标题渲染
    await waitFor(() => screen.findByText("My Posts"));
    posts.forEach((post) =>
      expect(screen.getByText(post.title)).toBeInTheDocument()
    );
  });

我们使用 mockResolvedValueOnce 返回一些假数据。然后,我们等待异步方法解析并等待 Posts 组件重新渲染。为此,我们使用 waitFor 方法,同时检查标题是否呈现,之后遍历检查,确保每一个标题在页面上。

测试接口错误

接下来我们要测试错误是否被正确呈现,那么只需要修改 mock:

test("should show an error message on failures", async () => {
    fetchPosts.mockRejectedValueOnce("Error!");

    render(<Posts />);
    expect(screen.getByText("Loading...")).toBeInTheDocument();

    await waitFor(() => {
      expect(screen.getByText("服务端错误")).toBeInTheDocument();
    });
    expect(fetchPosts).toHaveBeenCalledWith();
    expect(fetchPosts).toHaveBeenCalledTimes(1);
  });

小结

以下是测试异步组件的步骤:

  1. 通过 mock 使组件可以获取静态假数据;
  2. 测试加载状态;
  3. 测试异步方法是否被正确调用,并且带上了正确的参数;
  4. 测试组件是否正确地渲染了数据
  5. 测试异步方法错误时,组件是是否渲染了正确的状态

文中关于登录成功后页面跳转并未测试,那么如何测试 react 路由 ?请关注我,我会尽快出 React test 系列的下文。

希望这篇文章对大家有所帮助,也可以参考我往期的文章或者在评论区交流你的想法和心得,欢迎一起探索前端。

相关文章
|
2月前
|
JavaScript 安全 编译器
TypeScript 与 Jest 测试框架的结合使用,从 TypeScript 的测试需求出发,介绍了 Jest 的特点及其与 TypeScript 结合的优势,详细讲解了基本测试步骤、常见测试场景及异步操作测试方法
本文深入探讨了 TypeScript 与 Jest 测试框架的结合使用,从 TypeScript 的测试需求出发,介绍了 Jest 的特点及其与 TypeScript 结合的优势,详细讲解了基本测试步骤、常见测试场景及异步操作测试方法,并通过实际案例展示了其在项目中的应用效果,旨在提升代码质量和开发效率。
55 6
|
2月前
|
监控 JavaScript 前端开发
如何在实际应用中测试和比较React和Vue的性能?
总之,通过多种方法的综合运用,可以相对客观地比较 React 和 Vue 在实际应用中的性能表现,为项目的选择和优化提供有力的依据。
43 1
|
2月前
|
JavaScript 测试技术 API
Jest进阶:测试 Vue 组件
Jest进阶:测试 Vue 组件
|
2月前
|
前端开发 JavaScript 安全
学习如何为 React 组件编写测试:
学习如何为 React 组件编写测试:
44 2
|
2月前
|
前端开发 JavaScript 测试技术
React 模拟测试与 Jest
【10月更文挑战第21天】本文介绍了如何使用 Jest 进行 React 组件的单元测试和模拟测试,涵盖了基础概念、常见问题及解决方案,并提供了实践案例。通过学习本文,你将掌握如何有效地使用 Jest 提高代码质量和稳定性。
88 1
|
3月前
|
测试技术 C# 数据库
C# 一分钟浅谈:测试驱动开发 (TDD) 实践
【10月更文挑战第18天】测试驱动开发(TDD)是一种软件开发方法论,强调先编写测试代码再编写功能代码,以确保代码质量和可维护性。本文从 TDD 的基本概念入手,详细介绍了其核心步骤——编写测试、运行测试并失败、编写代码使测试通过,以及“红绿重构”循环。文章还探讨了 TDD 的优势,包括提高代码质量、促进设计思考、减少调试时间和文档化。此外,文中分析了常见问题及解决方案,如测试覆盖率不足、测试代码过于复杂、忽视重构和测试依赖过多,并通过一个简单的计算器类的代码案例,展示了 TDD 的实际应用过程。
51 1
|
2月前
|
资源调度 前端开发 JavaScript
React 测试库 React Testing Library
【10月更文挑战第22天】本文介绍了 React Testing Library 的基本概念和使用方法,包括安装、基本用法、常见问题及解决方法。通过代码案例详细解释了如何测试 React 组件,帮助开发者提高应用质量和稳定性。
75 0
|
3月前
|
前端开发 JavaScript 应用服务中间件
linux安装nginx和前端部署vue项目(实际测试react项目也可以)
本文是一篇详细的教程,介绍了如何在Linux系统上安装和配置nginx,以及如何将打包好的前端项目(如Vue或React)上传和部署到服务器上,包括了常见的错误处理方法。
926 0
linux安装nginx和前端部署vue项目(实际测试react项目也可以)
|
5月前
|
资源调度 前端开发 API
React Suspense与Concurrent Mode:异步渲染的未来
React的Suspense与Concurrent Mode是16.8版后引入的功能,旨在改善用户体验与性能。Suspense组件作为异步边界,允许子组件在数据加载完成前显示占位符,结合React.lazy实现懒加载,优化资源调度。Concurrent Mode则通过并发渲染与智能调度提升应用响应性,支持时间分片和优先级调度,确保即使处理复杂任务时UI仍流畅。二者结合使用,能显著提高应用效率与交互体验,尤其适用于数据驱动的应用场景。
89 20
|
5月前
|
Java 测试技术 开发者
提高代码质量:深入实践测试驱动开发(TDD)
【8月更文挑战第14天】测试驱动开发是一种强大的软件开发方法,它通过先写测试再编写代码的方式,显著提高了代码质量。通过实践TDD,开发者可以编写出更可靠、更易于维护的代码,并加速开发进程。虽然TDD需要一定的学习和适应过程,但其带来的长期收益是不可估量的。如果你还没有尝试过TDD,现在就开始吧!