[Day27] 建立 Mock Module / Function 的方式(ft. TypeScript)
keywords: testing
, react-testing-library
, jest
今天來談談 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 個使用者的資料回來,這時候畫面會長這樣:
接著一如往常的來撰寫測試,可以寫成像這樣:
// 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 個,測試也順利的通過了:
這裡筆者不會說明如何使用 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/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
這個方法:
因為只要能模擬 fetchUser
回傳的結果的話,就不需要實際向 jsonplaceholder 發送 API 請求。
要模擬 fetchUser
,只需要在測試檔(App.test.tsx
)中,加上這兩行:
-
載入要被 mock 的 module(如果沒有要在 test 中使用到這個 method 則可省略)
-
使用
jest.mock(<module 的路徑或名稱>)
來 mock 它
// App.test.tsx
// 1. 載入要被 mock 的 module(如果沒有要在 test 中使用到這個 method 則可省略)
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: 'Sincere@april.biz',
},
{
id: 2,
name: 'Ervin Howell',
username: 'Antonette',
email: 'Shanna@melissa.tv',
},
]);
render(<App />);
const listItemElements = await screen.findAllByRole('listitem');
expect(listItemElements.length).toBe(2);
});
最神奇的地方就在這裡了!這點非常非常重要!!
讀者可能會覺得很奇怪,我們是在測試檔 App.test.tsx
中匯入並模擬 fetchUser
這個方法的回傳值,但實際上會影響到的是在 App
中 fetchUser
被呼叫到時的回傳值。什麼意思,剛剛我們曾經在 App.tsx
中使用 console.log()
檢視 fetchUser 回傳的結果,我們來看看這時候 useEffect 中 fetchUser 回傳的結果是什麼:
你會發現雖然是在 App.test.tsx
中 mock 了 fetchUser 這個方法,但實際上造成效果的地方會是在 App.tsx
中。這時候因為我們已經 mock 了 fetchUser 這個函式,所以 fetchUser 就不會再真的去對 jsonplaceholder 發送 API 請求。
透過 Mock Module/Function 的使用,就可以確保測試的結果不會受外部服務而有影響。
直接 Mock 這個 Method(不 import 原本的 method)
在上面的範例中,我們是從原本的 package 中 import fetchUser
來用,這麼做會有一種怪怪的感覺,怎麼這個 fetchUser
和實際上想的不一樣。
有另一種作法是不要 import 這個 package,而是直接 mock 這個 package 和裡面的方法,例如:
// 不需要在 import 原本的 package
// import { fetchUser } from './util/api';
// 直接 mock 這個 package
jest.mock('./util/api', () => ({
fetchUser() {
return new Promise((resolve) => {
resolve([
{
id: 1,
name: 'Leanne Graham',
username: 'Bret',
email: 'Sincere@april.biz',
},
{
id: 2,
name: 'Ervin Howell',
username: 'Antonette',
email: 'Shanna@melissa.tv',
},
]);
});
},
}));
如此可以得到一樣的結果。
搭配 TypeScript 使用
如果有在使用 TypeScript 的話,你可能會發現到測試檔中在使用 mockFn 的地方 TypeScript 會報錯:
報錯的原因在於雖然我們用 jest.mock()
來讓 fetchUser
變成了一個 mockFn,邏輯上 fetchUser 可以使用 mockFn 中的所有方法,但因為 TypeScript 並不知道 fetchUser 已經變成了 mockFn 這件事,因此會報錯。
要解決這個問題,只需把 fetchUser
使用 Type Assertion 指定成 jest.Mock
或 jest.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
// ...
});
就完成了。