/Unit Testing, React Testing Library, React, JavaScript, Jest

Jest | 再一次測試你的 Component-feat.react-testing-library 基本用法

前言

Hi !大家好,雖然之前有使用 Enzyme 講解如何搭配 JestReact 的Component 做測試,但是幾個禮拜前偶然在某個討論串中看到有大神推薦另一套測試 Component 的套件 react-testing-library ,功能和 Enzyme 相同,兩者都是在測試時 Render Component 的 DOM 下斷言測試,如果是剛接觸 Enzyme 的朋友,不妨可以參考看看兩者的不同,來選擇愛用套件 😃 。


react-testing-library

SUT (測試目標)

在開始測試之前,仍然需要一個小助手,這裡請出之前常露面的 Counter 來擔任 SUT:

import React, { useState } from 'react';

const Counter = () => {
  const [count, changeCount] = useState(0);

  return (
    <div>
      <span data-testid="display_count">{`點了${count}`}</span>
      <br />
      <button
        className="add_button"
        type="button"
        onClick={() => { changeCount(count + 1); }}
      >
        點我加 1
      </button>
      <button
        className="add_button"
        type="button"
        onClick={() => { changeCount(count + 2); }}
      >
        點我加 2
      </button>
    </div>
  );
};

export default Counter;

安裝 react-testing-library

因為只有在開發時的 Test 上用得到套件,因此安裝在 devDependencies 裡:

npm install --save-dev react-testing-library
6 / 16 更新:要注意哦! react-testing-library 似乎在版本 8 的時候將套件換成 @testing-library/react 了,目前筆者還不曉得差異在哪裡,使用來也沒有感到差別,所以如果文章中有問題再麻煩留言告知,感激不盡!
//新版本:
npm install --save-dev @testing-library/react

常用 API

撰寫測試前,先簡單說明幾個常用的 API :

render

react-testing-library 的 render 類似於 Enzyme 中的 Mount ,意思是它會將所有的子組件都 render 出來成為 DOM 節點。

getByTestId 、 getByText

render 後會回傳的 Method ,兩個都是用來搜尋 DOM , getByTestId 是以 DOM 中的 data-testid 值取要斷言的 DOM , getByText 則是以該 DOM 內呈現的內容,獲取到 DOM 後便能以 textContent 再取得內容。

container

container 也是 render 所回傳的,等於取得整包 DOM 物件,甚至是能夠直接對它使用 query​Selector 來搜尋節點,通常我會在搞不清楚到底 Render 了什麼的時候,用 innerHTML 來偷看 😆。

fireEvent

這個 Method 可以觸發 DOM 的事件,例如 onClickonChange 等等。

開始測試

其實只要了解上述幾個簡單的 API ,就能夠輕鬆對 Component 的節點做測試,下方先 renderCounter ,並對 span 的內容做斷言,確認是否 render 正確:

import React from 'react';
import { render, fireEvent, cleanup } from 'react-testing-library';
import Counter from '../src/component/Countera/Counter';

describe('Test <Counter />', () => {
  // 每次測試後將 render 的 DOM 清空
  afterEach(cleanup);
  test('測試是否正常 render ', () => {
    // render Component
    const { getByTestId, getByText, container, } = render(<Counter />);

    // 下方三個方法都可以找到顯示計數的 <span />
    expect(getByTestId('display_count').textContent).toBe('點了0下');
    expect(getByText('點了0下').textContent).toBe('點了0下');
    expect(container.querySelector('span').innerHTML).toBe('點了0下');
  });
});

上方用了三種方式去取得 span 來確認 Component 顯示的是否正確, getByTestId 就是直接抓取相同 data-testid 的 DOM,然後如果不幸有兩個 DOM 同時用了一樣的 data-testid 那測試就會發生錯誤:

顯示找到複數的錯誤

但錯誤的提示還滿不錯的,如果真的必須要取同樣的名稱,也可以用 getAllByTestId ,來獲得一個陣列,當然它也是由 render 回傳的 Method 之一。

第二種方式是以 DOM 的內容來獲取,因為一開始 CounterStatecount 值是 0 ,所以可以知道 span 的內容會是 點了0下 ,這個 Mtehod 可以用在內容不會改變的地方,像是登入按鈕的字就永遠是登入,儲存就永遠是儲存,不會有變化,這種情況就能用 getByText

第三種的 container 就把它當成 JavaScript 取得的物件就好,但通常不會使用它來查找 DOM ,但為什麼?不是很方便嗎?這個是有原因的,文章的最後會整理一些結論。

接著,要來確認的是按鈕的事件,點擊時會不會讓 count 值加上一或二,這時候就能用到剛剛提及的 getByText 因為按鈕的文字是不會改變的,找到按鈕後就能使用 fireEvent 觸發點擊:

test('測試點擊功能是否正常', () => {
  // render 畫面
  const { getByText, getByTestId, } = render(<Counter />)

  // 首先找到 +1 button
  let addButton = getByText('點我加 1');
  fireEvent.click(addButton);
  expect(getByTestId('display_count').textContent).toBe('點了1下')

  // 接著找到 +2 button
  addButton = getByText('點我加 2');
  fireEvent.click(addButton);
  expect(getByTestId('display_count').textContent).toBe('點了3下');
});

如果是 change 也是一樣的方式,只需將改變的值放在 fireEvent 的第二個參數,要注意的是改變的值仍然要模擬觸發事件本身的 event

fireEvent.change(input, { target: { value: &apos;2&apos;, }, });

測試結果如下:


感覺上寫起來是不是直觀許多了?而且在這之前一直故意不去提,不曉得大家有沒有發現,上述的 Counter 是透過 Hooks 的 useState 管理 State ,也就是說 react-testing-library 百分之百完美相容 Hooks ,測試起來絕對不會有問題!

顯然這是個令人開心的消息,但 Redux 呢?會不會相對變得複雜?

你的考量,我看得見!

下方就持續講解該如何在 Redux 專案中進行測試!

Redux

這裡先將小助手 Counter 申裝上 Redux :

import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { addCounter } from '../../actions/Counter';

export const Counter = (props) => {
  const { count, addCount, } = props;
  return (
    <div>
      <span data-testid="display_count">{`點了${count}`}</span>
      <br />
      <button className="add_button" type="button" onClick={() => { addCount(1); }}>點我加 1</button>
      <button className="add_button" type="button" onClick={() => { addCount(2); }}>點我加 2</button>
    </div>
  );
};

Counter.propTypes = {
  count: PropTypes.number,
  addCount: PropTypes.func,
};

Counter.defaultProps = {
  count: 0,
  addCount: () => { console.log('error'); },
};

const mapStateToProps = state => ({
  count: state.count,
});

const mapDispatchToProps = dispatch => ({
  addCount: addQuantity => dispatch(addCounter(addQuantity)),
});

export default connect(mapStateToProps, mapDispatchToProps)(Counter);

也少不了 Redux 的標配 actionsreducerstore

export const ADD_COUNTER = 'ADD_COUNTER';

export const addCounter = addQuantity => ({
  type: ADD_COUNTER,
  payload: { addQuantity, },
});

開始測試

欸等等?不需要再裝些什麼其他 redux-mock-store 嗎?

完全不用

貫徹 Mount 的精神,就直接用正式的 Reducer 正式的 store 來測試,把覆蓋率蓋好蓋滿,以下會示範三種在 Redux 中玩轉測試的方法。

直接使用 Reducer 創建 Store

import React from 'react';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import { render, fireEvent, cleanup } from 'react-testing-library';
import Counter from '../src/component/CounterWithRedux/Counter';
import reducer from '../src/reducer/Counter';

describe('test <Counter />', () => {
  afterEach(cleanup);

  test('直接用目前的 reducer render', () => {
    // 用 import 的 reducer createStore
    const store = createStore(reducer);

    // 做 render
    const { getByText, getByTestId, } = render(
      <Provider store={store}>
        <Counter />
      </Provider>
    );

    // 印出 store 保管的 state 狀態
    console.log(store.getState());

    fireEvent.click(getByText('點我加 1'));
    expect(getByTestId('display_count').textContent).toBe('點了1下');

    // 印出 store 保管的 state 狀態
    console.log(store.getState());
  });
});

render 的時候就直接帶入 Store 了,而且例子中有特別印了關於 Store 的兩段 console.log ,可以清楚看見 Store 會隨著測試改變 State 的值:

Store 的值會隨著測試的過程改變

如此一來也能更方便的知道,觸發某個 dispatch 後, Store 內的 State 變化是不是在預料之中。

指定 Reducer 的預設值

除了用原有的 Reducer 外,也能另外指定 State 取代 Reducer 自身的初始 State :

test('預設 reducer 的初始值,從 2 開始', () => {
  // 另外設定初始 State
  const initialState = {
    count: 2,
  };

  // 將 initialState 與 Reducer 一同 createStore
  const store = createStore(reducer, initialState);

  const { getByText, getByTestId, } = render(
    <Provider store={store}>
      <Counter />
    </Provider>
  );

  // +1
  fireEvent.click(getByText('點我加 1'));
  expect(getByTestId('display_count').textContent).toBe('點了3下');
});

自訂 renderWithRedux

這個 render 方式不是 react-testing-library 原有的 Method ,而是官方用了一些小技巧另外寫的,它長這樣子:

const renderWithRedux = (ui, { initialState, store = createStore(reducer, initialState), } = {}) => ({
  ...render(<Provider store={store}>{ui}</Provider>),
  store,
});

看起來有點複雜,但其實內部的原理就是將上方例子 render 的步驟簡化成 Method ,回傳的結果也和 render 後的 Component 一樣,只是會多傳一個 Store ,實際用起來如下:

test('直接預設 store ,保管的 state 從 -3 開始', () => {
   const store = createStore(reducer, {
     count: -3,
   });
   
   // 使用 renderWithRedux 回傳 render 後的結果
   const { getByText, getByTestId } = renderWithRedux(<Counter />, { store, });
   
   // +2
   fireEvent.click(getByText('點我加 2'));
   expect(getByTestId('display_count').textContent).toBe('點了-1下');
 });

筆者也建議可以直接使用 renderWithRedux ,讓測試的畫面看起來比較乾淨俐落,不會定義一堆重複的東西,上方關於 Redux 的例子也都會改成 renderWithRedux 重新寫在筆者的 GitHub 上,可以再參考看看。

使用心得

react-testing-library 的基本方法大家都應該了解了,最後就來談談使用的心得。

一開始最困惑的點是 getByTestIdgetByText ,根本就不曉得到底為什麼要這樣子做,因此大量了使用 container 搭配 querySelector 抓取想斷言的 DOM ,初期用得很開心,但是最後突然發現,如果節點的位置發生改變,或多了另一個 DOM ,都有可能會讓 Test Case 錯誤,但其實不是錯在邏輯,而是因為原本的 querySelector 已經取不到更改前的 DOM 。

這裡 react-testing-library 的開發者 Kent C. Dodds ,也有在他的 Blog 寫了一篇文章提出對 UI 面對測試時的看法

文章裡有個最簡單的例子,當假設 Component 中有一個按鈕:

<button className="button_style" type="button">點我</button>

那 Test Case 會這樣子得到它:

container.querySelector('button[class="button_style"]')

看起來一切正常,但是當對 Component 做了異動:

<div>
  <button className="button_style" type="button">想不到吧</button>
  <button className="button_style" type="button">點我</button>
</div>

原本 Test Case 中的 querySelector 就取成第一個新增的按鈕,而不是原有的 點我 按鈕。

再來,若是有天 button 們都不再需要依賴 button_style 這個樣式,那是否應該要為了 Test Case 而將這沒有任何用的 ClassName 屬性留下?答案應該很明顯。

因此,提升 UI 在測試時的適應力非常重要!

如果考量到 data-testid 被 build 後會被看見,也可以透過 babel-plugin-react-remove-propertiesdata-testid 移除。


本文用了一些例子講解 react-testing-library ,因為筆者大約是今年三月多才開始玩測試,所以 Enzyme 及 react-testing-library 體感上會偏好使用後者,因為寫起來還滿方便的而且又支援 Hooks 😅,不過如果有不同看法,也歡迎提出一起討論!

最後對於文章中講解有任何不清楚或是覺得有需要補充的地方,再麻煩留言指教,謝謝!

參考文章:

  1. https://github.com/testing-library/react-testing-library
  2. https://kentcdodds.com/blog/making-your-ui-tests-resilient-to-change