[Day27] 建立 Mock Module / Function 的方式(ft. TypeScript)

Mock Module

今天来谈谈 Mock Module / Function 的部分。Mock Function 是在 Jest 中非常强大的功能,它可以模拟某个函式会回传的值,并且监控该函式被执行的次数,而 Mock Module 可以说是垫加在 Mock Function 上的功能,也就是可以直接模拟某套件中特定函式回传的结果。

虽然说在写测试时 Mock Module 很常会被使用到,但因为 Jest 提供了几种不同的方式让开发者能够 Mock Module,因此过去在找资料时常会看到多种不同写法而对 Mock 的方式感到有点混乱,这篇内容则是整理了笔者比较常用的方式。

建立一个简单的测试范例

在开始说明 Mock Module 的概念前,让我们先来看个例子。现在我们有个 React 元件长这样:

import { useEffect, useState } from 'react';
import { fetchUser } from './utils/api';
import { User } from './types';

function App() {
  const [users, setUsers] = useState<User[]>([]);

  useEffect(() => {
    fetchUser<User[]>().then((users) => {
      return setUsers(users);
    });
  }, []);

  return (
    <div className="App">
      <header className="App-header">
        <ul>
          {users.map((user) => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      </header>
    </div>
  );
}

export default App;

<App /> 中,会载入 ./utils/api 这支档案,在 ./utils/api 中则撰写一个用来 fetch User API 的方法,它会实际向 jsonplaceholder 发送请求:

// ./utils/api
export const fetchUser = async <T>() => {
  const resp = await fetch('https://jsonplaceholder.typicode.com/users');
  const data = (await resp.json()) as T;
  return data;
};

预设的情况下,jsonplaceholder 会回传 10 个使用者的资料回来,这时候画面会长这样:

Mock Module

接着一如往常的来撰写测试,可以写成像这样:

// App.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
import { create } from 'react-test-renderer';

test('render user data successfully', async () => {
  render(<App />);
  const listItemElements = await screen.findAllByRole('listitem');
  expect(listItemElements.length).toBe(10);
});

在这个测试中我们透过 react-testing-library 提供的 render 来转译出 <App /> 这个元件,接着在 DOM 上找出 <li /> 的元素,并 assert 它应该有 10 个,测试也顺利的通过了:

Mock Module

这里笔者不会说明如何使用 react-testing-library 来撰写 React 元件的测试,如果有需要的话,推荐可以看 React Testing Library Tutorial @ Youtube。

但等等,这个测试实际上真的有去打 API 吗?你觉得呢?

我们可以在 <App /> 元件中的 useEffect 中加上 console.log('[App] users', users);,看看取得的资料是否和从 jsonplaceholder 回来的资料相同:

useEffect(() => {
  fetchUser<User[]>().then((users) => {
    console.log('[App] users', users);
    return setUsers(users);
  });
}, []);

当郎~的确是打 API 回来的资料:

Mock Module

为什麽要建立 Mock Module/Function

从上面的例子中可以看到,我们真的对 jsonplaceholder 发出了请求,但等等,这样是不是表示我们的测试以後都需要连上网路才能执行?就算连网本身不是问题,未来如果 jsonplaceholder 的网站维修的话,是不是表示我们的测试也会连带无法进行。

除非是後期的 E2E 测试,否则当我们的测试依赖外部的资源时,是一件不太好的事,因为测试 failed 的时候,开发者将没办法马上知道现在测试 failed 的原因是我们的程序写坏了,还是外部的网站挂了。逻辑上同样的程序码每次执行测试得到的结果应该要是稳定的,不会现在跑没事,下次跑却莫名失败。

未来确保执行测试时能够控制不要让外部的变数影响到测试的稳定度,我们可以透过 Jest 的 Mock Function 来模拟 API 回传的结果,让这个元件不需要实际发送请求。

实际上,不只是串接 API 才需要使用到 Mock Function,如果要测试的内容包含会持续变动的时间、路由(router)、custom hooks 等等,也都可也透过 Mock Function 模拟。

使用 Mock Module/Function 来模拟回传值

在撰写 Mock Module 的时候,开发者要清楚知道现在要 Mock 的 Function 是什麽,以上面的例子来说,我们想要模拟的应该是 fetchUser 这个方法:

Mock Module

因为只要能模拟 fetchUser 回传的结果的话,就不需要实际向 jsonplaceholder 发送 API 请求。

要模拟 fetchUser ,只需要在测试档(App.test.tsx)中,加上这两行:

  1. 载入要被 mock 的 module(虽然在 test 档中不会用到)

  2. 使用 jest.mock(<module 的路径或名称>) 来 mock 它

// App.test.tsx

// 1. 载入要被 mock 的 module(虽然在 test 档中不会用到)
import { fetchUser } from './utils/api';

// 2. 使用 `jest.mock(<module 的路径或名称>)` 来 mock 它
jest.mock('./utils/api');

test('render user data successfully', async () => {
  // ...
});

在使用了 jest.mock('./utils/api') 後,现在的这个 fetchUser 已经不是原本的 fetchUser 了,而是 Jest 的 mockFn(即 jest.fn()),。因此就可以使用 Jest mockFn 提供的许多方法。举例来说,这里可以使用 mockResolvedValue 来模拟 fetchUser 会回传的内容:

test('render user data successfully', async () => {
  // 模拟 fetchUser 会回传的内容
  fetchUser.mockResolvedValue([
    {
      id: 1,
      name: 'Leanne Graham',
      username: 'Bret',
      email: '[email protected]',
    },
    {
      id: 2,
      name: 'Ervin Howell',
      username: 'Antonette',
      email: '[email protected]',
    },
  ]);

  render(<App />);
  const listItemElements = await screen.findAllByRole('listitem');
  expect(listItemElements.length).toBe(2);
});

最神奇的地方就在这里了!这点非常非常重要!!

读者可能会觉得很奇怪,我们是在测试档 App.test.tsx 中汇入并模拟 fetchUser 这个方法的回传值,但实际上会影响到的是在 AppfetchUser 被呼叫到时的回传值。什麽意思,刚刚我们曾经在 App.tsx 中使用 console.log() 检视 fetchUser 回传的结果,我们来看看这时候 useEffect 中 fetchUser 回传的结果是什麽:

Mock Module

你会发现虽然是在 App.test.tsx 中 mock 了 fetchUser 这个方法,但实际上造成效果的地方会是在 App.tsx 中。这时候因为我们已经 mock 了 fetchUser 这个函式,所以 fetchUser 就不会再真的去对 jsonplaceholder 发送 API 请求。

透过 Mock Module/Function 的使用,就可以确保测试的结果不会受外部服务而有影响。

搭配 TypeScript 使用

如果有在使用 TypeScript 的话,你可能会发现到测试档中在使用 mockFn 的地方 TypeScript 会报错:

Mock Module

报错的原因在於虽然我们用 jest.mock() 来让 fetchUser 变成了一个 mockFn,逻辑上 fetchUser 可以使用 mockFn 中的所有方法,但因为 TypeScript 并不知道 fetchUser 已经变成了 mockFn 这件事,因此会报错。

要解决这个问题,只需把 fetchUser 使用 Type Assertion 指定成 jest.Mockjest.MockedFunction<typeof fetchUser> 即可,通常可以写成像这样:

import { fetchUser } from './utils/api';
jest.mock('./utils/api');

const mockedFetchUser = fetchUser as jest.MockedFunction<typeof fetchUser>; // 也可以 as jest.Mock

test('render user data successfully', async () => {
  mockedFetchUser.mockResolvedValue([
    /* ... */
  ]);

  // ...
});

这时候因为有做 Type Assertion 的关系,在使用 mockedFetchUser 时就不会有 TypeScript 的型别错误。

此外,为了节省每次都需要使用 as jest.MockedFunction<...> 的写法,有开发者包好了一个 mockFunction 的 utility:

// credit and refer to:
// https://instil.co/blog/typescript-testing-tips-mocking-functions-with-jest/

export function mockFunction<T extends (...args: any[]) => any>(
  fn: T
): jest.MockedFunction<T> {
  return fn as jest.MockedFunction<T>;
}

使用时只需要变成:

import { fetchUser } from './utils/api'; // 要被 mock 的 module
import mockFunction from './utils/mockFunction';
jest.mock('./utils/api'); // 让该 module 变成 mockFn
const mockedFetchUser = mockFunction(fetchUser); // 处理 TS 型别

test('render user data successfully', async () => {
  mockedFetchUser.mockResolvedValue([
    /* ... */
  ]); // 建立 mock data

  // ...
});

就完成了。

Mock Module/Function 学习重点

需要在「测试档」中撰写 mock module 和 mock data,实际的效果会作用在「原程序」中。

  1. 先找出「原程序」中要 Mock 的 module,例如 fetchUser
  2. 在「测试档」中载入要被 Mock 的 module,例如 import { fetchUser } from './utils/api'
  3. 在「测试档」中使用 jest.mock<path_or_name> 来 mock 该 module
  4. (TS Optional)使用 mockFunction 这个 utility 或使用 Type Assertion
  5. 在「测试档」中,撰写该 function 的 mock return value,例如,mockedFetchUser.mockResolvedValue
  6. 在「原程序」中确认该 function 执行的回传值是 mock 後的结果

推荐资源

参考资料


<<:  Day_30 RPI GPIO

>>:  [Day 27] Web 小迷茫

我们的基因体时代-AI, Data和生物资讯 Day21- 基因注释资料在Bioconductor中物件:IRanges和GenomicRanges

上一篇我们的基因体时代-AI, Data和生物资讯 Day20-注释基因资讯的BED档案格式和bed...

Day25 实作MiddleWare(2)

昨天跟大家介绍完middleware的基本建立後,大家有没有理解并成功做出来呢! 如果对於这部分还有...

D16 - 用 Swift 和公开资讯,打造投资理财的 Apps { 加权指数 K 线图实作.4 - 在 X 轴标上每一根 K 棒的日期 }

目前我们已经做出台股加权指数的 K 线图,但目前进度的线图的 x 轴没有时间,所以当使用者看到这张图...

Ruby解题分享--Climbing Stairs

当开始可以发现韩国女团,每个人长得都不一样时,就代表你长大了... Climbing Stairs ...

Day 10 - 转换人生跑道

简介 casting 就是资料型态之间的转换。 例如把 A type 转换成 B type。 但是这...