React with redux-saga Testing

Testing ứng dụng của bạn là một phần quan trọng đối với sự phát triển phần mềm chuyên nghiệp. Có 1 vài thứ bạn nên test. Một vài kỹ thuật được sử dụng để test tôi xin giới thiệu khi test ứng dụng react kết hợp redux, saga là Unit Testing, Snapshot Testing, Shallow rendering.

Note: Framework sử dụng demo

  • Ứng dụng React kết hợp redux-saga: Để cho đơn giản, tôi sử dụng React Boilerplate để demo vì nó đã được setup một ứng dụng đầy đủ với các kỹ thuật đã kể trên.
  • Framework test: JEST kết hợp Sinon

Trước tiên chúng ta liệt kê qua một số thành phần cần test có trong ứng dụng React, redux-saga:

  • Action.
  • Reducer: Nơi tiếp nhận action và xử lý.
  • Saga: middleware.
  • React component.

Các kỹ thuật testing

Unit testing

Unit testing là 1 thực hành việc test với đơn vị nhỏ nhất có thể trong code và các hàm của chúng ta. Chúng ta chạy các test và kiểm tra tự động các hàm của chúng ta làm việc có đúng như chúng ta mong đợi chúng làm. Chúng ta assert (yêu cầu, đòi hỏi) rằng gửi 1 tập đầu vào và hàm của chúng ta trả về các giá trị phù hợp và xử lý các vấn đề.

Boilerplate đã hỗ trợ sẵn Jest test framework để chạy test và tạo xác sự xác nhận. Thư viện làm cho chúng ta viết test đơn giản như các lời nói: bạn mô tả (describe) 1 đơn vị trong code của bạn và mong đợi (expect) nó (it) làm việc gì đó đúng:

1
2
3
4
5
6
7
8
9
describe('add()', () => {
it('adds two numbers', () => {
expect(add(2, 3)).toEqual(5);
});

it("doesn't add the third number", () => {
expect(add(2, 3, 5)).toEqual(add(2, 3));
});
});

Snapshot Testing

Đây là một kỹ thuật rất hữu ích được JEST hỗ trợ để test bất cứ khi nào bạn muốn đảm bảo rằng giao diện người dùng (UI) của bạn không có các thay đổi bất ngờ.

Ý tưởng của kỹ thuật này là với mỗi test case, bạn sẽ renders các component, ghi nhận lại 1 snapshot và so sánh nó với snapshot đã được lưu lại từ trước với cùng test case đó. Test không thành công nếu 2 snapshot không khớp nhau. Tuy nhiên trong trường hợp 2 snapshot không khớp nhau, nếu bạn thấy sự thay đổi đó là đúng như cái bạn dự kiến (không bất ngờ) thì bạn có thể cập nhật snapshot, chạy lại để test case trở thành đúng là cập nhật phiên bản snapshot này cho các lần test sau đó :D

Kỹ thuật này không chỉ được sử dụng để test UI, nó có thể được sử dụng để test saga, lưu các trạng thái của action, chúng ta sẽ xem xét nó chi tiết trong phần Saga Testing.

Shallow rendering

Kỹ thuật này tạm dịch là render “nông”. Khi bạn chỉ muốn viết unit test cho React, kỹ thuật này có thể hữu ích. Kỹ thuật cho phép bạn kết xuất một thành phần sâu ở một cấp độ (“one level deep”) và xác nhận giá trị trả về từ phương thức render của nó trả về mà không phải lo lắng về các hành vi của các thành phần con không được khởi tạo hoặc hiển thị. Điều này có nghĩa, nó không yêu cầu DOM.

Kỹ thuật này có thể được coi là unit test cho các component của React (chỉ quan tâm kết quả trả về hàm render, không quan tâm các hành vi và thẻ con). Tuy nhiên đó cũng chính là điểm yếu của nó

  • Vì không render ra DOM nên nó không thể test được các hành vi với các component (click, change …)
  • Bạn phải thực hiện test thủ công với từng commponent

Kỹ thuật này cá nhân mình thấy ít được sử dụng. Để test các component trong React, kỹ thuật snapshot testing với sự giúp đỡ của 1 vài thư viện có vẻ hữu ích hơn.

Action, reducer testing

Reducer

Với reducer bạn có thể sử dụng unit test hoặc snapshot để test. Unit test được sử dụng nếu state ít thay đổi (để tránh cập nhập code liên tục), nếu không, hãy cân nhắc sử dụng snapshot testing.

Ví dụ khi test init state reducer:

1
2
3
4
5
6
7
8
9
describe('NavBarReducer', () => {
it('returns the initial state', () => {
expect(NavBarReducer(undefined, {})).toEqual({
open: false,
});
});

it('handles the toggleNav action', () => {});
});

Với init state là:

1
2
3
{
open: false,
}

Tuy nhiên phương pháp test này có 1 điểm yếu là khi có 1 ai đó thay đổi init state => bạn phải thay đổi code??
Thay vào đó có thể sử dụng snapshot, khi ai đó thay đổi init state => báo lỗi change snapshot. Từ đó bạn có thể so sánh để cập nhật snapshot khi test mà không phải cập nhật code

1
2
3
4
5
6
7
describe('NavBarReducer', () => {
it('returns the initial state', () => {
expect(NavBarReducer(undefined, {})).toMatchSnapshot();
});

it('handles the toggleNav action', () => {});
});

Actions

Một Redux action là 1 các hàm thuần túy vì vậy không khó để test nó, sử dụng unit test, truyền input và expect output thôi :D

1
2
3
4
5
it('should return the correct constant', () => {
expect(toggleNav()).toEqual({
type: TOGGLE_NAV,
});
});

Saga Testing

Có 2 cách chính để test Sagas: test hàm generator từng bước một hoặc chạy full saga và kiểm tra các effects được tạo ra từ saga

Testing the Saga Generator Function

Để test từng bước một các hàm generator bạn có thể sử dụng hàm next().value để lấy ra các giá trị trả về từ yield, sau đó kết hợp kỹ thuật snapshot hoặc các kết quả trả về đó để so sánh với kết quả mong đợi.
Chúng ta cùng xem xét ví dụ về saga sau:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* Github repos request/response handler
*/
export function* getRepos() {
// Select username from store
const username = yield select(makeSelectUsername());
const requestURL = `https://api.github.com/users/${username}/repos?type=all&sort=updated`;

try {
// Call our request helper (see 'utils/request')
const repos = yield call(request, requestURL);
yield put(reposLoaded(repos, username));
} catch (err) {
yield put(repoLoadingError(err));
}
}

Chúng ta sẽ dùng next().value để lấy ra các giá trị từ yield và so sánh với kết quả ta mong muốn:

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
/**
* Tests for HomePage sagas
*/

import { put, takeLatest } from 'redux-saga/effects';

import { LOAD_REPOS } from 'containers/App/constants';
import { reposLoaded, repoLoadingError } from 'containers/App/actions';

import githubData, { getRepos } from '../saga';

const username = 'mxstbr';

/* eslint-disable redux-saga/yield-effects */
describe('getRepos Saga', () => {
let getReposGenerator;

// We have to test twice, once for a successful load and once for an unsuccessful one
// so we do all the stuff that happens beforehand automatically in the beforeEach
beforeEach(() => {
getReposGenerator = getRepos();

const selectDescriptor = getReposGenerator.next().value;
expect(selectDescriptor).toMatchSnapshot();

const callDescriptor = getReposGenerator.next(username).value;
expect(callDescriptor).toMatchSnapshot();
});

it('should dispatch the reposLoaded action if it requests the data successfully', () => {
const response = [
{
name: 'First repo',
},
{
name: 'Second repo',
},
];
const putDescriptor = getReposGenerator.next(response).value;
expect(putDescriptor).toEqual(put(reposLoaded(response, username)));
});

it('should call the repoLoadingError action if the response errors', () => {
const response = new Error('Some error');
const putDescriptor = getReposGenerator.throw(response).value;
expect(putDescriptor).toEqual(put(repoLoadingError(response)));
});
});

Branching Saga

Thỉnh thoảng, saga của bạn lại có một vài kết quả khác nhau. Khi đó để test các nhánh khác nhau, bạn cần truyền giá trị trả về mong đợi từ yield trước đó vào hàm next để thực hiện vào các branch.

Chi tiết cách test này tham khảo tại đây.

Testing the full Saga

Mặc dù rất hữu ích khi test từng bước một với saga, nhưng trên thực tế nó có thể làm cho test của saga đơn giản và ngắn. Thay vào đó một cách thích hợp hơn để test cho cả saga và mong đợi các effects đã xảy ra.

Chúng ta cùng quay lại với ví dụ trên. Chúng ta sẽ test cả hành động call API lấy repos của 1 người dùng trong trường hợp thành công và mong đợi hành động put(reposLoaded(repos, username)) được xảy ra.
Trước hết chúng ta cần mock function request. Việc làm này để mô phỏng giá trị trả về thành công API thay vì ta gọi API thực tế, một kỹ thuật phổ biến được sử dụng trong test.

1
2
3
4
5
6
7
8
9
const dispatched = [];

const saga = runSaga(
{
dispatch: action => dispatched.push(action),
getState: () => ({ state: 'test' }),
},
getRepos,
);

Và rồi test cả saga thôi:

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
describe('getRepos fullSaga', () => {
it('should put expected event', async () => {
const dispatched = [];
const response = [
{
name: 'First repo',
},
{
name: 'Second repo',
},
];
sinon.stub(request, 'default').callsFake(function fakeFn() {
return response;
});
await runSaga(
{
dispatch: action => dispatched.push(action),
getState: () => ({ state: 'test' }),
},
getRepos,
).toPromise();

expect(dispatched).toEqual([reposLoaded(response, '')]);
});
});

Component Testing

Unit Tesing với action và reducer là ngon, nhưng bạn có thể làm thậm chí là nhiều hơn để chắc chắn rằng không gì bị break trong ứng dụng của bạn. Vì React là tầng view của ứng dụng, hãy xem xét các test Components

  • Shallow rendering
  • react-testing-library
    • Snapshot testing
    • Behavior testing

Shallow rendering

Như đã đề cập trong lý thuyết, nó là kỹ thuật Unit Test với các component của React mà không cần dùng DOM.

Ưu điểm

Hãy xem xét nó có nghĩa gì với thẻ đơn giản <Button>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Button.js
import React from 'react';
import CheckmarkIcon from './CheckmarkIcon';

function Button(props) {
return (
<button className="btn" onClick={props.onClick}>
<CheckmarkIcon />
{React.Children.only(props.children)}
</button>
);
}

export default Button;

=> Note: This is a stateless (“dumb”) component

Điều đó có nghĩa 1 component khác sử dụng nó sẽ có dạng:

1
2
3
4
5
6
7
// HomePage.js

import Button from './Button';

function HomePage() {
return <Button onClick={this.doSomething}>Click me!</Button>;
}

=> Note: This is a stateful (“smart”) component!

Khi render thông thường với hàm chuẩn ReactDOM.render, đây sẽ là HTML output (Nhận xét được thêm song song để so sánh các cấu trúc trong HTML từ nguồn JSX)

1
2
3
4
<button>                                            <!-- <Button>             -->
<i class="fa fa-checkmark"></i> <!-- <CheckmarkIcon /> -->
Click Me! <!-- { props.children } -->
</button> <!-- </Button> -->

Trái lại, khi render với shallow render, chúng ta sẽ nhận chuỗi string với đoạn HTML như sau:

1
2
3
4
<button>              <!-- <Button>             -->
<CheckmarkIcon /> <!-- NOT RENDERED! -->
Click Me! <!-- { props.children } -->
</button> <!-- </Button> -->

Nếu chúng ta test Button với render thông thường, khi có 1 vấn đề với CheckmarkIcon sau đó test với Button sẽ fail. Điều này rất khó khăn để tìm nguyên nhân (không biết CheckmarkIcon lỗi hay Button lỗi). Sử dụng shallow renderder, chúng ta cô lập được các sự cố do chúng ta không render 1 vài thẻ nào khác ngoài thẻ chúng ta đang test. Đây có thể coi là ưu điểm duy nhất của kỹ thuật này so với các kỹ thuật test component khác.

Nhược điểm

Vấn đề của shallow renderer là tất cả các assertions phải làm thủ công, và bạn có thể không bất cứ điều gì cần DOM.

react-testing-library

Để viết được nhiều test có thể maintain cái mà sẽ giống hơn cách mà chúng ta sử dụng component ở thực tế, chúng có có cái gọi là react-testing-library. Thư viện này renders component của chúng ta với DOM thực tế và cung cấp các tiện ích để truy vấn nó.

Hãy xem xét component <Button />. Đầu tiên, hãy check xem nó có được renders với con của nó, nếu có, và thứ 2 là nó có xử lý sự kiện click?

1
2
3
4
5
6
7
8
9
import React from 'react';
import { render, fireEvent } from 'react-testing-library';
import Button from '../Button';

describe('<Button />', () => {
it('renders and matches the snapshot', () => {});

it('handles clicks', () => {});
});

Snapshot testing

Hãy bắt đầu bằng cách chắc chắn rằng nó render ra các component và không thay đổi nào được xảy ra kể từ lần cuối, lần mà nó được test thành công.

Chúng ta sẽ làm vậy bằng cách render nó và tạo 1 snapshot cái mà có thể so sánh với snapshot đã được commit trước đó. Nếu không có snapshot nào tồn tại, 1 cái mới sẽ được tạo.

Do đó, đầu tiên chúng ta sẽ call render. Nó sẽ render component <Button /> vào container, và mặc định là 1 <div>được thêm vào document.body. Chúng ta sau đó sẽ tạo ra 1 snapshot và expect snapshot đó giống với snapshot đã tồn tại (nhận vào từ lần chạy trước đó của test này) và commit nó lên repo.

1
2
3
4
5
6
it('renders and matches the snapshot', () => {
const text = 'Click me!';
const { container } = render(<Button>{text}</Button>);

expect(container.firstChild).toMatchSnapshot();
});

render trả về 1 đối tượng với thuộc tính container cái sẽ chứa được render từ <Button /> component
Vì đây là render với DOM thông thường, chúng ta có thể truy vấn vào component bằng cách sử dụng container.firseChild. Cái này sẽ là snapshot, được chứa trong thư mục __snapshots__ trong folder tests. Chắc chắn rằng bạn đã commit snapshots này lên repo của bạn.

Tuyệt vời. Bây giờ nếu bạn thực hiện 1 vài thay đổi với component <Button />, test của bạn sẽ trả về fail và bạn sẽ nhận được thông báo cái gì đã thay đổi. Nếu thay đổi đó đúng với cái mà bạn muốn, hãy update snapshots và commit nó vào repo thôi :D.

Test các hành vi

Test các sự kiện như click vào button.

Chúng ta sẽ sử dụng mock function để làm việc này. Một mock function là 1 hàm sẽ theo dõi: nếu, sự thường xuyên và các đối số nào sẽ được gọi (if, how often and with what arguments). Chúng ta truyền hàm này như là hàm xử lý sự kiện onClick, mô phỏng 1 lần nhấp và cuối cùng, kiểm tra hàm mock đã được gọi:

1
2
3
4
5
6
7
8
it('handles clicks', () => {
const onClickMock = jest.fn();
const text = 'Click me!';
const { getByText } = render(<Button onClick={onClickMock}>{text}</Button>);

fireEvent.click(getByText(text));
expect(onClickSpy).toHaveBeenCalledTimes(1);
});

Và cuối cùng, test đầy đủ của chúng ta sẽ có dạng:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React from 'react';
import { render, fireEvent } from 'react-testing-library';
import Button from '../Button';

describe('<Button />', () => {
it('renders and matches the snapshot', () => {
const text = 'Click me!';
const { container } = render(<Button>{text}</Button>);

expect(container.firstChild).toMatchSnapshot();
});

it('handles clicks', () => {
const onClickMock = jest.fn();
const text = 'Click me!';
const { getByText } = render(<Button onClick={onClickMock}>{text}</Button>);

fireEvent.click(getByText(text));
expect(onClickSpy).toHaveBeenCalledTimes(1);
});
});

Tổng kết

  • Khi test action và reducer: sử dụng Unit Testing
  • Khi test component: sử dụng snapshot
  • Khi test saga: sử dụng snapshot, có thể test full saga hoặc step-by-step

Tài liệu tham khảo

Author

Ming

Posted on

2019-09-02

Updated on

2021-04-10

Licensed under

Comments