前端掌握單元測試-jest
一、前言
本文基于開源項目:
https://github.com/facebook/jest
https://www.jestjs.cn/
對于單元測試,可能小伙伴們的第一反應(yīng)都是“難”,能不寫一般就不去寫了。廣東靚仔也覺得寫單元測試是個有挑戰(zhàn)性,且有難度的任務(wù),但廣東靚仔覺得大家可以盡量去嘗試寫一寫單元測試,在bug減少的同時,項目的質(zhì)量也有很大的提升,對個人而言一定能提升我們自己的能力。
本文我們一起來看看Jest,Jest現(xiàn)在已經(jīng)更新到了28~
二、what Jest
Jest 是一個令人愉快的 JavaScript 測試框架,專注于"簡潔明快"。
這些項目都在使用 Jest:Babel、 TypeScript、 Node、 React、 Angular、 Vue 等等!
特點:
??????? 零配置:Jest 的目標(biāo)是在大部分 JavaScript 項目上實現(xiàn)開箱即用, 無需配置。
??快照測試:能夠輕松追蹤大型對象的測試??煺湛梢耘c測試代碼放在一起,也可以集成進(jìn)代碼行內(nèi)。
???? 隔離:測試程序擁有自己獨立的進(jìn)程 以最大限度地提高性能。
??????? 優(yōu)秀的api:從 it 到 expect - Jest 將整個工具包放在同一個 地方。好書寫、好維護(hù)、非常方便。
三、入門
安裝 Jest:npm / yarn
npm install --save-dev jest
# or
yarn add --dev jes
一般在選中哪個版本的時候,廣東靚仔建議使用穩(wěn)定的版本即可,不一定要最新。
(@27版本)初始化【@28可以省略這一步】
npx jest --init
執(zhí)行完后能看到如下文件(翻譯了一下):
export default {
// 測試中所有導(dǎo)入的模塊都應(yīng)該自動模擬
// automock: false,
// `n` 次失敗后停止運行測試
// bail: 0,
// Jest 應(yīng)該存儲其緩存的依賴信息的目錄
// 每次測試前自動清除模擬調(diào)用、實例、上下文和結(jié)果
// 開啟覆蓋率
clearMocks: true,
// 指示是否應(yīng)在執(zhí)行測試時收集覆蓋率信息
collectCoverage: true,
// 一組 glob 模式,指示應(yīng)為其收集覆蓋信息的一組文件
// collectCoverageFrom: undefined,
// Jest 應(yīng)該輸出其覆蓋文件的目錄
coverageDirectory: "coverage",
// 用于跳過覆蓋收集的正則表達(dá)式模式字符串?dāng)?shù)組
// coveragePathIgnorePatterns: [
// "\\\\node_modules\\\\"
// ],
// 指示應(yīng)使用哪個提供程序來檢測代碼以進(jìn)行覆蓋
coverageProvider: "v8",
};
Demo
Tips: 一般單元測試建議寫在utils文件夾下。
目錄如下:
├── jest.config.js
├── package-lock.json
├── package.json
├── src
│ └── utils
│ └── sum.js
└── liangzai-tests
└── utils
└── sum.test.js
/utils/sum.js
// sum.js
function sum(a, b) {
return a + b;
}
module.exports = sum;
/liangzai-utils/sum.test.js
const sum = require('../../utils/sum');
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});
然后執(zhí)行
npm test
可以看到結(jié)果:
四、轉(zhuǎn)譯ts
Jest 本身不做代碼轉(zhuǎn)譯工作:
安裝ts
npm i -D typescript@4.6.3
初始化 TypeScript 的配置
npx tsc --init
執(zhí)行后會看到tsconfig.json 文件:
{
"compilerOptions": {
"types": ["node", "jest"],
"target": "es2016",
/* 為發(fā)出的 JavaScript 設(shè)置 JavaScript 語言版本并包含兼容的庫聲明 */
"module": "commonjs",
/* 指定生成什么模塊代碼. */
"esModuleInterop": true,
/* 發(fā)出額外的 JavaScript 以簡化對導(dǎo)入 CommonJS 模塊的支持。這將啟用 `allowSyntheticDefaultImports` 以實現(xiàn)類型兼容性. */
"forceConsistentCasingInFileNames": true,
/* 確保imports中的大小寫正確 . */
"strict": true,
/* 啟用所有嚴(yán)格的類型檢查選項。 */
"skipLibCheck": true
/* 跳過類型檢查所有 .d.ts 文件. */
}
}
修改.js為.ts,代碼增加類型
const sum = (a: number, b: number) => {
return a + b;
}
export default sum;
安裝Jest 類型聲明包
npm i -D @types/jest@28.1.2
最后執(zhí)行 npm run test,測試通過。
小優(yōu)化
路徑使用簡寫,修改 tsconfig.json 配置:
{
"compilerOptions": {
"paths": {
"@/*": ["src/*"]
}
}
}
jest.config.js修改moduleNameMapper
modulex.exports = {
"moduleNameMapper": {
"@/(.*)": "<rootDir>/src/$1"
}
}
五、其他知識點
setupFilesAfterEnv 和 setupFiles
簡單來說:
setupFiles 是在 引入測試環(huán)境(比如下面的 jsdom)之后 執(zhí)行的代碼
setupFilesAfterEnv 可以指定一個文件,在每執(zhí)行一個測試文件前都會跑一遍里面的代碼。
具體應(yīng)用場景是:在 setupFiles 可以添加 測試環(huán)境 的補充,比如 Mock 全局變量 abcd 等。而在 setupFilesAfterEnv 可以引入和配置 Jest/Jasmine(Jest 內(nèi)部使用了 Jasmine) 插件。
jsdom 測試環(huán)境
jest 提供了 testEnvironment 配置:
module.exports = {
testEnvironment: "jsdom",
}
jsdom: 這個庫用 JS 實現(xiàn)了一套 Node.js 環(huán)境下的 Web 標(biāo)準(zhǔn) API。
添加 jsdom 測試環(huán)境后,全局會自動擁有完整的瀏覽器標(biāo)準(zhǔn) API,不需要Mock了。
引入react/vue
step1: 安裝Webpack 依賴
step2: 安裝相應(yīng)的Loader
step3: 安裝React/vue 以及業(yè)務(wù)
這里列舉下webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'development',
entry: {
index: './src/index.tsx'
},
module: {
rules: [
// 解析 TypeScript
{
test: /\.(tsx?|jsx?)$/,
use: 'ts-loader',
exclude: /(node_modules|tests)/
},
// 解析 CSS
{
test: /\.css$/i,
use: [
{ loader: 'style-loader' },
{ loader: 'css-loader' },
]
},
// 解析 Less
{
test: /\.less$/i,
use: [
{ loader: "style-loader" },
{
loader: "css-loader",
options: {
modules: {
mode: (resourcePath) => {
if (/pure.css$/i.test(resourcePath)) {
return "pure";
}
if (/global.css$/i.test(resourcePath)) {
return "global";
}
return "local";
},
}
}
},
{ loader: "less-loader" },
],
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js', '.less', 'css'],
// 設(shè)置別名
alias: {
utils: path.join(__dirname, 'src/utils/'),
components: path.join(__dirname, 'src/components/'),
apis: path.join(__dirname, 'src/apis/'),
hooks: path.join(__dirname, 'src/hooks/'),
store: path.join(__dirname, 'src/store/'),
}
},
devtool: 'inline-source-map',
// 3000 端口打開網(wǎng)頁
devServer: {
static: './dist',
port: 3000,
hot: true,
},
// 默認(rèn)輸出
output: {
filename: 'index.js',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
// 指定模板 html
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
}),
],
};
package.json 添加啟動命令
{
"scripts": {
"start": "webpack serve",
"test": "jest"
}
}
配置 tsconfig.json
{
"compilerOptions": {
"jsx": "react",
"esModuleInterop": true,
"baseUrl": "./",
"paths": {
"utils/*": ["src/utils/*"],
"components/*": ["src/components/*"],
"apis/*": ["src/apis/*"],
"hooks/*": ["src/hooks/*"],
"store/*": ["src/store/*"]
}
}
}
六、組件測試
Demo: 這里列舉了一個簡單的場景
user.ts: 獲取用戶角色身份
import axios from "axios";
// 類型:用戶角色身份
export type UserRoleType = "user" | "admin";
// 接口:返回
export interface GetRoleRes {
userType: UserRoleType;
}
// 函數(shù):獲取用戶角色身份
export const getUserRole = async () => {
return axios.get<GetRoleRes>("https://xxx.xx.com/api/role");
};
業(yè)務(wù)組件/Auth/Button/index.tsx(縮略代碼)
import React, { FC, useEffect, useState } from "react";
...
// 身份文案 Mapper
const mapper: Record<UserRoleType, string> = {
user: "用戶",
admin: "管理員",
};
const Button: FC<Props> = (props) => {
const { children, className, ...restProps } = props;
const [userType, setUserType] = useState<UserRoleType>();
// 獲取用戶身份,并設(shè)值
const getLoginState = async () => {
const res = await getUserRole();
setUserType(res.data.userType);
};
useEffect(() => {
getLoginState().catch((e) => message.error(e.message));
}, []);
return (
<Button {...restProps}>
{mapper[userType!] || ""}
{children}
</Button>
);
};
export default Button;
測試用例button.test.tsx
import { render, screen } from "@testing-library/react";
import Button from "components/Button";
import React from "react";
describe('Button', () => {
it('可以正常展示', () => {
render(<Button>登錄</Button>)
expect(screen.getByText('登錄')).toBeDefined();
});
})
上面這代碼只是一個簡單的Demo測試
測試組件功能
mockAxios.test.tsx
import React from "react";
import axios from "axios";
import { render, screen } from "@testing-library/react";
import Button from "components/Button";
describe("Button Mock Axios", () => {
it("可以正確展示用戶按鈕內(nèi)容", async () => {
jest.spyOn(axios, "get").mockResolvedValueOnce({
// 其它的實現(xiàn)...
data: { userType: "user" },
});
render(<Button>你好</Button>);
expect(await screen.findByText("用戶你好")).toBeInTheDocument();
});
it("可以正確展示管理員按鈕內(nèi)容", async () => {
jest.spyOn(axios, "get").mockResolvedValueOnce({
// 其它的實現(xiàn)...
data: { userType: "admin" },
});
render(<Button>你好</Button>);
expect(await screen.findByText("管理員你好")).toBeInTheDocument();
});
});
當(dāng)然,我們也可以不mock,而是使用 Http Mock 工具:msw
Mock Http
代碼如下:
/mockServer/handlers.ts
import { rest } from "msw";
const handlers = [
rest.get("https://xxx.xx.com/api/role", async (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
userType: "user",
})
);
}),
];
export default handlers;
/mockServer/server.ts
import { setupServer } from "msw/node";
import handlers from "./handlers";
const server = setupServer(...handlers);
export default server;
/jest-setup.ts
import server from "./mockServer/server";
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
最后測試用例代碼:
// 偏向真實用例
import server from "../../mockServer/server";
import { rest } from "msw";
import { render, screen } from "@testing-library/react";
import Button from "components/Button";
import React from "react";
import { UserRoleType } from "apis/user";
// 初始化函數(shù)
const setup = (userType: UserRoleType) => {
server.use(
rest.get("https://xxx.xx.com/api/role", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json({ userType }));
})
);
};
describe("Button Mock Http 請求", () => {
it("可以正確展示普通用戶按鈕內(nèi)容", async () => {
setup("user");
render(<Button>廣東</Button>);
expect(await screen.findByText("用戶你好")).toBeInTheDocument();
});
it("可以正確展示管理員按鈕內(nèi)容", async () => {
setup("admin");
render(<Button>靚仔</Button>);
expect(await screen.findByText("管理員你好")).toBeInTheDocument();
});
});
setup 函數(shù),在每個用例前初始化 Http 請求的 Mock 返回。
七、小結(jié)
Jest的功能遠(yuǎn)不止于此,還能做性能測試、自動化測試等等
在我們閱讀完官方文檔后,我們一定會進(jìn)行更深層次的學(xué)習(xí),比如看下框架底層是如何運行的,以及源碼的閱讀。
這里廣東靚仔給下一些小建議:
在看源碼前,我們先去官方文檔復(fù)習(xí)下框架設(shè)計理念、源碼分層設(shè)計
閱讀下框架官方開發(fā)人員寫的相關(guān)文章
借助框架的調(diào)用棧來進(jìn)行源碼的閱讀,通過這個執(zhí)行流程,我們就完整的對源碼進(jìn)行了一個初步的了解
接下來再對源碼執(zhí)行過程中涉及的所有函數(shù)邏輯梳理一遍
作者:廣東靚仔
歡迎關(guān)注:前端早茶