深度体验 GitLab CI 自动化测试闭环
这件事折腾了两周多。
一开始我只是想给前端项目补一套自动化测试:Vue 测试有一点,关键页面有一点 E2E,GitLab 能自己跑,失败了去飞书提醒一下。听着像个正常需求,对吧?
结果真干起来才发现,所谓“让 CI 跑一下”,后面藏着一大堆破事:
- E2E 到底测什么,和单元测试有什么区别;
- 测试文件要不要按项目分开;
- 报告应该留在哪,为什么不能只有一堆终端日志;
- GitLab 说配置不合法,到底是我写错了还是它太老;
- 服务器为什么越跑越慢,最后还满了;
- 为什么业务 Job 已经挂了,连飞书通知也能跟着一起挂;
- Wiki 写不进去,流水线到底该不该算成功。
这不是一篇成功学复盘。我甚至觉得,最值得写的不是最后那张绿色流水线图,而是中间那几次“卧槽,原来问题在这”的瞬间。
如果你没接触过 GitLab CI、Runner、Docker、E2E,也不用怕。这篇我尽量用人话讲。
不过在往下看之前,先把文章里的 PM 说清楚:这里的 PM 不是“产品经理”,而是我们平台里的 PM 测试管理系统。它是一个给开发、测试和项目负责人使用的内部入口,具体怎么工作,后面会展开。
一、我一开始连“该测什么”都不知道
前端自动化测试这东西,很容易一上来就被几个词吓到:单元测试、组件测试、E2E、冒烟测试、接口测试、Mock……
其实没必要先把词背得很漂亮。先问一句:这玩意儿到底能帮我们挡住什么坑?
单元测试、组件测试、E2E,到底差在哪
拿一个登录页面举例。
- 单元测试,是盯着一小段逻辑看。比如“江苏用户登录后应该跳哪个地址”“权限判断返回的结果是不是对”。
- 组件测试,是盯着 Vue 页面上的一块东西看。比如点登录按钮有没有触发、表单校验有没有拦住空密码、没有权限的按钮有没有隐藏。
- E2E,是让浏览器真的跑起来。打开页面、输入账号、点击、跳转、看页面有没有出来。它就是在模拟一个人用系统。
E2E 全名叫 End-to-End,翻成人话就是:从用户点下去开始,到他看到结果结束,这条路通不通。
它不能替代单元测试。你不能每次改一个 if 判断,都启动浏览器跑十分钟;反过来也一样,单测一片绿,不代表页面能启动、不代表登录服务能连上、不代表路由没炸。
所以我们最后没有选择“只做其中一个”,而是让它们各管一段:
单元 / 组件测试:盯逻辑和页面细节
E2E 冒烟:盯关键主链路有没有直接死
发布前完整 E2E:盯真实账号、真实环境、真实业务流程2
3
冒烟测试听起来很玄,其实就是先看系统有没有烧起来
“冒烟测试”这名字挺唬人,但意思很朴素。
以前硬件通电后,如果机器一通电就冒烟,你根本没必要继续测功能。软件里也是一样:页面能不能起、登录能不能进、几个核心入口能不能点,这些最基本的东西先走一遍。
它不是全量回归,不是把几十个页面所有按钮挨个点完。它是最短的关键路径。
我们最终让 CI 自动跑的 E2E,就是这一层。发布前如果要跑完整 E2E,再手动触发,避免每次提交都把低配置机器压成狗。
接口测试和 Mock,当时为什么没硬塞进去
这点我一开始就比较明确:后端接口不是我们维护的,就别假装前端 CI 能替整个后端背质量责任。
前端把自己该做的做扎实:组件逻辑、权限分支、登录跳转、浏览器链路、真实测试环境的关键入口。
Mock 也一样。不是说它不能用,而是不能拿它当默认答案。你把所有东西都 mock 了,最后很可能得到一个“假世界里一切正常”的测试结果。真环境一接,照样炸。
二、测试文件不统一,后面肯定会烂
这个仓库不是一个小页面。里面有公共登录、多个业务端、管理端、模板,还有公共包。
如果每个项目自己散着放测试,短期没感觉,后面就全是麻烦:
- 新人不知道测试在哪;
- 一次改动影响多个应用,不知道该跑哪几套;
- 报告结构各不相同;
- 以后项目负责人想做测试管理,根本没法统一展示。
所以先定了一件不算酷、但很重要的事:测试都放到根目录 test/ 下。
test/
login/
js-web/
js-admin/
ah-web/
ah-admin/
template-web/
template-admin/
packages/
e2e/2
3
4
5
6
7
8
9
10
统一放,不是说所有测试混成一锅粥。
本地开发时,我们希望一句命令能跑;但 CI 里必须分开。公共登录挂了,和江苏管理端挂了,处理的人、影响范围、排查方向都不一样。你给我一个叫 verify 的大 Job,红了以后什么都不说,我只会想骂人。
三、PM 测试管理系统,不是附属页面
统一测试目录,只是解决了“测试放在哪里”的问题。接下来还得解决更现实的一件事:谁来运行这些测试,又怎么知道它跑到了哪一步、为什么失败。
我们不是先有一条服务器流水线,再想着“要不要做个 PM 页面看一下”。顺序反过来:先把测试能力接到 PM 测试管理系统里,让人可以主动选测试、看过程、看报告;之后才把同一套能力搬到服务器,变成谁都绕不过去的底线。
原因很简单,也不用装高尚:人就是会偷懒。
赶进度时,“我本地看了一眼没报错”很容易变成默认动作;有的人只跑单测,不跑浏览器;有的人什么都不跑,觉得改得不多应该没事。不能指望每个人在每一次提交前都自觉把该做的验证做完。
所以 PM 和 GitLab CI 不是两套重复系统,而是同一套测试能力的两种使用方式:
| 位置 | 它解决什么 |
|---|---|
| PM 测试管理系统 | 让开发、测试和项目负责人可以主动选择测试,盯着任务过程,看每一份报告 |
| 服务器 GitLab CI | 不管有没有人主动点,代码一进来就自动校验,给流程兜底 |
PM 测试管理系统里的“测试管理”页面,做的不是摆几颗按钮。它是 PM 里的一个测试入口:页面创建异步工作区任务,在仓库根目录执行选中的测试脚本。可以选默认测试、全部单元测试、E2E 冒烟、完整 E2E、覆盖率、受影响项目测试,或者把 CI 整套流程在 PM 所在机器上先跑一遍。
过程大概是这样:
在 PM 里选一个测试
→ 创建工作区任务
→ 根目录执行对应的 pnpm 测试命令
→ 实时日志进任务中心
→ 测试结果写入 test/reports/
→ PM 读取 summary.json
→ 在报告列表里看到这一次测试,再打开 HTML 详情2
3
4
5
6
7

这件事的体验很重要。不是让人去终端敲半天命令、记住一堆参数;而是让他在 PM 测试管理系统里明确地知道:我现在跑的是什么、还在不在跑、为什么失败、这份报告属于哪一次。
报告列表按时间展示,能按标题、状态、日期、报告编号去找;点进去就是 index.html,需要时再单独打开完整报告。这样做以后,测试才第一次从“少数开发会用的命令”变成了 PM 系统里能被正常使用的能力。

这里有个边界不能混:PM 展示的是 PM 服务所在机器 / 工作区刚刚跑出来的本地报告,它直接扫描 test/reports/ 并读取 summary.json;它不会自动把 GitLab Runner 的制品下载回来冒充本地报告。
也就是说,PM 版本和服务器版本跑的是同一套测试脚本、同一份报告结构,但它们面对的是两个不同场景:前者让人主动验证和快速排查,后者让服务器在提交后强制兜底。不能因为 PM 里跑过一次,就误以为 GitLab 已经替你验证过;也不能因为 GitLab 绿了,就把 PM 的任务过程和本地报告当成天然同步。
但 PM 只能提供“我愿意去跑”的入口,不能提供“所有人都不会忘”的保证。
这就是后来一定要上服务器 CI 的动机:不是为了炫 GitLab,也不是为了多一条绿线。是因为总得有个东西在最后拦一下。有人没点 PM、有人赶时间、有人觉得改动太小不用测,服务器照样会把该跑的基础校验、分类测试、构建和冒烟跑掉。
说得难听一点:PM 是把测试做得更好用;CI 是防止测试被跳过。两边缺一个都不够。
四、然后我们才把它搬到服务器:GitLab CI 不是替代 PM,是最后的兜底
第一次接 GitLab CI 的时候,我以为是“写一个 YAML,跑几个命令”。
后来才发现,GitLab CI 里有三个角色,不分清真的会懵。
GitLab:负责接收提交、排任务、展示流水线
Runner:真的执行命令的机器或程序
Docker:给 Runner 准备一份临时、统一的运行环境2
3
举个例子:
image: node:22-bullseye
script:
- pnpm test2
3
它不是 GitLab 自己突然有了 Node 22。是 Runner 拉了一个带 Node 22 的 Docker 镜像,然后在里面执行 pnpm test。
这就是为什么 Docker 在 CI 里这么常见:不用赌宿主机今天装了什么、明天被谁改了什么。
但是,理论讲完,现实马上给我一拳。
当前 GitLab 服务端比较老(具体版本以你们环境为准)。一些新版本文档里很顺手的写法,放进去就是不认:YAML 校验不通过、Job 定义不兼容、配置行为不符合预期。
我一开始还想把 CI 拆成多个文件,再用 include 组合,用 needs 组织依赖。看上去很优雅。
但它不支持。
那就别自我感动了。最后回到最土、也最稳的方案:一个根 .gitlab-ci.yml,所有 Job 直接写进去,用 stage 保证顺序。
基础质量
→ 公共登录
→ 江苏客户端
→ 江苏管理端
→ 安徽客户端
→ 安徽管理端
→ Web 模板
→ 管理端模板
→ E2E 冒烟
→ 报告发布
→ 飞书通知2
3
4
5
6
7
8
9
10
11
文件长了,但至少机器认得,人在 GitLab 页面上也看得懂。
这件事之后我记住一句话:
你写的 CI 不是写给最新文档看的,是写给你眼前这台老 GitLab 和这群 Runner 看的。
五、跑完测试以后,我想问自己:这他妈的到底测了什么?
这句问得一点问题没有。
最初报告做出来时,确实太像交差:一个结果,一个页面,告诉你“成功”或“失败”,然后没了。
可真实使用时,别人想知道的是:
- 什么时候跑的?
- 跑的是单测还是 E2E?
- 对应哪个提交?
- 测试了几个项目?
- 总共多少用例?
- 哪个步骤失败?
- 覆盖率在哪?
- 浏览器测试失败的截图、视频和 Trace 去哪看?
这些答不上来,报告再漂亮也没用。
于是测试报告被重新设计成每次独立存一份:
test/reports/2026-08-19_180000_e2e-smoke_abcd1234/里面不只放一张 HTML:
index.html # 给人直接看的汇总页
summary.json # 给 PM、Wiki、脚本读取的结构化数据
logs/ # 每一个步骤的原始日志
vitest-junit.xml # Vitest 结果
playwright-junit.xml # Playwright 结果
playwright/ # Playwright HTML 报告
artifacts/ # 失败截图、视频、Trace
coverage/ # 覆盖率2
3
4
5
6
7
8
报告目录不提交 Git。它是执行证据,不是源代码。
后来报告分成三处去放:
| 地方 | 它负责什么 |
|---|---|
本地 test/reports/ | 开发自己复现、PM 测试管理页面查看 |
| GitLab Job 制品 | 原始日志、JUnit、截图、视频、Trace、覆盖率 |
| GitLab Wiki | 团队看最近结果、趋势和摘要 |
别小看这个拆分。
只留本地,别人看不到;只留 GitLab 制品,每次都要钻 Job 翻半天;只留 Wiki,又会把截图、日志、Trace 这些敏感又大块的东西堆进去。
三层不是重复,是各自干自己的活。
六、然后问题从代码变成了机器:服务器开始撑不住了
测试流程一开始跑,最先折磨人的不一定是断言失败,而是环境。
依赖下载慢、镜像拉取慢、缓存不生效、流水线取消、Docker 空间越来越紧……这些听上去都像“运维的小问题”,但它们能直接让 CI 结果失去可信度。
尤其是服务器资源开始报警的时候,不能一句“内存满了”就结束。
CI 会吃资源的地方太多:
- Docker 镜像层;
- Docker 工作目录;
- pnpm 缓存;
- Node 进程;
- 浏览器测试;
- 依赖安装;
- 大体积图表、图分析、3D 相关构建;
- 磁盘空间和 inode。
这个时候有一个非常重要的判断:
代码失败,和基础设施失败,不是一回事。
比如断言错了、模块导入失败、构建语法报错,这些是代码或测试的问题。
但 runner_system_failure、Docker executor 出错、镜像拉不下来、no space left on device,这类就别往开发同学头上扣“测试失败”的帽子。
后来飞书通知里也专门做了分类。因为如果机器磁盘爆了,你却发一条“江苏客户端测试失败”,那就是在带着所有人往错方向跑。
七、如果你也想自己架 Docker Runner,先别把 Docker 想的太可怕
很多人第一次听 Docker,会觉得它像一门单独的语言。
其实可以先把它当成:在一台 Linux 机器上,开一间一次性的标准化小房间来跑程序。
CI 需要 Node 22,就用 Node 22 镜像;需要浏览器测试,就用 Playwright 镜像。Job 结束,这个临时环境也就没了。
这比“所有东西都装在服务器上,然后祈祷别冲突”靠谱多了。
如果你有一台较新的 Ubuntu 服务器,Docker 的安装流程大概是这样。下面的 .sources 写法适用于支持新版 APT 的系统;老版本系统要按对应版本的官方文档调整。不要盲目跑网上一行 curl | sh 的脚本,最好走官方软件源:
# 准备 Docker 官方软件源
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources >/dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
# 安装并验证
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
sudo docker run hello-world2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
最后那条 hello-world 能跑,说明 Docker 基础环境至少没问题。
但别因为嫌麻烦,就把所有日常账号都丢进 docker 用户组。这个组的权限很高,CI 最好用专门机器、专门账号、最小权限。别拿生产机当实验田。
八、最离谱的一次:业务 Job 挂了,飞书通知也跟着挂了
这大概是整个过程里最让人无语的一段。
当时 Runner 比较老,实际日志里 Node 启动时创建后台线程失败。业务 Job 已经红了,理论上下一步应该赶紧发飞书告诉大家。
结果呢?
通知 Job 原来也是 Node 脚本,于是它也一起红了。
发个消息而已,居然还要经过:拉镜像、启动 Node、装依赖、跑脚本。任何一环出问题,消息就没了。
这就不对。
我重新问自己:为什么发飞书一定要用 Node?
答案是,不一定。
后面通知链路被单独拆出来:
独立 Shell Runner(标签 ci-notify)
→ POSIX sh
→ OpenSSL
→ 直接发 HTTPS 请求
→ 飞书群机器人2
3
4
5
业务测试、构建、E2E 继续走 Docker Runner;飞书通知走单独故障域里的 Shell Runner。这个“单独”很重要:如果两个 Runner 仍在同一台磁盘已满或网络中断的机器上,通知一样可能发不出去。
不是把所有 CI 搬回本地,而是把那条“业务失败时仍然必须活着”的通知线单独拎出来。
这一下,业务 Docker Runner 就算因为镜像、磁盘、网络、资源问题不工作,通知至少还有另一条路。

Runner 到底怎么装、怎么注册?
Runner 不是 GitLab 里点一下就从天上掉下来的。你需要准备一台 Linux 机器,装 GitLab Runner,然后把它注册到项目、群组或实例里。
Ubuntu / Debian 上常见的安装过程是:
# 下载软件源配置脚本后,先看一眼,不要闭眼执行
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" -o script.deb.sh
less script.deb.sh
sudo bash script.deb.sh
sudo apt update
sudo apt install -y gitlab-runner2
3
4
5
6
7
然后去 GitLab 项目的 Runner 设置页创建 Runner,拿到认证 Token,再执行:
sudo gitlab-runner register它会一步步问你 GitLab 地址、Token、Runner 名称、标签和执行器。
这次我们最后分了两类 Runner:
| Runner | 执行器 | 负责什么 |
|---|---|---|
| 业务 Runner | Docker executor | Node 构建、Vitest、Playwright E2E |
| 通知 Runner | Shell executor | 只发飞书通知 |
通知 Runner 只打一个 ci-notify 标签。CI 里也明确写它:
'校验|通知|失败通知':
stage: 通知
tags: [ci-notify]
before_script: []
dependencies: []
script:
- 'sh scripts/notify-feishu.sh failed'
when: on_failure2
3
4
5
6
7
8
before_script: [] 和 dependencies: [] 的意思很简单:别给通知 Job 装 pnpm,别让它下载上游制品,别再往这条轻量链路上绑垃圾。
对于仍然需要 Node 的业务 Job,则限制了后台线程:
NODE_OPTIONS: '--v8-pool-size=1'它不是性能魔法,只是在低资源机器上让 Node 少一点启动时的线程压力。先活下来,再谈性能。
九、飞书机器人接入,其实不是“给机器人一个账号”
飞书机器人本质上就是一个群 Webhook。
CI 成功或失败时,脚本往那个 Webhook 发一个 HTTPS 请求,飞书群里就能收到卡片。
配置过程并不复杂:
- 在飞书群里添加自定义机器人;
- 生成 Webhook 地址;
- 如果启用了签名,再保存签名密钥;
- 去 GitLab 项目的 CI/CD Variables 里配置;
- 必须设成遮蔽变量,不要写进仓库,也不要打印到日志;是否设为受保护变量,要看通知 Job 是否需要在非保护分支运行。
变量名大概长这样:
FEISHU_WEBHOOK_URL
FEISHU_WEBHOOK_SECRET
FEISHU_PROJECT_NAME
WIKI_ACCESS_TOKEN
FEISHU_GITLAB_API_TOKEN2
3
4
5
飞书通知后来不只说“失败了”。它会尽量说清:哪个分类、哪个 Job、卡在哪个步骤、关键报错是什么、GitLab Job 和测试报告在哪。
如果是 Runner 系统问题、Docker 问题、镜像问题、磁盘问题,也会标出来。别让大家一看到红色就误以为是业务代码坏了。
成功通知也会有模块结果、用例数、耗时、覆盖率和 Wiki 入口。内容太长时就只保留结果和链接——群里最怕的不是消息少,是来一堵没人想读的墙。

十、Wiki 写不进去的时候,我才意识到报告不是“顺手发一下”
测试报告发布到 Wiki,听上去就是 clone、写 Markdown、commit、push,没什么特别的。
真正做的时候,权限、Token、Wiki 有没有初始化、提交身份显示谁、旧报告怎么清理,全都得处理。
我一开始也有过一个想法:Wiki 写失败了就跳过,别影响测试主链路。
但仔细一想,这就是给自己找借口。
我们前面折腾半天,不就是为了让测试结果不要只埋在 Job 日志里吗?结果测试都过了,Wiki 还是上周的数据,团队打开一看全是旧东西,那这条流水线绿得有什么意义?
所以后面规则改成了:
- 优先使用
WIKI_ACCESS_TOKEN; - 没配时再尝试
CI_JOB_TOKEN(能否推送 Wiki 取决于 GitLab 版本、项目设置和 Token 权限); - Wiki 仓库不可用、Token 没权限,就明确失败;
- 只保留最近 5 次测试报告;
- 旧详情页清理;
- Wiki 提交尽量显示真实触发人,而不是一个奇怪的机器人名字。
Wiki 里最终留的是团队会看的东西:总耗时、每个步骤耗时、各项目测试数、通过 / 失败 / 跳过、覆盖率、E2E 明细、最近 5 次趋势。
完整日志、截图、视频、Trace 还是去 GitLab 制品里找。Wiki 不是硬盘。
十一、低配机器跑不动完整构建,不能靠装没看见解决
后面还有一类问题:构建太吃资源。
图表、图分析、3D 这类依赖一上来,低配 Runner 很容易扛不住。这个时候最危险的做法,是为了让 CI 绿掉,偷偷把真正会用到的东西不构建了,或者拿残缺的构建产物去部署。
这肯定不行。
所以后来加了 build:ci:它还是会编译五个应用的源码和路由,但在低资源 CI 校验里外部化特别吃内存的可视化依赖,产物放在 dist-ci。
它能证明源码、路由和大部分构建链路没有立即报错,但被外部化的可视化依赖没有经过完整打包验证。所以它不是“完整构建的替代品”,只是低资源机器上的快速校验。
关键是:
dist-ci只能证明低资源构建校验能过,不能用来部署。
真正的测试环境发布仍然走完整 build:test,并且要在发布前或定时任务中持续执行。资源约束可以影响校验方式,不能偷偷降低发布标准。
十二、最后终于不是“跑完了”,而是“看得懂了”
折腾到最后,GitLab、Wiki 和飞书开始说同一种话。
flowchart LR A["开发在 PM 选择测试"] --> B["PM 工作区任务与实时日志"] B --> C["统一测试脚本"] C --> F["PM 工作区的 test/reports/ 独立报告"] F --> G["PM 报告列表与 HTML 详情"] D["提交代码"] --> E["GitLab CI 分类校验"] E --> H["GitLab Job 制品"] E --> I["GitLab Wiki 摘要与趋势"] E --> J["飞书成功 / 失败通知"] E -.-> C

现在报告会告诉我们:
- 什么时候跑的;
- 跑了哪些项目;
- 哪个提交;
- 用例通过、失败、跳过多少;
- 每个步骤用了多久;
- 各项目覆盖率怎样;
- E2E 测了什么;
- 失败时该去哪个 Job、哪个报告目录看。
飞书如果没法读取 GitLab Job 或日志详情,也会直接说明是权限问题,而不是假装一切正常。
历史流水线还加了定时清理:保留最近 100 条已经完成的流水线。正在跑、等待人工操作、当前清理任务本身不删。流水线记录和制品是一层,Runner 的 pnpm 缓存、Docker 镜像和工作目录是另一层,后者还需要单独设置清理策略。
不然服务器迟早又会被缓存、制品、镜像和老记录拖得喘不过气。
十三、这两周最后留下的,不是一份 YAML
最后留下来的,是一条能顺着查下去的路。
开发时,先在 PM 里主动选测试、盯任务、看报告;代码进入仓库后,GitLab 再把关键校验强制跑一遍。跑完有独立报告;需要排障看制品;团队看 Wiki;出错飞书会提醒;机器出问题也会尽量说明是机器,不会乱把锅甩给代码;通知不会再和业务 Job 一起陪葬。
这事最开始看着只是“接个自动化测试”。干到后面才发现,真正麻烦的从来不是写一条 pnpm test。
而是你得让测试、报告、Runner、Docker、网络、磁盘、Token、Wiki、飞书,都别在关键时候突然装死。
现在还不能吹“万无一失”。真实账号、真实环境、发布后的人工验收,该做还得做。
但至少以后再看到一条红色流水线,不会只剩下一句毫无用处的“失败”。
我们终于知道该从哪儿开始骂,啊不,开始查了。
