前端掌握单元测试-jest

一、前言
本文基于开源项目:

https://github.com/facebook/jest

https://www.jestjs.cn/

    对于单元测试,可能小伙伴们的第一反应都是“难”,能不写一般就不去写了。广东靓仔也觉得写单元测试是个有挑战性,且有难度的任务,但广东靓仔觉得大家可以尽量去尝试写一写单元测试,在bug减少的同时,项目的质量也有很大的提升,对个人而言一定能提升我们自己的能力。
    本文我们一起来看看Jest,Jest现在已经更新到了28~
二、what Jest
Jest 是一个令人愉快的 JavaScript 测试框架,专注于"简洁明快"。
这些项目都在使用 Jest:Babel、 TypeScript、 Node、 React、 Angular、 Vue 等等!

特点:

??????? 零配置:Jest 的目标是在大部分 JavaScript 项目上实现开箱即用, 无需配置。
??快照测试:能够轻松追踪大型对象的测试。快照可以与测试代码放在一起,也可以集成进代码行内。
???? 隔离:测试程序拥有自己独立的进程 以最大限度地提高性能。
??????? 优秀的api:从 it 到 expect - Jest 将整个工具包放在同一个 地方。好书写、好维护、非常方便。
三、入门
安装 Jest:npm / yarn

npm install --save-dev jest
# or
yarn add --dev jes
一般在选中哪个版本的时候,广东靓仔建议使用稳定的版本即可,不一定要最新。

(@27版本)初始化【@28可以省略这一步】

npx jest --init
执行完后能看到如下文件(翻译了一下):

export default {
  // 测试中所有导入的模块都应该自动模拟
  // automock: false,
  // `n` 次失败后停止运行测试
  // bail: 0,
  // Jest 应该存储其缓存的依赖信息的目录
  // 每次测试前自动清除模拟调用、实例、上下文和结果
  // 开启覆盖率
  clearMocks: true,
  // 指示是否应在执行测试时收集覆盖率信息
  collectCoverage: true,
  // 一组 glob 模式,指示应为其收集覆盖信息的一组文件
  // collectCoverageFrom: undefined,
  // Jest 应该输出其覆盖文件的目录
  coverageDirectory: "coverage",
  // 用于跳过覆盖收集的正则表达式模式字符串数组
  // coveragePathIgnorePatterns: [
  //   "\\\\node_modules\\\\"
  // ],

  // 指示应使用哪个提供程序来检测代码以进行覆盖
  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);
});
然后执行

npm test
可以看到结果:



四、转译ts
Jest 本身不做代码转译工作:

安装ts

npm i -D typescript@4.6.3
初始化 TypeScript 的配置

npx tsc --init
执行后会看到tsconfig.json 文件:

{
  "compilerOptions": {
    "types": ["node", "jest"],
    "target": "es2016",                                  
    /* 为发出的 JavaScript 设置 JavaScript 语言版本并包含兼容的库声明 */
    "module": "commonjs",                                
    /* 指定生成什么模块代码. */
   "esModuleInterop": true,                             
   /* 发出额外的 JavaScript 以简化对导入 CommonJS 模块的支持。这将启用 `allowSyntheticDefaultImports` 以实现类型兼容性. */
    "forceConsistentCasingInFileNames": true,            
    /* 确保imports中的大小写正确 . */
    "strict": true,                                      
    /* 启用所有严格的类型检查选项。 */
    "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
最后执行 npm run test,测试通过。

小优化

路径使用简写,修改 tsconfig.json 配置:

{
  "compilerOptions": {
    "paths": {
      "@/*": ["src/*"]
    }
  }
}
jest.config.js修改moduleNameMapper

modulex.exports = {
  "moduleNameMapper": {
    "@/(.*)": "<rootDir>/src/$1"
  }
}
五、其他知识点
setupFilesAfterEnv 和 setupFiles
简单来说:






setupFiles 是在 引入测试环境(比如下面的 jsdom)之后 执行的代码
setupFilesAfterEnv 可以指定一个文件,在每执行一个测试文件前都会跑一遍里面的代码。
具体应用场景是:在 setupFiles 可以添加 测试环境 的补充,比如 Mock 全局变量 abcd 等。而在 setupFilesAfterEnv 可以引入和配置 Jest/Jasmine(Jest 内部使用了 Jasmine) 插件。
jsdom 测试环境
jest 提供了 testEnvironment 配置:

module.exports = {
  testEnvironment: "jsdom",
}
jsdom: 这个库用 JS 实现了一套 Node.js 环境下的 Web 标准 API。

添加 jsdom 测试环境后,全局会自动拥有完整的浏览器标准 API,不需要Mock了。

引入react/vue
step1: 安装Webpack 依赖

step2: 安装相应的Loader

step3: 安装React/vue 以及业务

这里列举下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'],
    // 设置别名
    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 端口打开网页
  devServer: {
    static: './dist',
    port: 3000,
    hot: true,
  },
  // 默认输出
  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;
}

// 函数:获取用户角色身份
export const getUserRole = async () => {
  return axios.get<GetRoleRes>("https://xxx.xx.com/api/role");
};
业务组件/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>();

  // 获取用户身份,并设值
  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("可以正确展示用户按钮内容", async () => {
    jest.spyOn(axios, "get").mockResolvedValueOnce({
      // 其它的实现...
      data: { userType: "user" },
    });

    render(<Button>你好</Button>);

    expect(await screen.findByText("用户你好")).toBeInTheDocument();
  });

  it("可以正确展示管理员按钮内容", async () => {
    jest.spyOn(axios, "get").mockResolvedValueOnce({
      // 其它的实现...
      data: { userType: "admin" },
    });

    render(<Button>你好</Button>);

    expect(await screen.findByText("管理员你好")).toBeInTheDocument();
  });
});
当然,我们也可以不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";

// 初始化函数
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("可以正确展示普通用户按钮内容", async () => {
    setup("user");

    render(<Button>广东</Button>);

    expect(await screen.findByText("用户你好")).toBeInTheDocument();
  });

  it("可以正确展示管理员按钮内容", async () => {
    setup("admin");

    render(<Button>靓仔</Button>);

    expect(await screen.findByText("管理员你好")).toBeInTheDocument();
  });
});
setup 函数,在每个用例前初始化 Http 请求的 Mock 返回。

七、小结
Jest的功能远不止于此,还能做性能测试、自动化测试等等

在我们阅读完官方文档后,我们一定会进行更深层次的学习,比如看下框架底层是如何运行的,以及源码的阅读。

    这里广东靓仔给下一些小建议:
在看源码前,我们先去官方文档复习下框架设计理念、源码分层设计
阅读下框架官方开发人员写的相关文章
借助框架的调用栈来进行源码的阅读,通过这个执行流程,我们就完整的对源码进行了一个初步的了解
接下来再对源码执行过程中涉及的所有函数逻辑梳理一遍



作者:广东靓仔

欢迎关注:前端早茶