Tại sao lại nên dùng integration tests với React, Redux và Router

861

Sau vài lần trải nghiệm sử dụng app tệ hại mặc dù trước đó nó đã pass được vài bài test do chính mình đặt ra. Tôi quyết định tìm hiểu về integration test React, kèm theo đó là cả Redux và React Router.

Thật đáng kinh ngạc khi hầu như không hề có một nguồn nào tốt cả. Đa phần họ đều sử dụng integration test một cách khập khễnh hoặc là sai hoàn toàn luôn.

Nói cách khác bạn sẽ phải tự tạo ra một integration test để initializes một React component, thay đổi simulated user interaction và assert để component phát triển theo đúng hướng ta muốn.

Setting up

Với project này, chúng ta sẽ cần có React, Redux, React/Redux Router (không bắt buộc) và Thunk (không bắt buộc) để chạy app, Jest Enzyme cho testing.

Tôi sẽ không nói thêm về phần cái đặt những phần mềm trên vì nó có đầy trên mạng rồi.

Để setup cơ bản cho integration test, tôi sẽ dùng vài tiểu xảo và tạo ra một util function như sau:

import { applyMiddleware, combineReducers, createStore } from 'redux';
import thunk from 'redux-thunk';
import { ReactWrapper } from 'enzyme';
import { routerStateReducer } from 'redux-router';


/* Sets up basic variables to be used by integration tests
 * Params:
 *   reducers: should be an object with all the reducers your page uses
 *   initialRouterState: an optional object to set as the initial state for the router
 * Returns:
 *   an object with the following attributes:
 *     store: the reducer store which contains the main dispatcher and the state
 *     dispatchSpy: a jest spy function to be used on assertions of dispatch action calls
 */
export function setupIntegrationTest(reducers, initialRouterState = {}) {
  // creating the router's reducer
  function routerReducer(state = initialRouterState, action) {
    // override the initial state of the router so it can be used in test.
    return routerStateReducer(state, action);
  }

  // creating a jest mock function to serve as a dispatch spy for asserting dispatch actions if needed
  const dispatchSpy = jest.fn(() => ({})); 
  const reducerSpy = (state, action) => dispatchSpy(action);
  // applying thunk middleware to the the store
  const emptyStore = applyMiddleware(thunk)(createStore);
  const combinedReducers = combineReducers({
    reducerSpy,
    router: routerReducer,
    ...reducers,
  });
  // creating the store
  const store = emptyStore(combinedReducers);

  return { store, dispatchSpy };
}

Testing

Trong thư mục testing, chúng ta sẽ cần import một vài dependencies, reducer và component:

import React from 'react';
import { Provider } from 'react-redux';
import { mount } from 'enzyme';

import myReducer from '../src/reducers';
// make sure to import your connected component, not your react class
import MyComponent from '../src/components/MyComponent'

Sau đó, với beforeEach function, set up variables của integration test nhờ vào util function trên:

describe('integration tests', () => {
  let store;
  let dispatchSpy;
  let router;

  beforeEach(() => {
    router = {
      params: { myParam: 'any-params-you-have' },
    };
    ({ store, dispatchSpy } = setupIntegrationTest({ myReducer }, router));
  });

(Nếu bạn không có xài React Router hoặc Thunk, bạn có thể remove mấy cái references  của nó trong util function và mọi thứ sẽ hoạt động giống như trên)

Giờ thì bạn đã sẵn sàng nạp mấy cái component và test nó. Hãy tưởng tượng có một component với khả năng renders một div và hiển thị tin nhắn text từ reducer. Khi bạn click vào nó, đoạn text đó sẽ biến thành một string mới (new text). Để test quá trình tương tác trên, bạn có thể làm như sau:

it('should change the text on click', () => {
  const sut = mount(
    <Provider store={store}>
      <MyComponent />
    </Provider>
  );
  
  sut.find('div').simulate('click');
  
  expect(sut.find('div').prop('children')).toEqual('new text');
});

 

Đơn giản vậy đây, với chỉ những dòng code trên bạn sẽ test div gọi action producer khi có click tương tác của user, cũng như đưa ra một action cho reducer, như thay đổi data, trigger re-render trên component. Nếu có bất kì lỗi nào xảy ra, bài test sẽ báo hiệu màu đỏ cho bạn biết app của mình đã bị error.

Bạn cũng có thể đào sâu hơn về vấn đề trên với cách thức sau:

// To test if the action producer has dispatched the correct action
expect(dispatchSpy).toBeCalledWith({ type: 'DIV_HAS_BEEN_CLICKED' });

// To test if the actual data on the reducer has changed
expect(store.getState().myReducer.divText).toEqual('new text');

Testing API calls

Bạn sẽ cần tới API để lấy data cho app của mình, và đó là nơi mà bạn cần phải kiểm tra theo dõi để bài test diễn ra một cách chính xác. Trong bài viết này, tôi sẽ dùng Jest bởi sự tiện lợi của nó.

Tôi sẽ giả sử rằng chúng ta đang dùng một http client để call một endpoint để lấy function khi bạn click vào div, rồi return call trên về với reducer để có thể hiển thị trở lại trong div:

// to mock a whole imported dependency, do this
jest.mock('my-http-client');
import http from 'my-http-client';

// ...

it('should change the text on click', () => {
  http.get.mockImplementation({ body: 'the-return-of-my-get-request' });
  const sut = mount(
    <Provider store={store}>
      <MyComponent />
    </Provider>
  );
  
  sut.find('div').simulate('click');
  
  expect(sut.find('div').prop('children')).toEqual('the-return-of-my-get-request');
});

Đôi khi trong quá trình lấy function nhưng lại return một  Promise object. Đó là bởi click function không nhận ra được promise đó và thế là bị đứng. Khi đó, reference của object đã bị mất.

Như vậy, chúng ta cần phải đợi đến khi nào promise đó tự giải quyết được trước khi có thể executing bước tiếp theo. Giải pháp đưa ra là dùng một mánh khóe với util function:

/* Async function that will finish execution after all promises have been finished
 * Usage:
 *   it('...', async () =. {
 *     // mount component
 *     // execute actions
 *     await flushAllPromises();
 *     // execute assertions for async actions
 *   });
 */
export function flushAllPromises() {
  return new Promise(resolve => setImmediate(resolve));
}

Và test của chúng ta sẽ trông như thế này đây:

 

it('should change the text on click', async () => {
  http.get.mockImplementation(() => Promise.resolve({ body: 'the-return-of-my-get-request' }));
  const sut = mount(
    <Provider store={store}>
      <MyComponent />
    </Provider>
  );
  
  sut.find('div').simulate('click');
  
  await flushAllPromises();
  
  expect(sut.find('div').prop('children')).toEqual('the-return-of-my-get-request');
});

Do Jest hiện tại vẫn chưa có cách giải quyết vấn đề trên nên mánh khóe trên tỏ ra khá tiện lợi trong thời điểm hiện tại.

Còn nếu bạn có action producer phức tạp hơn với các promises được gọi bởi do resolve hoặc reject của promise đầu tiên, tôi khuyên bạn nên xài unit test đối với chúng và integration tests cho kết quả cuối cùng.

Vì sao không xài UNIT TEST?!?

Bạn có thể làm quá trình TDD chỉ với integration tests mà kết quả đưa ra vẫn tốt. Mặt khác, integration tests  rất hữu ích đối với việc tìm kiếm broken link giữa các app layer cũng như kiểm tra xem app có hoạt động theo mong muốn hay không.

Hơn nữa, unit test chỉ thích hợp đối với những trường hợp phức tạp, còn đa phần các trường hợp khác đều có thể xử lý với intergration tests. Nhờ đó mà quá trình test sẽ trở nên đơn giản, tiết kiệm thời gian.

Ngoài ra một lợi thế lớn khác là với intergration test, bạn sẽ mounting component “Gốc” để kiểm tra xem các component con có hoạt động đúng không. Và cực kì linh hoạt bởi bạn có thể kiểm tra từng component hoặc kiểm tra cả đám cùng một lúc.

Nguồn: blog.topdev.vn via Medium