PJCHENder 未整理筆記

[React] 撰寫測試 Testing

2019-12-27

[React] 撰寫測試 Testing - react testing library

本篇文章內容整理自:

React 元件的測試主要可以分成兩個部分,廣泛來說分別是:

  • 在較簡化的測試環境下轉譯組建(Rendering component trees),並宣告應該有什麼樣的結果。
  • End-to-end tests:在實際的瀏覽器環境下執行完整的應用程式,在這種測試中你會測試瀏覽器如何轉譯整個應用程式、從 API 實際拉取資料,使用 sessions 和 cookies、在不同連結中導覽,同時你不只想要確認 DOM 的狀態,還要檢查當前端修改資料後,後端的資料是否有一併更新。React 官方建議可以使用 Cypresspuppeteer

React 中常見的測試情境

設定(Setup/Teardown)

在每一個測試,我們常會把 React tree 轉譯到某個 DOM 元素上,這個 DOM 元素會依附在 document 上,如此在能接收到 DOM 事件,但當測試結束後,我們要執行清理(clean up)的動作,並將 React tree 從 document 上移除(unmount)。

一個常見的方式是搭配使用 beforeEachafterEach 的區塊,如此總是會在這個區塊中執行和隔離效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { unmountComponentAtNode } from 'react-dom';

let container = null;
beforeEach(() => {
// setup a DOM element as a render target
container = document.createElement('div');
document.body.appendChild(container);
});

afterEach(() => {
// cleanup on exiting
unmountComponentAtNode(container);
container.remove();
container = null;
});

你可能會使用不同的方式(pattern),但要留意即使測試的結果失敗了,仍要記得執行清理的動作,否則該測試可能會洩漏(leaky),以至於不同的測試之間可能會相互影響。

act()

當在撰寫 UI 測試時,像是轉譯、使用者的事件、資料拉取等等,都可以被視為與 UI 介面互動的單元(units)。React 提供了一各個 helper 稱作 act(),可以用來確保在你定義任何 assertions 前,所有和這些「單元(units)」有關的更新都已經被處理和套用到 DOM 上:

1
2
3
4
act(() => {
// render components
});
// make assertions

這可以幫助你的測試在執行起來更貼近使用者在使用應用程式的情況。如果你覺得每次都要從 act() 寫起很麻煩,在 React Testing Library 套件中提供了許多方法已經把 act() 包在內可供你使用。

轉譯(Rendering)

一般來說,你會想要測試元件是否有根據所給的 props 正確轉譯,例如 Hello 元件如下:

1
2
3
4
5
6
7
8
9
10
11
// hello.js

import React from 'react';

export default function Hello(props) {
if (props.name) {
return <h1>Hello, {props.name}!</h1>;
} else {
return <span>Hey, stranger</span>;
}
}

我們可以撰寫下面這樣的測試:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// hello.test.js

import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { act } from 'react-dom/test-utils';

import Hello from './hello';

let container = null;
beforeEach(() => {
// setup a DOM element as a render target...
});

afterEach(() => {
// cleanup on exiting...
});

it('renders with or without a name', () => {
act(() => {
render(<Hello />, container);
});
expect(container.textContent).toBe('Hey, stranger');

act(() => {
render(<Hello name="Jenny" />, container);
});
expect(container.textContent).toBe('Hello, Jenny!');

act(() => {
render(<Hello name="Margaret" />, container);
});
expect(container.textContent).toBe('Hello, Margaret!');
});

拉取資料(data fetching)

測試中,可以先使用假資料而非實際透過 API 取得的資料來進行測試,假設 User 元件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// user.js

import React, { useState, useEffect } from 'react';

export default function User(props) {
const [user, setUser] = useState(null);

async function fetchUserData(id) {
const response = await fetch('/' + id);
setUser(await response.json());
}

useEffect(() => {
fetchUserData(props.id);
}, [props.id]);

if (!user) {
return 'loading...';
}

return (
<details>
<summary>{user.name}</summary>
<strong>{user.age}</strong> years old
<br />
lives in {user.address}
</details>
);
}

我們可以撰寫如下的測試:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// user.test.js

import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { act } from 'react-dom/test-utils';
import User from './user';

let container = null;
beforeEach(() => {
// setup a DOM element as a render target...
});

afterEach(() => {
// cleanup on exiting...
});

it('renders user data', async () => {
const fakeUser = {
name: 'Joni Baez',
age: '32',
address: '123, Charming Avenue',
};

jest.spyOn(global, 'fetch').mockImplementation(() =>
Promise.resolve({
json: () => Promise.resolve(fakeUser),
})
);

// Use the asynchronous version of act to apply resolved promises
await act(async () => {
render(<User id="123" />, container);
});

expect(container.querySelector('summary').textContent).toBe(fakeUser.name);
expect(container.querySelector('strong').textContent).toBe(fakeUser.age);
expect(container.textContent).toContain(fakeUser.address);

// remove the mock to ensure tests are completely isolated
global.fetch.mockRestore();
});

Mocking Modules

有些模組在測試環境下可能沒辦法正常運作,或者測試對它來說並不是必要的,這時候是有假的替代品來模擬這些模組會讓測試變得較容易。

舉例來說,在 Contact 這個元件中內嵌了第三方的 GoogleMap 元件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// map.js
import React from 'react';

import { LoadScript, GoogleMap } from 'react-google-maps';
export default function Map(props) {
return (
<LoadScript id="script-loader" googleMapsApiKey="YOUR_API_KEY">
<GoogleMap id="example-map" center={props.center} />
</LoadScript>
);
}

// contact.js
import React from 'react';
import Map from './map';

function Contact(props) {
return (
<div>
<address>
Contact {props.name} via{' '}
<a data-testid="email" href={'mailto:' + props.email}>
email
</a>
or on their <a data-testid="site" href={props.site}>
website
</a>.
</address>
<Map center={props.center} />
</div>
);
}

如果我們不想在測試中載入這個元件,我們可以使用一個假的元件來模擬它,並執行測試:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// contact.test.js

import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { act } from 'react-dom/test-utils';

import Contact from './contact';
import MockedMap from './map';

jest.mock('./map', () => {
return function DummyMap(props) {
return (
<div data-testid="map">
{props.center.lat}:{props.center.long}
</div>
);
};
});

let container = null;
beforeEach(() => {
// setup a DOM element as a render target...
});

afterEach(() => {
// cleanup on exiting...
});

it('should render contact information', () => {
const center = { lat: 0, long: 0 };
act(() => {
render(
<Contact
name="Joni Baez"
email="test@example.com"
site="http://test.com"
center={center}
/>,
container
);
});

expect(
container.querySelector("[data-testid='email']").getAttribute('href')
).toEqual('mailto:test@example.com');

expect(
container.querySelector('[data-testid="site"]').getAttribute('href')
).toEqual('http://test.com');

expect(container.querySelector('[data-testid="map"]').textContent).toEqual(
'0:0'
);
});

事件(Events)

建議可以對實際的 DOM 元素派送真實的 DOM 事件,然後斷言該結果為何。以 Toggle 元件為例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// toggle.js

import React, { useState } from 'react';

export default function Toggle(props) {
const [state, setState] = useState(false);
return (
<button
onClick={() => {
setState((previousState) => !previousState);
props.onChange(!state);
}}
data-testid="toggle"
>
{state === true ? 'Turn off' : 'Turn on'}
</button>
);
}

測試的部分可以這樣寫:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// toggle.test.js

import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { act } from 'react-dom/test-utils';

import Toggle from './toggle';

let container = null;
beforeEach(() => {
// setup a DOM element as a render target
container = document.createElement('div');
// container *must* be attached to document so events work correctly.
document.body.appendChild(container);
});

afterEach(() => {
// cleanup on exiting...
});

it('changes value when clicked', () => {
const onChange = jest.fn();
act(() => {
render(<Toggle onChange={onChange} />, container);
});

// get ahold of the button element, and trigger some clicks on it
const button = document.querySelector('[data-testid=toggle]');
expect(button.innerHTML).toBe('Turn off');

act(() => {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});

expect(onChange).toHaveBeenCalledTimes(1);
expect(button.innerHTML).toBe('Turn on');

act(() => {
for (let i = 0; i < 5; i++) {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
});

expect(onChange).toHaveBeenCalledTimes(6);
expect(button.innerHTML).toBe('Turn on');
});

在 React 的 Testing Library 中提供了更簡潔的 helpers 來觸發事件。

⚠️ 由於 React 會自動委派(delegates)事件到 document 上,因此需要在每一個事件都帶入 { bubbles: true } 才能觸發到 React 的 listener。

計時器(Timers)

有些情況下可能會使用到 setTimeout 這類和時間有關的函式。在下面 Card 元件的例子中如果沒有在 5 秒鐘選擇的話會觸發 time out:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// card.js

import React, { useEffect } from 'react';

export default function Card(props) {
useEffect(() => {
const timeoutID = setTimeout(() => {
props.onSelect(null);
}, 5000);
return () => {
clearTimeout(timeoutID);
};
}, [props.onSelect]);

return [1, 2, 3, 4].map((choice) => (
<button
key={choice}
data-testid={choice}
onClick={() => props.onSelect(choice)}
>
{choice}
</button>
));
}

在這種情況下,可以利用 Jest’s timer mocks 來測試不同的情況:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// card.test.js

import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { act } from 'react-dom/test-utils';

jest.useFakeTimers();

let container = null;
beforeEach(() => {
// setup a DOM element as a render target...
});

afterEach(() => {
// cleanup on exiting...
});

it('should select null after timing out', () => {
const onSelect = jest.fn();
act(() => {
render(<Card onSelect={onSelect} />, container);
});

// move ahead in time by 100ms
act(() => {
jest.advanceTimersByTime(100);
});
expect(onSelect).not.toHaveBeenCalled();

// and then move ahead by 5 seconds
act(() => {
jest.advanceTimersByTime(5000);
});
expect(onSelect).toHaveBeenCalledWith(null);
});

it('should cleanup on being removed', () => {
const onSelect = jest.fn();
act(() => {
render(<Card onSelect={onSelect} />, container);
});

act(() => {
jest.advanceTimersByTime(100);
});
expect(onSelect).not.toHaveBeenCalled();

// unmount the app
act(() => {
render(null, container);
});

act(() => {
jest.advanceTimersByTime(5000);
});
expect(onSelect).not.toHaveBeenCalled();
});

it('should accept selections', () => {
const onSelect = jest.fn();
act(() => {
render(<Card onSelect={onSelect} />, container);
});

act(() => {
container
.querySelector("[data-testid='2']")
.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});

expect(onSelect).toHaveBeenCalledWith(2);
});

透過 jest.useFakeTimers() 可以不必真的在測試中等待 5 秒才執行,也不必為了測試去改元件中的程式碼。

其他測試

  • 快照測試(Snapshot Testing)
  • 多個轉譯器(Multiple Renderers)

React Testing Library

Testing Library @ Official Website

React Hooks Testing Library

react-hooks-testing-library @ testing/library

React Hooks Testing Library 主要提供三個方法:renderHook, actcleanup

renderHook

  • 透過 result.current 可以取得最新的資料狀態
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const RenderHookResult = renderHook(callback, RenderHookOptions);

// RenderHookOptions
{
initialProps,
wrapper,
}

// RenderHookResult Object
{
result: { current: [Getter], error: [Getter] },
rerender: [Function: rerender],
unmount: [Function: unmountHook], // 用來觸發 useEffect 的 cleanup 函式
wait: [Function: wait],
waitForNextUpdate: [Function: waitForNextUpdate],
waitForValueToChange: [Function: waitForValueToChange]
}

Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import { renderHook, act } from '@testing-library/react-hooks';
import useOpenTokReducer from './use-opentok-reducer';

test('addConnection and removeConnection', () => {
const { result } = renderHook(() => useOpenTokReducer());

// 透過 result.current 取得初始的資料狀態
const [initialState, actions] = result.current;
const { addConnection, removeConnection } = actions;
const initialConnections = initialState.connections;

// 檢測起始狀態
expect(initialConnections).toEqual([]);

// 執行某個方法
act(() => {
addConnection({
id: mockData.id,
connectionId: mockData.id,
});
});

// 透過 result.current 取得最新的資料狀態
const [stateAfterAddConnection] = result.current;

// 檢測變更後的狀態
expect(stateAfterAddConnection.connections.length).toBe(
initialConnections.length + 1
);

// 執行某個方法
act(() => {
removeConnection({
id: mockData.id,
connectionId: mockData.id,
});
});

// 透過 result.current 取得最新的資料狀態
const [stateAfterRemoveConnection] = result.current;

// 檢測變更後的狀態
expect(stateAfterRemoveConnection.connections.length).toBe(
initialConnections.length
);
});

針對透過陣列解構賦值的 Hook

針對會回傳陣列的 Hooks,可以在 renderHook 的 callback 中回傳解構後的內容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
describe('useOpenClose', () => {
// 在 renderHook 的 callback 中透過解構賦值來重新定義想要的結構
const { result } = renderHook(() => {
const [isOpen, actions] = useOpenClose();
return { isOpen, ...actions };
});

test('Should have initial value of false', () => {
console.log(result.current.isOpen);
});

test('Should update value to true', () => {
act(() => result.current.open());
console.log(result.current.isOpen);
});
});

act() doesn’t seem to be updating state @ github issue

掃描二維條碼,分享此文章