[note] React Testing
$ npm test # 執行測試
$ npm test -- --coverage # 檢視測試覆蓋率
Snippets
// https://www.udemy.com/course/learn-react-query/learn/lecture/26581756
import {
fireEvent,
screen,
waitForElementToBeRemoved,
} from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { mockUser } from '../../../mocks/mockData';
import { render } from '../../../test-utils';
import { Calendar } from '../Calendar';
// mocking useUser hooks
jest.mock('../../user/hooks/useUser', () => ({
__esModule: true,
useUser: () => ({ user: mockUser }),
}));
test('Cancel appointment', async () => {
render(
<MemoryRouter>
<Calendar />
</MemoryRouter>,
);
const cancelButtons = await screen.findAllByRole('button', {
name: /cancel appointment/i,
});
fireEvent.click(cancelButtons[0]);
const alertToast = await screen.findByRole('alert');
expect(alertToast).toHaveTextContent('Appointment cancelled');
const alertCloseButton = screen.getByRole('button', { name: 'Close' });
fireEvent.click(alertCloseButton);
await waitForElementToBeRemoved(alertToast);
});
前端測試(Testing)的概念
前端測試可以分成單元測試(unit testing)、整合測試(integration testing)和 end-to-end testing。詳細的說明可參考「前端測試的概念與類型」的內容。
前置作業
安裝
預設的情況下,透過 Create React App 所建立的專案都已經把測試所需要的套件和環境設定好了,除了 Jest 之外,也安裝好了 react testing library 可以直接使用。因此只需要在終端機輸入
npm test
即可。若是自己建立的專案,需要透過 babel 來處理
// .babelrc
// 參考設定
{
"presets": [
[
"@babel/preset-env",
{
// babel 會打包成相容於 node 12 的程式碼
"targets": {
"node": 12
}
}
],
"@babel/preset-typescript"
]
}
檔名慣例(Filename Conventions)
執行 npm test
時,Jest 預設就會去找符合下述規則的檔案進行測試:
- 檔案名稱中包含
.test
或.spec
的檔案,例如,foo.test.js
、bar.spec.js
- 在名為
__tests__
資料夾內的所有.js
、.jsx
、.ts
和.tsx
都會被執行測試
建議把執行測試的檔案和原本的檔案放在同一資料夾中,如此可以簡化 import
的路徑。舉例來說,把 App.js
和 App.test.js
放在同一個資料夾中,如此在執行測試時只需要寫 import App from './App'
即可。
Jest CLI
當執行 npm test
時,Jest 會自動進入「監控模式(watch mode)」,每當你儲存檔案它都會自動重新執行測試。這個監控器(watcher)包含一個 CLI 可以讓你選擇要執行所有的測試,還是只執行符合條件下的檔案來測試。
$ npm test
$ jest --watch
$ jest --watchAll=false # 不要監控
預設的情況下,當你執行 npm test
之後,Jest 只會針對上次 commit 後有變更的檔案進行測試,同時這也假設了你不會經常把沒有通過測試的檔案 commit 起來。
在 CLI 中你可以點擊 a
來強制 Jest 執行所有的測試。
Jest 在 continuous integration server 上、並非 Git 或 Mercurial 資料夾的專案總是會執行全部的測試。
測試環境設定與初始化
在測試中可能會使用到瀏覽器的 API 或進行全域環境的設定,這時候可以在專案中加入 src/setupTests.js
這支檔案,這支檔案會在執行測試前自動被執行。
舉例來說:
// src/setupTests.js
// 這支檔案會在執行測試前自動被執行
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
};
global.localStorage = localStorageMock;
CRA 若曾經 eject 過
若你在建立 src/setupTests.js
前已經把 create-react-app
所建立的專案執行 eject
,那麼 package.json
檔案將不會包含任何參照到 setupTests.js
的資訊,因此應該要在手動添加設定:
// package.json
"jest": {
// ...
"setupTestFrameworkScriptFile": "<rootDir>/src/setupTests.js"
}
單元測試(Unit Testing)
先從最基本的 Unit Test 開始。Unit Test 只需要使用 Jest 提供的方法即可完成,測試的起手式多半都是長這樣。使用 test
把要測試的內容包起來,Jest 則提供各種 method 可以幫你比對 expect 和 actual 之間是否相同:
基本使用
要建立測試,只需使用 it()
或 test()
函式,並在參數中放入該測試的名稱和程式碼。在 Jest 中提供內建的 expect()
函式來建立斷言(assertions),基本的測試會長得像這樣:
import sum from './sum';
it('sums numbers', () => {
expect(sum(1, 2)).toEqual(3);
expect(sum(2, 2)).toEqual(4);
});
// toBe 使用 Object.is 來比對,若想要比對物件內容是否一樣需使用 toEqual
test('two plus two is four', () => {
const actualValue = 2 + 2;
const expectedValue = 4;
expect(actualValue).toBe(expectedValue);
});
每一個 test
裡面會包起來的測試案例稱作 test case;當我們有很多相關連的 test case 想要放在一起跑或做某些相同的前處理時,可以把這些 test cases 包在 test suite 中。
在 Jest 中可以使用 describe
來建立 test suite:
describe('Test all math functions', () => {
beforeAll(() => {
/* ... */
});
test('two plus two is four', () => {
const actualValue = 2 + 2;
const expectedValue = 4;
expect(actualValue).toBe(expectedValue);
});
test('two multiplied by two', () => {
const actualValue = 2 * 2;
const expectedValue = 4;
expect(actualValue).toEqual(expectedValue);
});
});
常用方法
- Expect @ Jest Docs API
- Using Matcher @ pjchender.dev
- 判斷是否相同
expect.toBe(value)
:使用 Object.is 來比對,不適合物件、陣列、浮點數toEqual()
:比對物件內容是否相同toBeCloseTo()
:比對浮點數
- 判斷是否存在或真假
toBeTruthy()
,toBeFalsy()
toBeUndefined()
元件測試/整合測試(Integration Testing):React Testing Library
這裡會使用目前相當 Popular 用來測試 React Component 的 react-testing-library,並且一般會搭配 jest-dom 使用。
react-testing-library 其實是基於 Testing Library 的一套工具,方便 React 的開發者去針對 DOM、使用者的操作行為去進行測試。它提供了一些方法讓開發者可以去 Query DOM 裡面的元素,並且等待 component 重新 render 後看有無任何變化。適合用在測試一個較完整的 component 或 modules。
💡 透過
create-react-app
建立的 React 專案已經預裝好react-testing-library
和jest-dom。
在 React Testing Library 中提供了許多方法幫助開發者去 Query DOM 的內容,再把這些內容 Query 出來後,我們一樣需要使用 Jest 提供的 API 來比較「期待的」和「實際的」是否有差異。因此,React Testing Library 只是個幫忙測試的工具,並不是實際可以進行直接執行的 test runner。
Integration Test 和 E2E Test 很大的差異在於,Integration Test 通常不會實際去呼叫 server 的 API,而是用模擬回傳資料的方式。因此會需要模擬呼叫 API 的方法(例如,axios
或 fetch
),以及模擬 API 回傳的資料(mock data)。
除了 react-testing-library 外,還有一套稱做 react-hook-testing-library 工具,但這套工具主要是在測試 React Custom Hooks 的,並非針對 React Components,因此較靠近 Unit Test 一些。
前置設定
由於進行前端測試時,會針對 DOM 元素進行 assertion,這時候會需要使用到 @testing-library 提供的 extension library,稱作 jest-dom:
$ npm install --save-dev @testing-library/jest-dom
可以在 setupTest 時讓測試的環境全部載入這個 extension(建議):
// src/setupTests.js
// 這支檔案會在執行測試前自動被執行
import '@testing-library/jest-dom/extend-expect';
或者再會用到的 test file 中載入:
// src/example.test.js
import '@testing-library/jest-dom';
流程
在針對 React 元件進行測試時,通常都會有這樣的流程:
- render 出要測試的元件
- 找到元件中某元素位置
- 對該元素進行操作和互動
- 檢視結果是否和預期相符
// SomeComponent.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import SomeComponent from './SomeComponent';
test('render component to test', () => {
// 1. render 出要測試的元件
render(<SomeComponent />);
// 2. 找到元件中某元素位置
const button = screen.getByText(/buy it/i);
// 3. 對該元素進行操作和互動
fireEvent.click(button);
// 4. 檢視結果是否和預期相符
const banner = screen.getByText(/get promo/i);
expect(banner).toBeInTheDocument();
});
Query Methods
About Queries @ testing-library
在 React Testing Library 中提供許多不同的方法讓開發者能夠 query 到對應的元素,但他們的名稱都蠻相近的:
- 使用
queryBy
找不到該元素時不會噴錯,通常是要用來檢查某個元素不在 DOM 上時使用 - 使用
findBy
需要搭配async/await
- 如果使用的不是
All
的方法,但找到超過一個以上的元素是會噴錯;使用 All 方法時會回傳的是陣列
圖片來源:types of queries @ testing-library
針對不同的選取方式,使用 accessibility 越高的方式,優先性(priority)會越高:
- Accessible:
getByRole
,getByLabelText
,getByPlaceholderText
,getByText
- Semantic:
getByAltText
,getByTitle
- TestID:
getByTestId
透過 testing-library 提供的 configure
可以修改 getByTestId
會去 query 的 DOM attribute,參考 API Configuration。
getBy
screen.debug()
:可以看到 render 出來的 virtual DOMscreen.getByRole()
:根據 role 的類型和 options 加以找到該元素screen.getByTitle()
:根據元素上的 title attribute 來找screen.getByText(/regex/ | "string")
:找出有該 text 的 DOM Element
// https://www.codecademy.com/courses/learn-react-testing
import { render, screen } from '@testing-library/react'
const Greeting = () => {
return (
<>
<h1>Hello World</h1>
<button type="button" disabled>Submit</button>
</>
)
};
test('should prints out the contents of the DOM' () => {
render(<Greeting />);
const h1 = screen.getByText(/hello world/i);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
screen.debug();
});
使用 vscode 的話,也會顯示有哪些 role 可以選擇:
如果不清楚該元素的 role 是什麼,可以使用在 Chrome 的開發者工具,按下「Alt + Shift + P」後,搜尋 show accessibility,接著就會顯示所有元素的 role 和 name:
也可以使用 getByRole
後面的 options:
test('render Header successfully with getByRole', () => {
const headerEl = screen.getByRole('heading', { name: 'My Header' });
expect(headerEl).toBeInTheDocument();
});
queryBy
由於使用 queryBy 即使沒有找到該元素也不會噴錯,因此適合用來針對那些預期不會在 DOM 上出現的元素:
test('render Header successfully with queryByText', () => {
render(<Header title="My Header" />);
const headerEl = screen.queryByText(/not exist/i);
expect(headerEl).not.toBeInTheDocument();
});
findBy
有些情況會需要等待 API 回傳資料後才會有畫面,這時候可以用 findBy
的方法,它可以搭配 async/await
使用:
// 加上 async
test('case name', async () => {
render(<MockFollowerList />);
// 使用 await 等待資料回來
const followerItems = await screen.findAllByTestId('follower-item');
expect(followerItems.length).toBeGreaterThan(0);
});
render 的使用
user-management @ pjchender private github
// src/App.test.js
import React from 'react';
import { render } from '@testing-library/react';
import App from './App';
it('renders welcome message', () => {
const { getByText } = render(<App />);
expect(getByText('Learn React')).toBeInTheDocument();
});
若有搭配非同步的操作,可以在 test
後的 callback 帶入 async function:
import { expect, test } from '@jest/globals';
import { render } from '@testing-library/react';
import Carousel from './Carousel.js';
// 可以在 test 後的 callback 使用 async,並在函式中搭配 await 使用
test('lets users click on thumbnails to make them the hero', async () => {
const images = ['0.jpg', '1.jpg', '2.jpg', '3.jpg'];
const carousel = render(<Carousel images={images} />);
const hero = await carousel.findByTestId('hero');
expect(hero.src).toContain(images[0]);
});
rerender 的使用
import { render, waitFor, screen } from '@testing-library/react';
import myHook from '@/hooks/myHook';
describe('test some hook or utility', () => {
const MockComponent = ({ ...props }) => {
return <div {...props} />;
};
test('this myHook', async () => {
const { rerender, unmount } = render(<MockComponent />);
const errorMessage = await waitFor(() =>
screen.findByText(/Something go wrong. Please try again later./),
);
expect(errorMessage).toBeInTheDocument();
rerender(<MockComponent foo="bar" />);
const nameEmpty = screen.getByText(/Please enter your name/);
expect(nameEmpty).toBeInTheDocument();
unmount();
expect(nameEmpty).not.toBeInTheDocument();
});
});
testId 的使用
為了方便找到 DOM 元素,可以在要測試的元件上,加上 testId
:
<img data-testid="hero" src={images} alt="animal" />
這時候就可以透過 findByTestId
這個方法來取得對應的元素:
test('...', async () => {
const carousel = render(<Carousel images={images} />);
const hero = await carousel.findByTestId('hero');
});
fireEvent
userEvent @ testing-library
change event example
import { fireEvent } from '@testing-library/react';
test('should able to type in input correctly', () => {
render(<AddInput todos={[]} setTodos={mockedSetTodos} />);
const textbox = screen.getByRole('textbox');
// invoke input onChange
fireEvent.change(textbox, { target: { value: 'test' } });
expect(textbox.value).toBe('test');
screen.debug();
});
click event example
import { fireEvent } from '@testing-library/react';
test('should able to type in input correctly', () => {
render(<AddInput todos={[]} setTodos={mockedSetTodos} />);
const inputElement = screen.getByRole('textbox');
const buttonElement = screen.getByRole('button');
fireEvent.change(inputElement, { target: { value: 'test' } });
expect(inputElement.value).toBe('test');
fireEvent.click(buttonElement);
expect(mockedSetTodos).toHaveBeenCalledTimes(1);
});
常用 API
DOM Element
expect(<element>).toContainElement(<otherElement>)
expect(<element>).toBeInTheDocument()
expect(<element>).toBeVisible()
:使用者真的可以看到該元素,例如opacity: 0
的話就會 Fail。
Context Text
expect(<element>).toHaveTextContent('<some text...>')
CSS
expect(<element>).toHaveClass('<class-name>')
專注和略過某些測試
只需要把 it()
改成 xit()
就可以暫時略過某些測試項目;fit()
則可以讓你專注於某個測試項目而不用去執行其他的測試。
測試覆蓋率報告(Coverage Reporting)
執行 npm test -- --coverage
就可以取得測試覆蓋率的報告。這個部分有許多設定可以自行修改,可以參考 configuration 的部分。
快照測試(Snapshot Testing)
- Snapshot Testing @ Jest
- React Testing Done Right @ medium:有說明 test-renderer 和 shallow-renderer 的差異
Jest 提供了一個快照測試(snapshot testing),它會自動將元件產生一個文字版的快照,並儲存在硬碟上,每當 UI 改變時,就會收到通知,此時你可以檢視這個變更是不是預期的改變,是的話可以執行 jest -u
,否則的話就去修復這個 bug。
在下面的範例中,react-test-renderer
和 react-test-renderer/shallow
的差別在於:
react-test-renderer
會一直渲染到最底層的 componentreact-test-renderer/shallow
只會渲染該 component 第一層
範例
使用 react-test-renderer
keywords: react-test-renderer
, toMatchSnapshot()
test-renderer @ ReactJS
建立 snapshot testing 的方式如下:
- 第一次執行程式時,Jest 會自動產生 snapshot 並保存起來
- 當 UI 有變更的時候,Snapshot 就會顯示異動的部分
- 若確定這個異動的部分是合理且正確的,則執行
npm run test -- -u
表示現在的 Snapshot 是最新的,要覆蓋原本就的,產生新的 Snapshot。
import { create } from 'react-test-renderer';
import Button from './Button';
test('snapshot of button', () => {
const tree = create(<Button />).toJSON();
expect(tree).toMatchSnapshot();
});
使用 shallow renderer
keywords: createRenderer
shallow renderer @ React
import { createRenderer } from 'react-test-renderer/shallow';
test('renders correctly with some pets', () => {
const r = createRenderer();
r.render(<Results pets={pets} />);
expect(r.getRenderOutput()).toMatchSnapshot();
});
其他問題
搭配 react router dom 或其他 HOC(integration testing)
import { render, screen } from '@testing-library/react';
import TodoFooter from './TodoFooter';
import { BrowserRouter } from 'react-router-dom';
// 在要測試的地方要包 HOC
const MockTodoFooter = ({ numberOfIncompleteTasks }) => (
<BrowserRouter>
<TodoFooter numberOfIncompleteTasks={numberOfIncompleteTasks} />
</BrowserRouter>
);
it('should render TodoFooter successfully', () => {
render(<MockTodoFooter numberOfIncompleteTasks={5} />);
const content = screen.getByText('5 tasks left');
expect(content).toBeInTheDocument();
});
覆蓋掉 react-testing-library 的 render 方法
Custom Render @ testing-library > setup
在專案中有些 Provider 是所有元件都會套用到的,像是 styled-components 中的 <ThemeProvider />
、react-redux 中的 <Provider />
、recoil 中的 <RecoilRoot />
,又或者是自己寫的 React Context 等等,它們通常會放在整個 App 的根元素(例如,index.tsx
或 App.tsx
)。
雖然我們可以在撰寫測試是每次寫 MockComponent 的時候在把需要的 Provider 報在 MockComponent 外,但既然這些是每個 component 都會用到的 Provider,所以我們可以覆蓋掉 react-hook-library 提供的 render
方法,如此在撰寫測試是就不用額外包各個 Provider。像是這樣:
// https://testing-library.com/docs/react-testing-library/setup/#custom-render
import React, { FC, ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { ThemeProvider } from 'my-ui-lib';
import { TranslationProvider } from 'my-i18n-lib';
import defaultStrings from 'i18n/en-x-default';
const AllTheProviders: FC = ({ children }) => {
return (
<ThemeProvider theme="light">
<TranslationProvider messages={defaultStrings}>{children}</TranslationProvider>
</ThemeProvider>
);
};
const customRender = (ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) =>
render(ui, { wrapper: AllTheProviders, ...options });
export * from '@testing-library/react';
export { customRender as render };
如此,在撰寫測試的時候,不要直接載入 react-testing-library
,而是 import test-utils.tsx
這隻檔案。
無法使用 __mocks__
資料夾
透過 create-react-app 建立的 React 專案目前(2020-01-22
)可能會吃不到 __mocks__
資料夾中的設定,解決方法是把 __mocks__
的資料夾搬到 src
裡面。
參考資料:
解決 react-inlinesvg
在專案的根目錄新增 __mocks__
,在裡面建立 react-inlinesvg 的 mock:
// __mocks__/react-inlinesvg.tsx
import { FC } from 'react';
interface Props {
src: string;
cacheRequests: boolean;
}
const ReactInlineSVG: FC<Props> = ({ src, cacheRequests, ...props }) => (
<svg data-src={src} {...props} />
);
export default ReactInlineSVG;
解決 next-i18next 的 useTranslation 方法
建立 globalMock.js
,並且 mock next-i18next
中的 useTranslation
方法:
// internal/mocks/globalMock.js
jest.mock('next-i18next', () => ({
useTranslation: () => ({
t: jest.fn().mockImplementation((key) => key),
i18n: { language: 'en-us' },
}),
}));
接著在 jest.config.js
中,透過 setupFiles
來載入這隻檔案:
// jest.config.js
module.exports = {
setupFiles: ['<rootDir>/internal/mocks/globalMock.js'],
};
TypeError: Property 'toBeInTheDocument' does not exist on type Matchers
Property 'toBeInTheDocument' does not exist on type 'Matchers' @ stack overflow
如果出現類似的錯誤,可以嘗試安裝看能不能解決:
npm i -D @testing-library/jest-dom
參考程式碼
參考資料
- 👍 React Testing Library Tutorial @ Youtube
- How to use React Testing Library Tutorial @ robinwieruch