[Unit Test] 3.3 - 測試 api 與 React Query 的最佳幫手 - MockServiceWorker
前言
在前述 3.1 - 與 api 的測試 有提到 api 的測試,其實要在每個測試都去 mock api data 是件非常繁瑣的事,而且針對同一個 component,常常會需要 mock 相同的 api data,這樣便大大減少了可維護性
再加上現在因應 React Query 等 Server state management tool 的出現,這種測試方法變得很困難,甚至會需要直接去 mock React Query 所提供的 hook 來進行 mock api data 的動作,相對的非常不直覺很多,我們希望僅 mock 我們需要的 api data 部分,而不是整個 hook
所以,針對上述 2 個案例,我們就出現了 Mock Service Worker 來幫我們解決上述問題,以下會針對上述 2 點:
- 常常重複 api data mocking
- 難以 mock React Query result
來進行細部解說
常常重複 api data mocking
當我們在 mock api 時,有一些問題,例如:
- 測試不同的 component 時,假設用到同一隻 api,我們會需要重新 mock api,
( 雖然可以把 mock api 放在
/__mock__底下來避免重複撰寫,但是目前會有 Typescript 的型別問題 ) - 若有一個 custom hook 會去打 api,當我們在測試 custom hook 時,已經撰寫了一次 mock api,當在測試使用該 custom hook 的 component,我們必須在測試該 component 時重新撰寫一次 mock api,造成維護上不是很方便
Functional component
- Functional component product code
const useUserLocations = () => {
const [userLocations, setUserLocations] = useState();
const fetchUserLocations = async () => {
const users = await apiGetUsers();
const locations = users.map((user) => user.location);
return locations;
}
useEffect(() => {
fetchUserLocations()
.then((locations) => {
setUserLocations(locations);
})
.catch(...)
}, []);
return userLocations;
};
- Functional component testing code
describe('useFetchUserLocations', () => {
test('by default, should return an array containing users locations', () => {
// Arrange
apiGetUser.mockResolvedValue([
{ name: 'Alen', location: 'America' },
{ name: 'Benson', location: 'Taiwan' },
{ name: 'Camillie', location: 'French' },
]);
// Act
const { result } = renderHook(() => useFetchUserLocation());
// Assert
expect(result.current).toEqual(['American', 'Taiwan', 'French']);
});
});
Class Component
- Class component product code
// component's code & Testing
import useUserLocations from '@/hooks/useUserLocations';
const UserStatic = () => {
const userLocations = useUserLocations(); // using the hook above
return (...); // pretended this render a pie chart with label
};
- Class component testing code
describe('UserStatic', () => {
test('when users exist and have locations, should show location label', () => {
// Arrange
apiGetUser.mockResolvedValue([
{ name: 'Alen', location: 'America' },
{ name: 'Benson', location: 'Taiwan' },
{ name: 'Camillie', location: 'French' },
]); // mock the same value again !!
// Act
const { getByTestId } = render(<UserStatic />);
const labelAmerica = getByTestId('label-America');
// Assert
expect(labelAmerica).toBeVisible();
});
});
上述是我們在對 hook 和 component 要做 mock api data 的部分,如果每個 test case 都需要這樣重複撰寫 api data,則會變得非常繁瑣
問題:難以 mock React Query data
我在研究如何測試 React Query 的時,發現 React Query 其實沒那麼好測試,因為他已經是一個封裝好的 hook,內部有很多我不清楚的實現方式,想要利用 mock axios 的方式來對使用 React Query 的工作單位來做測試也沒這麼容易,通常需要不少奇淫技巧
我花了一番時間研究後,忽然發現一篇文章( Stop mocking fetch by Kent C. Dodds )有寫到如何解決這問題,就是與其在測試檔案一次次的撰寫 mock api,我們其實可以去偽造整個 api service !!!
我們就可以讓我們的 unit test 真的去打 api,但是打的是 mock service worker 提供的 api,而這些假的 service 會集中管理這些 api,這樣可以避免我們多次在測試檔寫 mock api,也方便我們統一管理所有的假 api
MSW 簡介
MSW 的全名是 Mock Service Worker,就是可以讓我們偽造 service worker,讓我們的測試程式碼可以依照原本的流程去打 api,但會被 msw 處理,而回傳我們自己偽造的結果
設定方法如下:
import { rest } from 'msw' // msw supports graphql too!
import * as users from './users'
const handlers = [
rest.get('/users', async (req, res, ctx) => {
const users = [
{ name: 'Alen', location: 'America' },
{ name: 'Benson', location: 'Taiwan' },
{ name: 'Camillie', location: 'French' },
];
return res(ctx.json(users));
}),
rest.post('/users', async (req, res, ctx) => {
if (req.name && req.email && req.location) {
return res(
ctx.staus(200)
ctx.json({ success: true })
);
}
}),
];
export { handlers };
// test/server.js
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { handlers } from './server-handlers'
const server = setupServer(...handlers)
export { server, rest };
// test/setup-env.js
// add this to your setupFilesAfterEnv config in jest so it's imported for every test file
import {server} from './server.js'
beforeAll(() => server.listen())
// if you need to add a handler after calling setupServer for some specific test
// this will remove that handler for the rest of them
// (which is important for test isolation):
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
而且 msw 更大的好處是,因為內部實作是靠 msw 作者自己去覆寫掉整個 Node.js 的 fetch, axios 和 XMLHttpRequest,
不是真的架一個 mock server,所以也可以直接使用在 CICD 的流程,不需要另外設定