Testing library 101 (二)

书接上文,上篇说到了 React Testing library 的安装和最基本用法。本篇继续深挖一些较复杂的场景。

mock 测试

开始 RTL 测试前,我们稍微回顾一下 Jest 的 Mock 测试。

所谓 mock 测试就是在测试过程中,对于某些不容易构造或者不容易获取的对象,用一个虚拟的对象代替具体实现,以便继续测试进度的方式。

其中最常用的 mock 方式有两种:

  1. 创建一个 mock 函数,注入到目标用例里, 如 jest.fn
  2. 设计一个手工的 mock 对象,来覆盖依赖模块,如 jest.mock

jest.fn()

先来说 mock 函数注入。我们写一个最最基础的 repeatTen 函数,功能就是调用10 次其他函数。

function repeatTen(fn) {
  for (let i = 0; i < 10; i++) {
    fn();
  }
}

repeatTen(() => console.log("Onion"));

repeatTen 函数的特点就是:它根本不关心 fn 的具体实现,只要保证 fn 一次性能跑到10趟就行。这类单元测试就非常适合使用 mock 函数了。如下所示,我们以参数的形式,为 repeatTen 传入一个 mock 函数(onChang),然后检查 mock 函数的调用状态,判定函数是否如期运行就行了。

it("Count the calls of the onChange", () => {
  const onChange = jest.fn();
  repeatTen(onChange);
  // assert that it is called 10 times
  expect(onChange).toHaveBeenCalledTimes(10);
});

jest.mock()

上文 repeatTen 函数的实现比较单纯,现实世界则复杂很多;有些函数的实现会依赖于三方库。在测试环境里你很难以传入一个 mock 函数的形式来模拟真实的运行状态。比如下面这个函数,通过 axios 调用 API 来返回数据,大家想想该怎么写 test case。

const loadStories = () => axios.get("/stories");

我们要测试 loadStories 但又不大可能调用真实的 API,就只能对 axios 的内部模块动点小脑筋了——用 jest.mock("axios") 自动模拟 axios 模块。

Jest 提供了一个很有意思的依赖覆盖方法——jest.mock("axios"),它会给对象模块——axios——的.get方法提供一个 mockResolvedValue。通俗来说,就是让axios.get("/stories") 返回一个假的 response;而这个 response 的数据是我们事先准备好的。

看一下写 mock 依赖测试的主要流程:

  1. 利用 jest.mock(...) 覆盖用例函数内部 import 的依赖
  2. 为用例函数内部使用的某个方法构造一个 mock 返回
  3. 断言用例函数的返回结果
import axios from "axios";

// step1: override a module
jest.mock("axios");

const mockStories = [
  { objectID: "1", title: "Hello" },
  { objectID: "2", title: "React" },
];

it("load stories by axios", async () => {
  // step2: make a fake response
  const response = { data: mockStories };
  axios.get.mockResolvedValue(response);

  // step3: assert the data
  const { data } = await loadStories();
  expect(data).toEqual(mockStories);
});

callback 测试

OK,兜兜转转说了很长篇幅的 Jest 方法。我们还是回到 React Testing library(以下简称RTL)。

如果大家看懂了 Jest.fn 章节的内容,RTL 的 callback 测试就一目了然了——就是来测试 onChange 事件的。我们写一个简单的搜索条,通过 onChange 事件返给父组件输入的内容。

// CallbackSearch.js
export function CallbackSearch({ onChange }) {
  return (
    <div>
      <label htmlFor="search">Search:</label>
      <input id="search" type="text" onChange={onChange} />
    </div>
  );
}

该组件的测试怎么写呢?很简单,反正只有一个 onChange 参数,我们只要测试它的调用状态就行了。

import userEvent from "@testing-library/user-event";

describe("Search", () => {
  it("paste counts", () => {
    const onChange = jest.fn();
    render(<CallbackSearch onChange={onChange} />);

    const $e = screen.getByRole("textbox");
    userEvent.paste($e, "Onion");

    expect(onChange).toHaveBeenCalledTimes(1);
  });
});

这次我们使用的是 userEvent 的paste方法,输入框内黏贴一段文本,自然只触发一次事件,所以断言 onChange 被调用了 1 次即可。假如你用的是userEvent.type,那就是每输入一个字符都会回调一次,就要断言 onChange 的掉用次数为输入字符串的长度了。

异步加载测试

上一章讲了 jest.fn 的案例,我们再说说 jest.mock 的案例。

React Hook

现在不是流行写 Hook 吗?我们就写个 loadStories 的加强版 Hook,主要功能还是老样子,利用 axios 远程调用 API;成功了就把数据写到 stories 这个 state 里,失败了就把错误写到 error 这个 state 里。

// userStory.js
import axios from "axios";
import { useState, useCallback } from "react";

export function useStory() {
  const [stories, setStories] = useState([]);
  const [error, setError] = useState(null);

  const handleFetch = useCallback(() => {
    const loadStories = async () => {
      try {
        const { data } = await axios.get("/stories");
        setStories(data);
      } catch (error) {
        setError(error);
      }
    };
    loadStories();
  }, []);

  return { error, stories, handleFetch };
}

给 React Hook 写单元测试需要额外安装一个依赖,@testing-library/react-hooks;主要是用了它的一个方法 readerHook 来模拟 react 组件的场景。

我们看一下怎么写这个 Hook 的 test case,套路是相似的:

  1. 利用 jest.mock 覆盖 axios 模块
  2. 伪造一个 axios.get 的 response
  3. 断言 stories 的初试状态是空数组
  4. 调用异步方法加载 stories
  5. 完成异步调用后,断言 stories 的最新状态
import { renderHook } from "@testing-library/react-hooks";
import axios from "axios";
import { useStory } from "./useStory";

// Step 1: override a module
jest.mock("axios");

const mockStory = [
  { objectID: "1", title: "Hello" },
  { objectID: "2", title: "React" },
];

it("load stories and succeed", async () => {
  // Step 2: fake a response
  const response = { data: mockStory };
  axios.get.mockResolvedValue(response);

  const { result, waitForNextUpdate } = renderHook(() => useStory());

  // Step 3: test initial state
  expect(result.current.stories).toEqual([]);

  // Step 4: fetch stories
  result.current.handleFetch();

  // Step 5: test after load axios api
  await waitForNextUpdate();
  expect(result.current.stories).toEqual(mockStory);
});

React Component

测试完 Hook,我们把它放到组件里。老规矩,先不看实现,看效果:

FetchButton

简单来说就是写了一个 button,点击后会异步调用数据,然后展示出所有的 stories 条目。

还是照搬上面的套路:

  1. 利用 jest.mock 覆盖 axios 模块
  2. 伪造一个 axios.get 的 response
  3. 点击按钮,即异步加载 stories
  4. 结束后,断言屏幕上会出现了相应的 story 条目
import axios from "axios";

// Step 1: override a module
jest.mock("axios");

const mockStory = [
  { objectID: "1", title: "Hello" },
  { objectID: "2", title: "React" },
];

describe("FetchButton", () => {
  it("fetches stories from an API and displays them", async () => {
    render(<FetchButton />);

    // Step 2: fake a response
    const response = { data: mockStory };
    axios.get.mockResolvedValue(response);

    // Step 3: click button
    userEvent.click(screen.getByRole("button"));

    // Step 4: assert that screen will display 2 items
    const $items = await screen.findAllByRole("listitem");
    expect($items).toHaveLength(2);
  });
});

教课书里的 TDD 要求先写测试,再写实现的。我这里也尽量按照这个思路排版,大家看完单元测试,也应该有了大体的组件实现轮廓了吧?我把我的实现写出来:

// FetchButton.js
import { useStory } from "./useStory";

export function FetchButton() {
  const { error, stories, handleFetch } = useStory();

  return (
    <div>
      <button onClick={handleFetch}>Fetch Stories</button>

      {error && <span>Something went wrong ...</span>}

      <ul>
        {stories.map((story) => (
          <li key={story.objectID}>{story.title}</li>
        ))}
      </ul>
    </div>
  );
}

异常测试

我们接着说上面的 FetchButton。在写实现的时候,我发现了之前测试里有个重大疏漏:异步调取的 API 可能会返回错误,之前的单元测试没有覆盖到抛异常的情况。所以我又在实现里加了个 error 的判断;有错误就显示 Something went wrong ...。既然有疏漏,就继续补 test case。这种测试其实更简单,基本上就是套用上文的步骤,唯一不同之处就是让 axios.get 返回mockRejectedValue。大家看看下面的 test case,应该已经没有难点了。

jest.mock("axios");

describe("FetchButton", () => {

  it("fetch stories from an API but fail", async () => {

    render(<FetchButton />);

+    axios.get.mockRejectedValue(new Error()));

    userEvent.click(screen.getByRole("button"));

    const $errorMsg = await screen.findByText(/Something went wrong/);

    expect($errorMsg).toBeInTheDocument();
  });
});

小结

我最近又回看了2020 年 JS 满意度调查,发现 Testing Library 位居测试榜榜首,还是很有群众基础的。我们用了两期时间介绍了 Testing Library 的入门教程,也希望大家能尽快把 TDD 落实到自己的项目中。我见过好多项目几乎没有任何测试,一两年就烂掉了;一加新功能就四处漏风,后期 bug 数量急速上升,release 甚至能因此拖延半年之久;屎山一堆积,想改就再也改不了了。TDD 是软件行业多年来的最佳实践,我们作为该行业的从业人员,也应该坚定地按行业规律办事;这不仅是“干活勤快”这么简单,更多的是自己职业素养的体现,共勉。

State of JS 2020

相关

推荐阅读更多精彩内容