[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
React 的antd-mobile 组件库,嵌套路由
React 的antd-mobile 组件库,嵌套路由
48 0
|
2天前
|
算法 测试技术 开发者
测试驱动开发(TDD)实战:从理论到实践
【5月更文挑战第8天】TDD实战指南:先测试后开发,确保代码质量与可维护性。核心思想是编写测试用例→实现代码→验证→重构。优点包括提高代码质量、促进设计思考和增强可测试性。实战步骤包括编写独立、明确的测试用例,遵循最小可用原则编写代码,运行测试并分析失败原因,以及在验证通过后进行代码重构与优化。通过TDD,开发者能提升编程技能和项目成功率。
|
2天前
|
测试技术 持续交付 Swift
【Swift开发专栏】Swift中的测试驱动开发(TDD)
【4月更文挑战第30天】Test-Driven Development (TDD) 是一种软件开发方法,强调先编写测试用例再写代码。通过测试驱动代码、简明设计、重构和持续集成的循环过程,TDD助力构建高质量Swift软件。在Swift中,使用XCTest框架进行TDD实践,包括编写测试用例、实现功能、运行测试和重构。TDD的优势在于提升代码质量、减少调试时间,且与持续集成相结合。然而,学习曲线和确保测试覆盖率是挑战。TDD不仅是技术实践,也是思维方式,随着Swift的发展,其应用将更广泛。
|
2天前
|
测试技术 开发者
【专栏】测试驱动开发(TDD)和行为驱动开发(BDD)的核心概念与实践
【4月更文挑战第27天】本文探讨了测试驱动开发(TDD)和行为驱动开发(BDD)的核心概念与实践。TDD强调先写测试用例,通过测试推动设计,确保代码质量与可维护性。BDD侧重软件行为和业务价值,提倡使用通用语言描述行为,减少沟通障碍。选择TDD或BDD取决于项目复杂性、团队技能和业务需求。理解两者差异有助于团队做出合适的选择,发挥测试的最大价值。
|
2天前
|
IDE Java 测试技术
Java 测试驱动开发(TDD)实践指南
【4月更文挑战第19天】测试驱动开发(TDD)在Java中强调先写测试用例再写代码,以提升代码质量和可维护性。核心原则是先写测试,确保代码功能正确,降低风险并促进良好设计。实践包括选择IDE,安装JUnit,明确需求,编写失败测试,写最小代码使测试通过,重构并重复该过程。编写测试用例应明确、独立且可重复。TDD能增强开发信心,提高效率,保证代码维护性。遵循指南,开发者能写出高质量代码,为未来开发打下基础。
|
2天前
|
设计模式 IDE 测试技术
深入理解自动化测试框架Selenium的核心组件
【2月更文挑战第30天】 在快速迭代的软件开发过程中,自动化测试已成为确保产品质量和加快上市速度的关键。本文将深入探讨Selenium这一广泛使用的自动化测试框架,剖析其核心组件以及它们如何协同工作以提供高效、灵活的测试解决方案。我们将从Selenium架构的基础出发,详细解读WebDriver API、Selenium Grid、以及各种语言绑定等关键部分,并讨论如何通过这些组件进行有效的UI测试。
|
2天前
|
存储 前端开发 中间件
React组件间的通信
React组件间的通信
18 1
|
2天前
|
测试技术 Python
Python中的单元测试与测试驱动开发(TDD)实践
Python中的单元测试与测试驱动开发(TDD)实践
|
2天前
|
前端开发 应用服务中间件 数据库
react服务端组件
react服务端组件
24 0
|
2天前
|
前端开发 JavaScript
快速上手React:从概述到组件与事件处理
快速上手React:从概述到组件与事件处理

热门文章

最新文章