前端掌握单元测试-jest

前端大神之路

共 17691字,需浏览 36分钟

 ·

2022-07-07 18:22

本文适合对单元测试感兴趣的小伙伴阅读

欢迎关注前端早茶,与广东靓仔携手共同进阶~

一、前言

本文基于开源项目:

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 应该存储其缓存的依赖信息的目录
  // 每次测试前自动清除模拟调用、实例、上下文和结果
  // 开启覆盖率
  clearMockstrue,
  // 指示是否应在执行测试时收集覆盖率信息
  collectCoveragetrue,
  // 一组 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(12)).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',
    port3000,
    hottrue,
  },
  // 默认输出
  output: {
    filename'index.js',
    path: path.resolve(__dirname, 'dist'),
    cleantrue,
  },
  // 指定模板 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的功能远不止于此,还能做性能测试自动化测试等等

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

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

关注我,一起携手进阶

欢迎关注前端早茶,与广东靓仔携手共同进阶~

浏览 108
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报