前端掌握單元測試-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)注:前端早茶