Unit Test(單元測試),針對程式的最小單位,進行測試,最小單位可能是一個 function,或一個 component、class 等等,執行單元測試是為了確保每個功能都能夠正常執行,提早發現並找出問題所在。

目前正在開發新專案,但又要兼顧維運另個舊專案,舊專案是公司核心產品,常常需要改動需求,因為新專案 deadline 緊迫,不大有時間詳細的測試改動,最近疊加太多功能,導致連動出現滿多 bug,因為這事件,更讓我們重視測試的重要性。

react unit test

接下來就來針對 react hooks 做 component testing。

安裝測試工具 Jest

Jest 是由 facebook 開源的工具,源自於 Jasmine 延伸開發,設定少、輕巧,官方文件非常清楚,提供斷言庫、mocking data等,Jest 算是我們 test 的 runner,可以再搭配 enzyme、puppeteer 等等執行,讓 test 能更齊全。

npm install --save-dev jest
  • 編輯 package.json
    "scripts": {
    ...
    "test": "jest",
    ...
    }

執行 Jest 會預設抓取 __tests__ folder 內 js、ts 檔案,或是 fileName 有 spec、test 的 js ts 檔案 來跑測試, 假設你有用到 babel,或是其他 css 編譯工具,則需要設定 jest config。

規則預設

([ "**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)" ])

官方文件: Jest starter guide

Jest 主要依賴幾個 function 執行測試,describe function 可以讓我們對這一個測試做命名,以便後面執行測試追蹤,test function 則是讓我們定義某個 test case,例如範例,我們用 describe 測試包覆多種狀況來測試,expect 則是讓我們丟入 function 做 return,toBe 就是預期測試結果是否符合預期。

官方文件: Jest 斷言方式

  • Jest 使用
    describe('Test sum', () => {
    test('function return 0', () => {
    expect(sum(0)).toBe(0)
    })
    test('function sum 0, 1 return 1', () => {
    expect(sum(0,1)).toBe(1)
    })
    })

目前新專案是純倚賴 Jest 來測試,主要規劃會以 function test 以及 end to end 為主,主要先針對最重要的 function 做不同情境測試。

安裝完成有遇到 react-script start 會有 jest error,依照提示移除 jest、node_modules、package-lock.json,並重新安裝即可解決。

react-test-renderer Snapshot Testing

剛好在 react test guide 看到,好奇順便研究一下,react-test-renderer 是 facebook 開發的工具,功能直覺簡單,是用來實現不需要依賴瀏覽器 render component 執行 test,首先會幫你 render compoent,並可讓你執行 toMatchSnapshot 匯出元件 render Snaphot,也可在依照 render component 模擬更新互動,更新後狀態也可匯出 snapshot,另外也可以純取值比較。

如果你元件經常要更新,可想而知你這份 snapshot testing 會經常需要更新,但如果穩定,就可以直接測試出 component 與資料不同的更新樣貌。

文件: react-test-renderer

下面是簡單的元件,主要測試是觸發 <span> 的 onClick 讓 state count 更新。

function TodoView() {
const inputEl = useRef(null);
const [todoList, addTodo, deleteToDo] = useTodoList([], inputEl);
const [count, setCount] = useState(0);

return (
<div>
<span onClick={()=>setCount(count+1)}>Counter : {count}</span>
...
</div>
);
}

下方為實際使用範例,主要邏輯就是使用 create render component,再把元件資料轉格式匯出 snapshot,並可以用 act 來調用 component function,直接拉 props function 用會有 error warning,主要用起來困難點會是在 selector,還是如何整理 snapshot 匯出格式,幫助我們日後測試。

目前專案尚未導入 snapshot test,因為專案還在不斷改動中,評估 component test 維運成本過高先略過。

  • src/testSnap/TodoView.test.js
    import React from "react";
    import { create, act } from "react-test-renderer";
    import TodoView from '/container/TodoView';

    describe("TodoView component", () => {
    test("it shows the expected text when clicked", () => {
    let component;
    act(() => {
    component = create(<TodoView />);
    });
    let tree = component.toJSON();
    expect(tree).toMatchSnapshot();
    const instance = component.root;

    const button = instance.findByType("span");
    act(() => button.props.onClick());
    expect(button.props.children.join()).toBe("Counter : ,1")
    expect(button.props.children).toMatchSnapshot();
    });
    });

Component Testing @testing-library/react

React 官方範例是可以依賴內部 function act 去 render component,並依賴 dispatch event 去觸發事件,但若是複雜,官方更推薦使用 @testing-library/react,這是一套專注於測試 user interactive 的工具,可以讓我們模擬 select component -> click,有別於 enzyme 依賴執行 function trigger 更新 component。

這套工具是同事推薦的,react testing library 專注的方向符合我們的需要,更能貼近使用者實際的互動。

  • React 官方 test note
    We recommend using React Testing Library which is designed to
    enable and encourage writing tests that use your components
    as the end users do.

    Alternatively, Airbnb has released a testing utility called Enzyme,
    which makes it easy to assert, manipulate, and traverse your
    React Components’ output.

下方為 TypeInInput 的元件,預期當我們 pass text 會 render 出字串,並觸發 onChange event 傳遞 value,再來比較 input 內的 value 是否符合,接下來就來測試這個情境。

@testing-library/react 主要依賴 render 來 render component,fireEvent 則讓我們可以觸發事件(click、change、dispatch Event)。

主要流程大致為 render component,並接受返回的 function,各種 selector 都會在這時候取得,目前寫起來最順手是利用 getByTestId 搭配 data-testid,以往再寫 react component,會比較少寫 class or id,利用 tag 或是 string 來做選擇,難度更是麻煩,這些更常會因為需求更新。

  • TypeInInput Component
    import React, { useState } from "react";
    import { render, fireEvent } from "@testing-library/react";

    function TypeInInput({text}) {
    const [value, setValue] = useState('');
    const onChangeValue = e => {
    setValue(e.target.value);
    };
    return (
    <div>
    <label>{text}</label>
    <input data-testid="typeIn" value={value} onChange={onChangeValue} />
    </div>
    );
    }

    describe("Test TypeInInput", () => {
    const text = "email";
    test("change event", () => {
    const { getByText } = render(<TypeInInput text={text} />);
    const title = getByText(/email/);
    expect(title.textContent).toEqual(text);
    });
    test("change event", () => {
    const { getByTestId } = render(<TypeInInput text={text} />);
    const typeInElem = getByTestId("typeIn");
    const test = "[email protected]";
    fireEvent.change(typeInElem, { target: { value: test } });
    expect(typeInElem.value).toEqual(test);
    });
    });

至於比較複雜的 async function api 的操作,記得要修改 babel config 執行非同步,以及安裝 @testing-library/jest-dom 執行 toHaveTextContent,讓斷言能更靈活。

  • babel.config.js
    [
    "@babel/preset-env",
    {
    targets: {
    node: "current"
    }
    }
    ]

Fetch 接受 url、axios (模擬 api call) 兩個 prop。Component 內用到 state、useCallback、建立 function fetchData,

Component 預期狀態

useEffect 會在元件 render 傳入 url 以及 apiCall function,並執行 click element 觸發 fetchData 調用 apiCall 更新 state。

import React, { useState, useCallback } from "react";
import axios from "axios";
import { render, fireEvent, waitForElement } from "@testing-library/react";

import '@testing-library/jest-dom/extend-expect'

function Fetch({ url, apiCall }) {
const [data, setDate] = useState();
const fetchData = useCallback(async () => {
const response = await apiCall.get(url);
setDate(response.data);
}, [apiCall, url]);
return (
<div>
<button onClick={fetchData}>Fetch</button>
{data ? <span data-testid="fetch">{data.test}</span> : null}
</div>
);
}

test("Fetch makes an API call and displays the greeting", async () => {
const fakeAxios = {
get: jest.fn(() => Promise.resolve({ data: { test: "hello world" } }))
};
const url = "https://example.com/get-hello-there";
const { getByText, getByTestId } = render(
<Fetch url={url} apiCall={fakeAxios} />
);
fireEvent.click(getByText(/fetch/i));

const fetchNode = await waitForElement(() => getByTestId("fetch"));

expect(fetchNode).toHaveTextContent("hello world");
});

更複雜例子也可以查閱作者在 codesandbox 寫的各種 sample code,

作者範例: kentcdodds codesandbox

心得

單元測試在我剛寫程式時,認為測試 case 是自己預期的,還主觀的認為沒什麼用,因為開發者所預期的測試一定充滿盲點,沒太大用處。但我在實際寫幾個測試後,發現最大的功用在於程式的 clear,當你在寫某個功能時,能更專注在預期判斷 input output,也有助於你思考 function 架構更清楚。

你可能會說每次測試都沒抓到真實發生的 bug,這時就要換個角度思考,為什麼測試 case 沒測試到,是不是情境 case 太過簡單、理想,是不是需要再擴展更多 case。

新專案不幸的重構了大概兩次左右…,還是最重要的資料更新,運氣很好的在改動中有在測試階段發現問題,我大概被抓到兩次問題,當時心想這個 testing case 的時間成本回本了XDD,不斷讓我佩服 unit test 發揮做用。

內文這些實際範例,寫起來最卡的部分在環境設定,非同步測試…、編譯錯誤等等,還有熟悉 selector 與斷言方式,不像是 enzyme 有 cherrio 支援類似 jquery 的語法,剩下就是各種使用測試技巧,這個寫更多案例後會更熟悉。後續有 react-testing-library 實際導入專案會再多寫相關內容。

以上若有錯誤,歡迎留言提醒,

感謝閱讀。