还不会做自动测试吗❓分享一些 CI 自动测试卡点的实现逻辑

背景

最近专注于开发团队的 CI 自动测试卡点,保障 MR(Merge Request)阶段的代码质量。今天就来分享一些脱敏后的卡点核心实现逻辑 ,帮助大家进一步了解如何开发 CI 的自动测试卡点。

注意!文章不会涉及具体的 CI 建设过程,只会分享卡点的代码实现方案,本文预期介绍以下卡点:

  1. commit 描述规范卡点
  2. lint、ts 类型检查卡点
  3. commit 行数限制卡点

在文章开始前,先说说通过文章能学到什么:

  1. 了解 CI 基本概念
  2. 学习相关自动测试卡点的核心实现方案

前置知识 ------ CI

在介绍卡点之前,我们先补充一些 CI 的基本知识。CI(持续集成 )是一种软件开发实践,目的是让开发团队能够快速、可靠地构建和测试代码 。与之相提并论的是 CD(持续部署),一种自动化软件交付过程,使得代码在通过所有测试后能够自动部署到生产环境,确保软件的快速、安全发布。

在 CI 的实践中,开发人员频繁地将代码提交到共享仓库,每次提交都会自动触发构建和测试流程,以确保新代码的加入不会影响产品的稳定性,从而提升软件的质量和交付效率。

显而易见的,一个拥有优秀 CI/CD 建设的团队,在时间利用、代码质量与安全等方面都会有很大的优势。那么在了解基本定义之后,我们再聚焦于 CI 进行更详细的讲解。

CI的关键组件和步骤

  1. 源代码存储库 :所有项目代码存储的中心地点,通常是 Git(如 GitHub、GitLab......)。
  2. 自动构建系统:每当代码被推送到源代码存储库时,自动触发构建过程。构建过程可以包括编译代码、打包软件等步骤。
  3. 自动测试:构建过程中自动运行一系列测试,包括单元测试、集成测试和代码覆盖率测试,以验证代码的功能和性能。
  4. 反馈循环:如果构建或测试失败,系统会立即通知开发团队,以便他们可以尽快解决问题。

当然不是所有的步骤都是必须的,团队可以依据自己的需求自由的配置 CI 的全流程。接下来我们对 CI 的自动检测进一步讲解。

CI的自动检测

自动检测 是 CI 的核心之一,大多数 CI 工具都提供了触发机制 ,通常是通过 Git hooks 触发的。当开发者向代码仓库提交新代码或者合并分支 时,会自动触发 CI 流程。

Git hooks 的配置是非常灵活的,具体不在本文介绍,感兴趣的小伙伴可以查阅官方文档或者查阅一些社区的文章:
官方文档
# 详解如何在项目中使用git Hooks(husky、yorkie)

流行的CI工具

  • Jenkins :一个开源的 CI/CD 工具,可以用来自动化各种任务,包括构建、测试和部署。
  • Travis CI :一个托管的 CI 服务,专门为 GitHub提供。
  • GitLab CI :GitLab 自带的 CI/CD 功能,直接集成在 GitLab 仓库中。
  • CircleCI :轻巧且开放,支持快速的构建、测试和部署。

对于如何选择工具,还需要基于团队的代码管理平台、项目规模等综合因素进行选择。

至此,对于 CI 的前置介绍已经结束,相信大家已经对 CI 有了一定的认知,那么接下来我们会着重于自动测试模块,介绍相关卡点的实现逻辑。

注:不同的 CI 工具有不同的实现方式,这边仅仅对自动测试卡点的实现方案 进行介绍,不会基于不同的工具说明自动测试的接入、调用方式。

卡点实现

为了仅通过一个场景覆盖接下来要介绍的所有卡点,我们约定自动测试通过提交 MR 触发。大家可以先通过下面的流程图了解一下自动测试整体流程:

其中,每一个测试卡点都可自由拼接,通常我们将整个测试流程称为 pipeline,大部分的 CI 工具都开放了灵活的 pipeline 配置能力,可以满足绝大部分测试需求。

接下来我们对具体的测试卡点进行介绍。

commit 描述规范卡点

功能

对 commit 的提交信息进行检测,确保提交信息符合团队规范。举个🌰,提交的信息要符合以下规范:

每个提交信息应该包含三个部分:类型(Type)、单号(Scope)和描述(Description)。

php 复制代码
<类型>:[<单号>] <描述>
feat: [10273490127] 添加了登录功能
fix: [1123213] 修复了用户认证失败的问题
refactor: [12312457879] 重构了工具类,提高代码复用

核心实现

核心实现非常简单,只需要获取 commit 提交信息,对字符串进行校验,这边依据不同的 CI 工具,有不同的获取方式,我们以最差的情况为准(只能拿到仓库和分支信息),我们就需要借助 simple-git 库获取相关信息。

ts 复制代码
const simpleGit = require('simple-git');

// 初始化 simple-git
const git = simpleGit();

// 通过环境变量获取信息
const repoUrl = process.env.REPO_URL
const branchName = process.env.BRANCH_NAME;

async function getLatestCommit() {
  try {
    // 克隆仓库
    await git.clone(repoUrl, branchName);
    // 切换到指定分支
    await git.checkout(branchName);

    // 获取最新的提交信息
    const log = await git.log();
    const latestCommit = log.latest;

    // 正则表达式,匹配<类型>:[<单号>] <描述> 可根据需求自行设置
    const commitMessageRegex = /^(feat|fix|refactor):\s*\[\d+\]\s+.+$/;
    // 检查提交信息是否符合规范
    const isValid = commitMessageRegex.test(latestCommit.message);

    // 输出检测结果 ......
  } catch (error) {
    console.error('Error getting the latest commit:', error);
    // 中断 pipeline
    process.exit(1); 
  }
}

// 调用函数
getLatestCommit();

当然,对于 MR 来说,仅仅检查最新的提交是不够的,最优的情况应该是获取目标分支到合入分支的全部提交,遍历检查:

ts 复制代码
const logData = await git.log({
  from: targetBranchName,
  to: branchName,
}); 

lint、ts 类型检查卡点

功能

对目标仓库的代码进行 lint、ts 类型检查,有效保证代码质量。

核心实现

部分 CI 工具(如 GitLabCI)能直接进行拉取仓库之后的操作。如果用 GitLabCI 操作,可以直接通过 .yml 文件实现:

yml 复制代码
stages:
  - test

lint:
  stage: test
  script:
    - npm install
    - npm run lint

typescript-check:
  stage: test
  script:
    - npm install
    - npm run typescript-check

但我们依旧以最差的情况实现,核心的思路就是:拉取仓库 > 下载依赖 > lint、ts类型检查 。 值得注意的是,除去 .yml 触发,一般建议使用 shell 脚本或 Batch 命令来执行调用,在脚本或命令中,我们可以配置相关的 node 环境、输出一些基本信息,然后调用 js/ts 文件,使功能更灵活的实现,我们拿执行 shell 脚本为例:

sh 复制代码
# 获取相对路径,方便执行同目录下的 ts 文件
export PATH=$(
    cd $(dirname $0)
    pwd
)

cd ${PATH}

npm i typescript ts-node
npm i

npx ts-node ./index.ts
ts 复制代码
import simpleGit from 'simple-git';
import { execSync } from 'child_process';
import chalk from 'chalk';

const git = simpleGit();

// 克隆目标仓库
async function cloneRepo() {
    let repoURL, branchName, repoName, localPath
    // 判断是否为线上环境,注入参数
    if (process.env.WORKFLOW_REPO_URL) {
        repoURL = process.env.WORKFLOW_REPO_URL || ''; // 仓库URL
        branchName = process.env.WORKFLOW_REPO_BRANCH || ''; // 分支名
        repoName = process.env.WORKFLOW_REPO_NAME || ''; // 仓库名
        localPath = './temp-repo'; // 临时目录来存储拉取的代码
    } else {
        repoURL = 'xxxx'; // 仓库URL
        branchName = 'xxxx'; // 分支名
        repoName = 'xxxxx'; // 仓库名
        localPath = './temp-repo'; // 临时目录来存储拉取的代码
    }

    // 拉取仓库代码
    try {
        console.log(`🚀 Cloning ${chalk.blueBright(branchName)} from ${chalk.blueBright(repoName)} into ${chalk.blueBright(localPath)}...`);
        await git.clone(repoURL, localPath, ['--branch', branchName]);
        console.log(`✅ Successfully cloned ${chalk.green(branchName)} into ${chalk.green(localPath)}!`);
    } catch (error) {
        console.error(chalk.red('❌ Error cloning repo:'), error);
        process.exit(1); // 使用非0退出码来表示出错
    }

    // 进入克隆的仓库目录
    process.chdir(localPath);
}

// 安装仓库依赖
async function installDependencies() {
    try {
        console.log(chalk.yellow('🚀 Install Dependencies...'));
        execSync(`pnpm i`)
    } catch (error) {
        console.error(chalk.red('❌ Error installing:'), error);
        process.exit(1);
    }
}

// 执行 lint 检查
async function lintCheck() {
    try {
        console.log(chalk.yellow('🔍 Running eslint...'));
        execSync(`pnpm lint`, { stdio: 'inherit' });
    } catch (error) {
        console.error(chalk.red('❌ Error running eslint:'), error);
        process.exit(1);
    }
}

// 执行 ts 类型检查
async function tsCheck() {
    let tsConfigPath = './xxx/tsconfig.json';

    try {
        console.log(chalk.yellow('🔍 Running TypeScript Type Check...'));
        execSync(`npx tsc --build ${tsConfigPath}`, { stdio: 'inherit' });
    } catch (error) {
        console.error(chalk.red('❌ Error running tsc:'), error);
        process.exit(1);
    }
}

// 执行相关任务

在 ts 文件中,可以通过任何方式来优化输出,使 CI 测试流程更为清晰。可以看到我将一个流程拆成了多个原子任务,后续可以对每个任务进行计时,最后将时间信息打包输出。

可以发现在任务拆分之后,逻辑变得十分清晰易懂。

commit 行数限制卡点

功能

限制 MR 的代码更改行数,使每个 MR 都专注于解决一个特定的问题或实现一个特定的功能,同时降低单次 code review 的成本。

实现

核心的实现也是通过 simple-git 实现:
拉取 MR 的两个分支 > 获取分支信息,进行 diff > 判断 diff 结果并输出

同样通过 shell 脚本调用,具体调用方式过于相似,就直接贴出 ts 代码好了:

ts 复制代码
import simpleGit from 'simple-git';
import { execSync } from 'child_process';
import fs from 'fs';
import chalk from 'chalk';

const git = simpleGit();

// 最大更改行数限制
const MAX_LINES = 500

// 定义忽略列表(从环境变量获取)
const ignoreList = JSON.parse(process.env.COMMIT_DIFF_IGNORE_LIST as string) || ['pnpm-lock.yaml']

interface Repo {
  repoURL: string; // 仓库URL
  branchName: string; // 分支名
  repoName: string; // 仓库名
  localPath: string; // 临时目录来存储拉取的代码
}

async function cloneRepo(repo: Repo) {
  if (fs.existsSync(repo.localPath)) {
    try {
      execSync(`rm -rf ${repo.localPath}`);
      console.log('✅ Directory deleted successfully');
    } catch (error) {
      console.error(chalk.red('An error occurred:'), error);
    }
  }
  console.log(`🚀 Cloning ${repo.branchName} from ${repo.repoName} into ${repo.localPath}...`);
  await git.clone(repo.repoURL, repo.localPath, ['--branch', repo.branchName]);
  console.log(chalk.green(`Successfully cloned ${repo.branchName} into ${repo.localPath}!\n`));
}


// 检查提交的修改行数
const checkCommitDiff = async () => {
  // 定义仓库信息
  let sourceRepo: Repo, targetRepo: Repo

  // 判断是否为线上环境,注入参数
  if (process.env.WORKFLOW_REPO_URL) {
    sourceRepo = {
      repoURL: process.env.WORKFLOW_REPO_URL || '',
      branchName: process.env.WORKFLOW_REPO_BRANCH || '',
      repoName: process.env.WORKFLOW_REPO_NAME || '',
      localPath: './source-repo'
    }

    targetRepo = {
      repoURL: process.env.WORKFLOW_REPO_URL || '',
      branchName: process.env.TEMPLATE_MERGE_TARGET_BRANCH || process.env.WORKFLOW_REPO_TARGET_BRANCH || '',
      repoName: process.env.WORKFLOW_REPO_NAME || '',
      localPath: './target-repo'
    }

    if (targetRepo.branchName === '' || sourceRepo.branchName === '') {
      console.log('Empty repository, skipping~')
      return
    }
  } else {
    // 线下测试数据......
  }


  // 克隆源仓库和目标仓库
  await cloneRepo(sourceRepo);
  await cloneRepo(targetRepo);

  // 使用simple-git比较两个分支的差异
  const sourceGit = simpleGit(sourceRepo.localPath);
  const targetGit = simpleGit(targetRepo.localPath);

  // 获取源分支最新commit
  const sourceLog = await sourceGit.log();
  const sourceLatestCommit = sourceLog.latest?.hash;

  // 切换到目标分支目录,比较差异
  await targetGit.checkout('.');

  // 初始化 diffArgs 数组
  let diffArgs = [sourceLatestCommit as string];

  console.log(chalk.yellow('Ignore list:'));
  // 添加忽略标记
  ignoreList.forEach((path: string) => {
    console.log(chalk.yellow(`- ${path}`));
    diffArgs = [...diffArgs, `:(exclude)${path}`];
  });

  const diffSummary = await targetGit.diffSummary(diffArgs);

  console.log(chalk.yellow('\n----- Diff Summary -----\n'));
  console.log(chalk.yellow(`Total files changed: ${diffSummary.files.length}`));
  console.log(chalk.yellow(`Insertions: ${diffSummary.insertions} 🟢`));
  console.log(chalk.yellow(`Deletions: ${diffSummary.deletions} 🔴`));
  console.log(chalk.yellow(`Max lines: ${MAX_LINES}`));
  console.log(chalk.yellow('\n------------------------\n'));

  if (diffSummary.insertions >= MAX_LINES || diffSummary.deletions >= MAX_LINES) {
    console.error(chalk.red(`Error: Change exceeds ${MAX_LINES} lines`));
    console.log('🌟🌟🌟 how to resolve: xxxxxxxxxxxxxxxxx')
    process.exit(1);
  } else {
    console.log(chalk.green('✅ Change is within limits.'));
  }
};

// 执行相关任务

在脚本中,提供了几个能力:

  1. 判断新增或删除代码是否超过最大限制行数
  2. diff 时传入忽略列表,防止不必要的文件被读取(如 pnpm-lock)
  3. 超出限额时,提供解决方案(申请逃逸、拆分 MR)

至此相关的卡点实现已经介绍完毕,其实自动测试还有很多的完善方向,比如:

  • 包体积限制卡:对首屏 chunks 体积进行限制,防止明显的性能劣化。
  • 保证环境一致性 :使用容器化技术(如 Docker)确保环境一致性,或通过脚本和自动化工具来构建和配置测试环境。
  • 缺乏足够的监控和日志:集成日志和监控工具,保留足够的构建和测试日志,方便问题追踪和分析。
  • 缓存策略:对于下载依赖、仓库等行为,如果提供缓存能一定程度上缩减自动测试的时间。
  • ......

同时需要注意的是,不能忘记提供一个完整的逃逸机制,面对紧急需求或特殊需求时,能够通过逃逸机制快速通过自动测试环节。

demo 演示

由于不方便展示线上的输出结果,demo 的演示需要在线下手动执行,这边我们演示一下 commit 行数限制卡点。

text 复制代码
> bash ./pipelines/commit-check/commit-check.sh

=== START: MR Commit Check ===
🚀 Cloning a from repoName into ./source-repo...
Successfully cloned a into ./source-repo!

🚀 Cloning b from repoName into ./target-repo...
Successfully cloned b into ./target-repo!

Ignore list:
- pnpm-lock.yaml

----- Diff Summary -----

Total files changed: 377
Insertions: 5215 🟢
Deletions: 9791 🔴
Max lines: 500

------------------------

Error: Change exceeds 500 lines
🌟🌟🌟 how to resolve: https://xxx
ELIFECYCLE  Command failed with exit code 1.

最后

实践出真知,大家不妨借助现成的 CI 工具手动实现一下,有什么问题可以评论区交流👊!

相关推荐
卸任3 小时前
Vibe coding工具使用心得
前端·ai编程
brien94 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance4 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M4 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写4 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李4 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易4 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马5 小时前
前沿视界
前端
kyriewen5 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易5 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员