我們是袋鼠云數堆疊 UED 團隊,致力于打造優秀的一站式資料中臺產品,我們始終保持工匠精神,探索前端道路,為社區積累并傳播經驗價值,
前言
單元測驗是一種用于測驗“單元”的軟體測驗方法,其中“單元”的意思是指軟體中各個獨立的組件或模塊,開發者需要為他們的代碼撰寫測驗用例以確保這些代碼可以正常使用,
在我們的業務開發中,通常應用的是敏捷開發的模型,在此類模型中,單元測驗在大部分情況下是為了確保代碼的正常運行以及防止在未來迭代的程序中出現問題,
測驗目的
1、排除故障
每個應用的開發中,多少會出現一些意料之外的 bug,通過測驗應用程式,可以幫助我們大大減少此類問題,并且增強應用程式的邏輯性,
2、保證團隊成員的邏輯統一
如果您是團隊的新成員,并且對應用程式還不熟悉,那么一組測驗就好像是有經驗的開發人員監視你撰寫代碼,確保您處于代碼應該執行的正確路線之內,通過這些測驗,您可以確信在添加新功能或更改現有代碼時不會破壞任何東西,
3、可以提高質量代碼
當您在撰寫 React 組件時,由于考慮到測驗,最好的方案將是創建獨立的、更可重用的組件,如果您開始為您的組件撰寫測驗,并且您注意到這些組件不容易測驗,那么您可能會重構您的組件,最終起到改進它們的效果,
4、起到很好的說明檔案作用
測驗的另一個作用是,它可以為您的開發團隊生成良好的檔案,當某人對代碼庫還不熟悉時,他們可以查看測驗以獲得指導,這可以提供關于組件應該如何作業的意圖的洞察,并為可能要測驗的邊緣部分提供線索,
規范
工具
在袋鼠云數堆疊團隊,我們建議使用 jest + @testing-library/react 來書寫測驗用例,后者是為 DOM 和 UI 組件測驗的軟體工具,
基礎語法
-
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=https://www.cnblogs.com/dtux/p/{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,通常需要和runAllTicks、runAllTimers配合,
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 = https://www.cnblogs.com/dtux/p/{};
test('',() => {
add(mockData)
...
...
})
test.only('',() => {
add(mockData) // the mockData is modified by add function here
...
...
})
在專案中遇到的一些問題
1、執行 pnpm test 報錯

原因:當引入外部庫是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版本未對應
報如下錯誤

升級后版本(僅供參考)

3、toBeInTheDocument、toHaveClass等報錯


型別檢查錯誤,應該是@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’

修改 tsconfig.json ,往 types 中加入 node
"types": ["node", "@testing-library/jest-dom"]
5、module 'tslib' cannot be found
報錯資訊如下

原因是在 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下
例如:

解決方案如下:
(global as any).document.createRange = () => ({
selectNodeContents: jest.fn(),
getBoundingClientRect: jest.fn(() => ({
width: 500,
})),
});
7、多個單測檔案缺失某一個方法,可以采用如下配置
例如:多個單測檔案有如下報錯:

那么首先在 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 報錯,

10、引入了第三方的組件CodeMirrorEditor寫單測報錯
在對該組件進行單測時,由于引入了第三方的組件 CodeMirrorEditor ,編譯時出現了以下問題,原因是試圖匯入 jest 無法決議的檔案,而從實際上來說我們對當前組件的測驗其實并不用去編譯 dt-react-codemirror-editor,


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

再次運行測驗,然而,,,,,,

好吧,又失敗了進入 index 查看,提示找不到 style 檔案但是檔案夾里又是存在的,初步嘗試是否由于檔案擴展名起,保存測驗通過,但是修改 node_modules 里的檔案擴展名無法從根本解決該問題,按照推薦提示在測驗覆寫檔案擴展名 moduleFileExtensions 內加入 css,

再次嘗試,然而,,,,,,jest 去編譯了 style.css 檔案,然后它無法決議失敗了,查看配置,

發現已經配置了當匹配到 css 檔案時映射到一個空物件里,并不會去編譯原樣式檔案,原因是由于加入到了編譯覆寫的檔案擴展名陣列里 moduleFileExtensions,因此無法采用推薦方法,

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

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-router 和react-router-dom分開的;而4.0路由將其合并成了一個包,在具體使用時應該基于不同的平臺要使用不同的系結庫,例如在瀏覽器中使用 react router,就安裝 react-router-dom 庫;在 React Native 中使用 React router 就應該安裝 react-router-native 庫,但是我們不會安裝 react-router了,專案中用的是3.0版本路由,于是改為3.0寫法,將link和router分開寫在兩個組件中,通過測驗
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')
})
})
參考文獻
- jest 官方檔案
- React Testing Library 官方檔案
轉載請註明出處,本文鏈接:https://www.uj5u.com/qiye/543920.html
標籤:Html/Css
