TTask - Part 5 单元测试与 CI

Published on
9 min read

上一篇文章介绍了使用装饰器简化路由定义的思路与简单实现,接下来配置 Jestnflask 代码进行单元测试,保证代码的健壮性。同时借助 huskylint-staged 在代码提交时检测代码规范,并借助 Github Actions 在代码提交到仓库之后检测单元测试覆盖率。

Jest 与 Typescript

使用 Jest 对 Typescript 文件做单元测试,官网提供了两种方式,一种是借助 Babel,另一种是使用 ts-jest。对于 nflask 项目而言使用 Babel 会带来额外的配置,使用 ts-jest 是最简单的方式。

安装依赖

pnpm add -D jest ts-jest @types/jest -w

添加配置文件

在 monorepo 项目根目录下,新建 jest.config.base.js 文件存放最基础通用的配置,以及 jest.config.js 作为 jest 执行的配置文件。

// jest.config.base.js
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  moduleFileExtensions: ['ts', 'js'],
  testMatch: ['**/__tests__/**/*.+(ts|tsx|js)', '**/?(*.)+(spec|test).+(ts|tsx|js)'],
  transform: {
    '^.+\\ts$': 'ts-jest',
  },
};
// jest.config.js
const base = require('./jest.config.base');

/** @type {import('ts-jest').JestConfigWithTsJest} */
const base = require('./jest.config.base');

/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
  ...base,
  verbose: true,
  silent: true,
  roots: ['<rootDir>'],
  projects: ['<rootDir>/packages/nflask'],
  collectCoverage: true,
  collectCoverageFrom: ['!**/node_modules/**', '!**/dist/**', '**/src/**'],
  coverageReporters: [['json', { file: 'report.json' }], 'lcov'],
  erageThreshold: {
    global: {
      statements: 70,
      functions: 80,
      branches: 80,
      lines: 80,
    },
  },
};

需要将项目添加到单元测试项目中时,需要在 jest.config.js 中的 projects 中添加相应的项目路径,同时在项目下添加 jest.config.js 文件,配置内容如下

const { pathsToModuleNameMapper } = require('ts-jest');
const base = require('../../jest.config.base');
const { compilerOptions } = require('./tsconfig.json');
const { name } = require('./package.json');

/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
  ...base,
  moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, {
    prefix: '<rootDir>/',
  }),
  displayName: name,
};

修改 packages/nflask/tsconfig.json 文件,主要有两个部分需要修改

  1. compilerOptions.paths 中添加 "@nflask": ["./src"],实现对 src 目录下文件的引用别名
  2. include 配置中添加 test 目录,使得 Typescript 能够作用于单元测试文件

测试一下

假设有如下函数需要测试

// src/index.ts
export function sum(a: number, b: number) {
  return a + b;
}

__tests__ 目录下新建 sum.ts 文件,并添加如下单元测试代码

import { sum } from '@nflask';

describe('sum module', () => {
  test('adds 1 + 2 to equal 3', () => {
    expect(sum(1, 2)).toBe(3);
  });
});

接着在根目录下的 package.json 中添加执行单元测试脚本 jest

测试 nflask

编写单元测试代码前首先需要构造测试数据,对于 nflask 而言,需要在测试时构造一个 node 服务,并模拟使用装饰器包装的接口信息是否正确返回。这里借助 supertest 来启用一个单元测试使用的 node 服务。

构造 mock 数据

test 目录下新建一个 **mock** 文件夹,存放测试需要模拟的 API 项目代码。这里 mock 的文件结构,如下

__mock__/
  controllers/     --
    /v1            -- v1 版本 API
      products.ts
    /v2            -- v2 版本 API
      product.ts
  routes/          -- 路由层,将请求转向对应的 controller
  services/        -- 服务层,业务逻辑
  app.ts

编写单元测试代码

具体的单元测试代码可以查看这里,这里就不展开说了。

Husky 和 lint-staged 配置

为了统一代码风格,代码 lint 工具几乎每个项目都会有配置,除了文件保存自动格式化之外,每次代码提交之前也都应该进行一次 lint,当出现代码不符合规范时,拒绝提交代码。

宗旨就是:封杀一切不合规的代码

Husky 是一个设置 Git hooks 的开源工具。代码提交时,进行 lint 操作,最优的情况一定是只对 Git 暂存区的代码进行 lint 操作,lint-stage 就被用来实现这样的需求。

Husky 和 lint-staged 由于整个项目都需要使用的工具,因此安装依赖时需要使用 -w

pnpm add -D husky lint-staged -w

配置 Husky

安装完成之后,执行 pnpm husky install 对 Husky 进行初始化配置。初始化会新建一个 .husky 目录,并执行如下命令

pnpm husky add .husky/pre-commit "pnpm lint-staged"

pre-commit 是一个可执行文件,如果不是可执行文件权限,记得执行 chmod u+x .husky/pre-commit

当其他人克隆了当前项目,执行 pnpm install 时,一定也要初始化 Husky ,所以添加一个 prepare 钩子,让依赖安装之后自动执行 huksy install

配置 lint-staged

lint-staged 的配置支持直接在 package.json 文件中修改,也可以在项目目录下新建 .lintstagedrc 等格式的配置文件。具体定义方法,可以查看官方文档

"lint-staged": {
  "*.{ts,tsx}": [
    "prettier --write",
    "eslint --fix"
  ]
}

Commit Lint

偶然了解了 conventional commits,生成 CHANGELOG 会依据 Git Commit 时填写的信息,例如 feat: add husky and lint-staged 在 CHANGELOG 中就会归类并生产一条新的记录。既然有这么好用的工具,那代码提交时,也就需要对 commit 信息做校验。

依赖安装

pnpm add -D @commitlint/config-conventional @commitlint/cli -w

添加配置文件

echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js

使用 Husky 添加 hook

pnpm husky add .husky/commit-msg 'npx --no -- commitlint --edit ${1}'

CI 执行单元测试

之前关于博客部署到服务器的文章提到了 Github Actions 的配置内容,这里就不在赘述,直接附上单元测试 Job 的配置。

name: Test

on:
  pull_request:
    branches:
      - main

jobs:
  coverage:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Use Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 16
      - uses: pnpm/action-setup@v2
        name: Install pnpm
        id: pnpm-install
        with:
          version: 7
          run_install: false
      - name: Get pnpm store directory
        id: pnpm-cache
        shell: bash
        run: |
          echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
      - uses: actions/cache@v3
        name: Setup pnpm cache
        with:
          path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
          restore-keys: |
            ${{ runner.os }}-pnpm-store-
      - name: Install Dependencies
        run: pnpm install --frozen-lockfile
      - uses: ArtiomTr/jest-coverage-report-action@v2
        with:
          skip-step: install
          test-script: pnpm test
          base-coverage-file: report.json

借助 Jest coverage report 插件,在测试完成之后把测试结果显示在 PR 上。

success

额外的需求

上面的 CI 配置,对所有目标分支为 main 的 PR 做了单元测试,当单元测试没有通过时,希望 Merge Pull Request 的按钮处于无法点击的状态,此时我们就可以通过在仓库 settings 中开启保护分支状态检测选项来实现需求。

setting

当单元测试未通过时,就会像下图一样,Merge Pull Request 被置灰了。

disable

当然按钮上方的复选框选中之后,即使单元测试未通过,也仍然可以合并 PR。要想严格限制,不允许绕过状态检测的话,可以在保护分支的配置中开启 Do not allow bypassing the above settings 选项