分享一些 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 CIGitLab 自带的 CI/CD 功能,直接集成在 GitLab 仓库中。
  • CircleCI轻巧且开放,支持快速的构建、测试和部署。

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

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

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

卡点实现

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

分享一些 CI 自动测试卡点的核心实现逻辑

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

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

commit 描述规范卡点

功能

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

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

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

核心实现

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

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 来说,仅仅检查最新的提交是不够的,最优的情况应该是获取目标分支到合入分支的全部提交,遍历检查:

const logData = await git.log({
  from: targetBranchName,
  to: branchName,
}); 

lint、ts 类型检查卡点

功能

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

核心实现

部分 CI 工具(如 GitLabCI)能直接进行拉取仓库之后的操作。如果用 GitLabCI 操作,可以直接通过 .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 脚本为例:

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

cd ${PATH}

npm i typescript ts-node
npm i

npx ts-node ./index.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 代码好了:

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 行数限制卡点。

> 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 工具手动实现一下,有什么问题可以评论区交流👊!

原文链接:https://juejin.cn/post/7341212581048959003 作者:只有一斤了呐

(0)
上一篇 2024年3月1日 下午4:26
下一篇 2024年3月1日 下午4:36

相关推荐

发表回复

登录后才能评论