Jest + React 單元測試最佳實踐

袋鼠雲數棧UED發表於2023-02-14
我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式資料中臺產品。我們始終保持工匠精神,探索前端道路,為社群積累並傳播經驗價值。

前言

單元測試是一種用於測試“單元”的軟體測試方法,其中“單元”的意思是指軟體中各個獨立的元件或模組。開發者需要為他們的程式碼編寫測試用例以確保這些程式碼可以正常使用。

在我們的業務開發中,通常應用的是敏捷開發的模型。在此類模型中,單元測試在大部分情況下是為了確保程式碼的正常執行以及防止在未來迭代的過程中出現問題。

測試目的

1、排除故障

每個應用的開發中,多少會出現一些意料之外的 bug。透過測試應用程式,可以幫助我們大大減少此類問題,並且增強應用程式的邏輯性。

2、保證團隊成員的邏輯統一

如果您是團隊的新成員,並且對應用程式還不熟悉,那麼一組測試就好像是有經驗的開發人員監視你編寫程式碼,確保您處於程式碼應該執行的正確路線之內。透過這些測試,您可以確信在新增新功能或更改現有程式碼時不會破壞任何東西。

3、可以提高質量程式碼

當您在編寫 React 元件時,由於考慮到測試,最好的方案將是建立獨立的、更可重用的元件。如果您開始為您的元件編寫測試,並且您注意到這些元件不容易測試,那麼您可能會重構您的元件,最終起到改進它們的效果。

4、起到很好的說明文件作用

測試的另一個作用是,它可以為您的開發團隊生成良好的文件。當某人對程式碼庫還不熟悉時,他們可以檢視測試以獲得指導,這可以提供關於元件應該如何工作的意圖的洞察,併為可能要測試的邊緣部分提供線索。

規範

工具

在袋鼠雲數棧團隊,我們建議使用 jest + @testing-library/react 來書寫測試用例。後者是為 DOMUI 元件測試的軟體工具。

基礎語法

  • describe:一個將多個相關的測試組合在一起的塊
  • test:將執行測試的方法,別名是it
  • expect:斷言,判斷一個值是否滿足條件,你會使用到expect函式。 但你很少會單獨呼叫expect函式, 因為你通常會結合expect和匹配器函式來斷言某個值
  • skip:跳過指定的describe以及test,用法describe.skip/test.skip
  • cleanup:在每一個測試用例結束之後,確保所有的狀態能迴歸到最初狀態,比如在 UI 元件測試中,我們建議在 afterEach 中呼叫 cleanup 函式

    import { cleanup } from '@testing-library/react';
    
    describe('For test', () => {
      afterEach(cleanup);
      test('...', () => {})
    })

注意事項

1、函式命名

關於是使用 test 還是使用 it 的爭論,我們不做限制。但是建議一個專案裡,儘量保持風格一致,如果其餘測試用例中均為 test,則建議保持統一。

2、業務程式碼

我們建議儘量把業務程式碼的函式的功能單一化,簡單化。如果一個函式的功能包含了十幾個功能數十個功能,那我們建議對該函式進行拆分,從而更加有利於測試的進行。

3、程式碼重構

在重構程式碼之前,請確保該模組的測試用例已經補全,否則重構程式碼的風險會過於巨大,從而導致無法控制開發成本。

4、覆蓋率

我們建議儘量以覆蓋率 100% 為目標。當然,在具體的開發過程中會有各種各樣的情況,所以很少有能夠達到 100% 的情況出現。

5、修復問題

每當我們修復了一個 bug,我們應當評估是否有必要為這個 bug 新增一個測試用例。如果需要的話,則在測試用例中新增一條以確保後續的開發中不會復現該 bug。

評估的參考內容如下:

  • 是否會造成白屏或其他嚴重的問題
  • 是否會影響使用者的互動行為
  • 是否會影響內容的展示

以上內容,滿足一條或多條,則認為應當為該 bug 新增測試用例。

6、toBe or toEqual

這兩者的區別在於,toBe 是相等,即 ===,而 toEqual 是內容相同,即深度相等。我們建議基礎型別用 toBe,複雜型別用 toEqual

我們需要測試什麼

包括但不限於以下幾種:

  • Component Data:元件靜態資料
  • Component Props:元件動態資料
  • User Interaction:使用者互動,例如單擊
  • LifeCycle Methods:生命週期邏輯
  • Store:元件狀態值
  • Route Params:路由引數
  • 輸出的dom
  • 外部呼叫的函式
  • 對子元件的改變

單元測試場景

1、快照測試

如果是一個純渲染的頁面或者元件,我們可以透過快照記錄最終效果,下一次快照結果會去對比是否正確。
使用場景:對於一個已知的固定的結果,我們使用快照去記錄結果,每次進行測試會將最新結果和記錄結果進行對比,如果一致,則代表測試透過,反之,則不然。

通常在測試 UI 元件時,我們會建議進行快照測試,以確保 UI 不會有意外的改變。這裡我們建議使用 react-test-renderer 進行快照測試。

yarn add react-test-renderer @types/react-test-renderer -D

安裝完成後,建議在 UI 測試的首個測試用例進行快照測試。

import React from 'react';
import renderer from 'react-test-renderer';
import { Toolbar } from '..';

test('Match Snapshot', () => {
  const component = renderer.create(<Toolbar data={toolbarData} />);
  const toolbar = component.toJSON();
  expect(toolbar).toMatchSnapshot();
});

2、dom 結構測試

使用場景:對於當前元件接收到的引數或者資料,會對應渲染出一個固定結構,我們對結構進行解析,看是否與預期相符。比如表格的行數應該與介面返回的 list 長度一致,表格的表頭應該固定是我們設定的文案,表格的對應某一格應該是介面返回的對應行和列的值。再比如元件內部根據接收的 props 的變數去判斷顯示 dom 結構,那我們在單測傳入某一個值時,我們的預期應該是顯示為什麼樣的。我們建議使用 @testing-library/jest-dom 做相關的測試

yarn add --dev @testing-library/jest-dom

測試例子如下:

import React from 'react';
import { render, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';

describe('Test Breadcrumb Component', () => {
  test('Should support to render custom title', async () => {
    const { container, getByTitle } = render(
       <MyComponent
         renderTitle={() => "I'm renderTitle";}
        />
     );

    const testDom = await waitFor(() =>
      container.querySelector('[title="test1"]')
    );
    const dom = await waitFor(() =>
      container.querySelector('[title="I\'m renderTitle"]')
    );

    expect(testDom).not.toBeInTheDocument();
    expect(dom).toBeInTheDocument();
  });
});

除了 toBeInTheDocument 外,還有其餘介面,參見官方文件。

3、事件測試

使用場景:當元件或者頁面上有點選事件,對於點選後發生的一系列動作是我們需要檢測的,首先需要用 fireEvent 去模擬事件發生,然後測試事件是否正確觸發,比如我的表單操作按鈕,對於操作後的動作進行一一檢測對應。

const btns = btnBox.getElementsByClassName('ant-btn');
// 取消
fireEvent.click(btns[0]);
await waitFor(() => {
  expect(API.getProductListNew).toHaveBeenCalled();
});

4、function測試

function add(a, b){
  return a+b;
}
it('test add function', () => {
  expect(add(2,2)).toBe(4);
})

5、非同步測試

使用場景:當你的預期需要時間等待

  • waitFor:可能會多次執行回撥,直到達到超時
await waitFor(() => expect(mockAPI).toHaveBeenCalledTimes(1))
  • useFakeTimers:指定 Jest 使用假的全域性日期、效能、時間和定時器 API,通常需要和runAllTicksrunAllTimers配合。
test('should warn if not saved custom type but clicked custom button', () => {
  const { getByText, baseElement } = wrapper;

  jest.useFakeTimers();
  fireEvent.click(getByText('自定義型別'));
  fireEvent.mouseDown(getByText('自定義型別'));

  expect(getByText('名稱不能為空')).toBeInTheDocument();
  jest.runAllTimers();

  const inputEle = baseElement.querySelector('.dt-input');
  fireEvent.change(inputEle, { target: { value: '1' } });
  jest.useFakeTimers();
  fireEvent.click(getByText('自定義型別'));

  expect(getByText('請先儲存')).toBeInTheDocument();
  jest.runAllTimers();
});

6、模擬屬性和方法的返回結果

使用場景:當訪問的某些屬性或者方法在當前環境不存在時。

// 已有屬性:jest.spyOn,例子如下
jest.spyOn(document.documentElement, 'scrollWidth', 'get').mockImplementation(() => 100);

// 未知屬性:Object.defineProperty,例子如下
Object.defineProperty(window, 'getComputedStyle', { value: jest.fn(() => ({ paddingLeft: '0px'})

// 方法的返回結果:jest.mock
function = jest.mock(() => {})

7、Drag

有時候,我們需要去測試拖拽功能,我們建議用以下函式來執行模擬拖拽的操作

import { fireEvent } from '@testing-library/react';

function dragToTargetNode(source: HTMLElement, target: HTMLElement) {
  fireEvent.dragStart(source);
  fireEvent.dragOver(target);
  fireEvent.drop(target);
  fireEvent.dragEnd(source);
}

8、test.only

在出現測試用例無法透過,但是又判斷程式碼的邏輯沒有問題之後,將該條測試用例設定為 only 再跑一遍測試用例,以確保不是其他測試用例導致的該測試用例的失敗。這類問題經常出現自程式碼中欠缺深複製,導致多條測試用例之中修改了原資料從而使得資料不匹配。

例如:

// mycode.ts
function add(record: Record<string, any>){
  Object.assign(record, { flag: false});
}

// mycode.test.ts
const mockData = {};
test('',() => {
  add(mockData)
  ...
  ...
})

test.only('',() => {
  add(mockData) // the mockData is modified by add function here
  ...
  ...
})

在專案中遇到的一些問題

1、執行 pnpm test 報錯

file

原因:當引入外部庫是es模組時, jest無法處理導致報錯,可以透過 babel-jest 進行處理,根據官方文件:https://jestjs.io/zh-Hans/docs/26.x/getting-started,還有一種就是修改jest.config.js 加入preset: 'ts-jest' ,會讓部分測試成功但是還是會存在一些問題。

方案一:採用了 babel-jest 進行處理

pnpm add -D babel-jest @babel/core @babel/preset-env

安裝完以後在工程的根目錄下建立一個babel.config.js

module.exports = {
  presets: [['@babel/preset-env', {targets: {node: 'current'}}]],
};

修改jest.config.js,增加transform

transform: {
  "^.+\\.js$": "babel-jest",
  "^.+\\.(ts|tsx)$": "ts-jest",
},

方案二:仍然採用 ts-jest ,把引起報錯檔案的字尾,如 js 改為 ts 即可

2、ts-jest和jest版本未對應

報如下錯誤

file

升級後版本(僅供參考)

file

3、toBeInTheDocument、toHaveClass等報錯

file

file

型別檢查錯誤,應該是@testing-library/jest-dom型別沒被引入導致的

有以下兩種方案,都需要修改tsconfig.json

// 方案一,刪除typeRoots
"typeRoots": ["node", "node_modules/@types", "./typings"]

// 方案二,新增types
"types": ["@testing-library/jest-dom"]

參考連結:https://stackoverflow.com/questions/57861187/property-tobeinthedocument-does-not-exist-on-type-matchersany

4、Cannot find namespace 'NodeJS’

file

修改 tsconfig.json ,往 types 中加入 node

"types": ["node", "@testing-library/jest-dom"]

5、module 'tslib' cannot be found

報錯資訊如下

file

原因是在 tsconfig.json 中開啟瞭如下配置

"importHelpers": true,

編譯檔案會引入tslib可以參考

https://juejin.cn/post/6953554051879403534

https://github.com/microsoft/TypeScript/issues/37991

解決方案如下:
方案一:

"importHelpers": false,

方案二:

pnpm add tslib

並且修改 tsconfig

"paths": {
  "tslib" : ["./node_modules/tslib/tslib.d.ts"] //在paths下新增tslib路徑
}

6、由於單測的執行環境問題,當遇到某些方法沒有的時候嘗試mock下

例如:

file

解決方案如下:

(global as any).document.createRange = () => ({
  selectNodeContents: jest.fn(),
  getBoundingClientRect: jest.fn(() => ({
    width: 500,
  })),
});

7、多個單測檔案缺失某一個方法,可以採用如下配置

例如:多個單測檔案有如下報錯:

file

那麼首先在 jest.comfig.js 中新增配置

module.exports = {
  setupFilesAfterEnv: ['./setupTests.ts'],
  // ...
}

然後在 setupTests.ts 檔案中:

Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: jest.fn().mockImplementation((query) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(), // deprecated
    removeListener: jest.fn(), // deprecated
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
  })),
});

8、The error below may be caused by using the wrong test environment;Consider using the "jsdom" test environment

依賴版本:

"ts-jest": "^28.0.8",
"jest": "^28.1.2",

解決方法: 在 jest.config.js 中新增配置

module.exports = {
  verbose: true,
  testEnvironment: 'jsdom',
  // ...
}

並安裝 jest-environment-jsdom (注意: 僅 jest 28 及更高版本需要安裝此依賴項)

{
  "devDependencies": {
    "jest-environment-jsdom": "^28.1.2",
  }
}

9、Echarts 單元測試 canvas 報錯

在寫 Echarts 單元測試的時候,會有 canvas 報錯。原因很明顯,Echarts 依賴了 canvas。

解決辦法:使用 jest-canvas-mock,參考:Error: Not implemented: HTMLCanvasElement.prototype.getContext

注意:直接引入 canvas 雖然可以解決單元測試的報錯,但是會導致安裝依賴會有偶發性 canvas 報錯。

file

10、引入了第三方的元件CodeMirrorEditor寫單測報錯

在對該元件進行單測時,由於引入了第三方的元件 CodeMirrorEditor ,編譯時出現了以下問題,原因是試圖匯入 jest 無法解析的檔案,而從實際上來說我們對當前元件的測試其實並不用去編譯 dt-react-codemirror-editor。

file

file

因此,在 jest.config.js 檔案加入編譯時需要忽略的檔案。

file

再次執行測試,然而。。。。。。

file

好吧,又失敗了進入 index 檢視,提示找不到 style 檔案但是資料夾裡又是存在的,初步嘗試是否由於副檔名起,儲存測試透過,但是修改 node_modules 裡的副檔名無法從根本解決該問題,按照推薦提示在測試覆蓋副檔名 moduleFileExtensions 內加入 css。

file

再次嘗試,然而。。。。。。jest 去編譯了 style.css 檔案,然後它無法解析失敗了,檢視配置。

file

發現已經配置了當匹配到 css 檔案時對映到一個空物件裡,並不會去編譯原樣式檔案,原因是由於加入到了編譯覆蓋的副檔名陣列裡 moduleFileExtensions,因此無法採用推薦方法。

file

再次回顧問題產生的原因,jest 無法找到 style 檔案但是找到了 style.css 檔案,但是 style 檔案我們並不需要進行編譯,加入 moduleNameMapper 當找到 style 檔案時對映到一個空物件的檔案裡。

file

11、Route && Link

在測試麵包屑元件BreadCrumb時,因為麵包屑元件中只用了 Link 標籤,最終會被轉成 a 標籤,用來路由導航。如下寫法是將 Link 和 route 放在一個元件之中。然後報錯:Invariant Violation: <Link>s rendered outside of a router context cannot navigate

import React from 'react'
import BreadCrumb from '../index';
import { render, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect';
import { Router, Switch, Route } from 'react-router-dom';
import { createMemoryHistory } from 'history'
const testProps = {
  breadcrumbNameMap: [
    {
       name: 'home',
       path: '/home'
    },
    {
       name: 'home/about',
       path: '/home/about'
    }
  ],
  style: {
    backgroundColor: '#dedede'
  }
}
const Home = () => <h1>home</h1>
const About = () => <h1>about</h1>
const App = () => {
  const history = createMemoryHistory();
  return (
     <>
       <Router history={history}>
         {< BreadCrumb {...testProps} />}
          <Switch>
             <Route exact path="/main" component={Home} />
             <Route path="/main/home" component={About} />
          </Switch>
        </Router>
      </>
    )
}
describe('test breadcrumb', () => {
  test('should navigate to home when click ', () => {
    const { container, getByTestId } = render(<App />);
    expect(container.innerHTML).toMatch('about')
    fireEvent.click(getByTestId('/home-link'))
      expect(container.innerHTML).toMatch('home')
  })
})

主要原因是版本原因:3.0版本路由不支援這種寫法。3.0是將react-routerreact-router-dom分開的;而4.0路由將其合併成了一個包,在具體使用時應該基於不同的平臺要使用不同的繫結庫。例如在瀏覽器中使用 react router,就安裝 react-router-dom 庫;在 React Native 中使用 React router 就應該安裝 react-router-native 庫,但是我們不會安裝 react-router了。專案中用的是3.0版本路由,於是改為3.0寫法,將linkrouter分開寫在兩個元件中,透過測試

const testProps = {
  breadcrumbNameMap: [
    {
      name: 'home',
      path: '/home'
    },
    {
      name: 'about',
      path: '/about'
    }
  ],
  style: {
    backgroundColor: '#dedede'
  }
}
const App = (props) => {
  return (
    <div>
      {<BreadCrumb {...testProps} />}
      {props.children}
    </div>
  )
}
const About = () => <h1>about page</h1>
const Home = () => <h1>home</h1>

describe('test breadcrumb', () => {
  afterEach(() => {
    cleanup();
  })
  test('should navigate to home router when click ', () => {
    const history = createMemoryHistory()
    const { container, getByTestId } = render(
    <Router history={history}>
      <Route path="/" component={App}>
        <IndexRoute component={About} />
        <Route path="/about" component={About} />
        <Route path="/home" component={Home} />
      </Route>
    </Router>
  );
    expect(container.innerHTML).toMatch('about')
    fireEvent.click(getByTestId('/home-link'))
    expect(container.innerHTML).toMatch('home')
  })
})

參考文獻

相關文章