背景
最近专注于开发团队的 CI
自动测试卡点,保障 MR
(Merge Request)阶段的代码质量。今天就来分享一些脱敏后的卡点核心实现逻辑,帮助大家进一步了解如何开发 CI
的自动测试卡点。
注意!文章不会涉及具体的 CI
建设过程,只会分享卡点的代码实现方案,本文预期介绍以下卡点:
- commit 描述规范卡点
- lint、ts 类型检查卡点
- commit 行数限制卡点
在文章开始前,先说说通过文章能学到什么:
- 了解 CI 基本概念
- 学习相关自动测试卡点的核心实现方案
前置知识 —— CI
在介绍卡点之前,我们先补充一些 CI
的基本知识。CI
(持续集成)是一种软件开发实践,目的是让开发团队能够快速、可靠地构建和测试代码。与之相提并论的是 CD
(持续部署),一种自动化软件交付过程,使得代码在通过所有测试后能够自动部署到生产环境,确保软件的快速、安全发布。
在 CI
的实践中,开发人员频繁地将代码提交到共享仓库,每次提交都会自动触发构建和测试流程,以确保新代码的加入不会影响产品的稳定性,从而提升软件的质量和交付效率。
显而易见的,一个拥有优秀 CI/CD
建设的团队,在时间利用、代码质量与安全等方面都会有很大的优势。那么在了解基本定义之后,我们再聚焦于 CI
进行更详细的讲解。
CI的关键组件和步骤
- 源代码存储库:所有项目代码存储的中心地点,通常是
Git
(如 GitHub、GitLab……)。 - 自动构建系统:每当代码被推送到源代码存储库时,自动触发构建过程。构建过程可以包括编译代码、打包软件等步骤。
- 自动测试:构建过程中自动运行一系列测试,包括单元测试、集成测试和代码覆盖率测试,以验证代码的功能和性能。
- 反馈循环:如果构建或测试失败,系统会立即通知开发团队,以便他们可以尽快解决问题。
当然不是所有的步骤都是必须的,团队可以依据自己的需求自由的配置 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)。
<类型>:[<单号>] <描述>
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.'));
}
};
// 执行相关任务
在脚本中,提供了几个能力:
- 判断新增或删除代码是否超过最大限制行数
- diff 时传入忽略列表,防止不必要的文件被读取(如 pnpm-lock)
- 超出限额时,提供解决方案(申请逃逸、拆分 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 作者:只有一斤了呐