跳至主要内容

[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.jsbar.spec.js
  • 在名為 __tests__ 資料夾內的所有 .js.jsx.ts.tsx 都會被執行測試

建議把執行測試的檔案和原本的檔案放在同一資料夾中,如此可以簡化 import 的路徑。舉例來說,把 App.jsApp.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.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-libraryjest-dom。

在 React Testing Library 中提供了許多方法幫助開發者去 Query DOM 的內容,再把這些內容 Query 出來後,我們一樣需要使用 Jest 提供的 API 來比較「期待的」和「實際的」是否有差異。因此,React Testing Library 只是個幫忙測試的工具,並不是實際可以進行直接執行的 test runner。

Integration Test 和 E2E Test 很大的差異在於,Integration Test 通常不會實際去呼叫 server 的 API,而是用模擬回傳資料的方式。因此會需要模擬呼叫 API 的方法(例如,axiosfetch),以及模擬 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 元件進行測試時,通常都會有這樣的流程:

  1. render 出要測試的元件
  2. 找到元件中某元素位置
  3. 對該元素進行操作和互動
  4. 檢視結果是否和預期相符
// 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 方法時會回傳的是陣列

react testing library query methods

圖片來源: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

信息

getByLabelText() 做的事情很類似:找到該 label element 後使用 $0.textContent 後取得的 text,在用這個 text 去比對,所以如果使用 regex,搭配 ^$ 的話,需要特別留意一些,因為如果使用 <label></label> 中還有其他文章,都會被 textContent 一併取出。

getBy

  • screen.debug():可以看到 render 出來的 virtual DOM
  • screen.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 可以選擇:

react testing library-getByRole

如果不清楚該元素的 role 是什麼,可以使用在 Chrome 的開發者工具,按下「Alt + Shift + P」後,搜尋 show accessibility,接著就會顯示所有元素的 role 和 name:

show accessibility

也可以使用 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)

Jest 提供了一個快照測試(snapshot testing),它會自動將元件產生一個文字版的快照,並儲存在硬碟上,每當 UI 改變時,就會收到通知,此時你可以檢視這個變更是不是預期的改變,是的話可以執行 jest -u,否則的話就去修復這個 bug。

在下面的範例中,react-test-rendererreact-test-renderer/shallow 的差別在於:

  • react-test-renderer 會一直渲染到最底層的 component
  • react-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.tsxApp.tsx)。

雖然我們可以在撰寫測試是每次寫 MockComponent 的時候在把需要的 Provider 報在 MockComponent 外,但既然這些是每個 component 都會用到的 Provider,所以我們可以覆蓋掉 react-hook-library 提供的 render 方法,如此在撰寫測試是就不用額外包各個 Provider。像是這樣:

test-utils.tsx
// 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

Could not convert the src to a React element #140 @ Github

在專案的根目錄新增 __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

參考程式碼

參考資料