Skip to content
扫码开始移动端阅读

深度体验 GitLab CI 自动化测试闭环

6508
32.54分钟

这件事折腾了两周多。

一开始我只是想给前端项目补一套自动化测试: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 判断,都启动浏览器跑十分钟;反过来也一样,单测一片绿,不代表页面能启动、不代表登录服务能连上、不代表路由没炸。

所以我们最后没有选择“只做其中一个”,而是让它们各管一段:

text
单元 / 组件测试:盯逻辑和页面细节
E2E 冒烟:盯关键主链路有没有直接死
发布前完整 E2E:盯真实账号、真实环境、真实业务流程

冒烟测试听起来很玄,其实就是先看系统有没有烧起来

“冒烟测试”这名字挺唬人,但意思很朴素。

以前硬件通电后,如果机器一通电就冒烟,你根本没必要继续测功能。软件里也是一样:页面能不能起、登录能不能进、几个核心入口能不能点,这些最基本的东西先走一遍。

它不是全量回归,不是把几十个页面所有按钮挨个点完。它是最短的关键路径。

我们最终让 CI 自动跑的 E2E,就是这一层。发布前如果要跑完整 E2E,再手动触发,避免每次提交都把低配置机器压成狗。

接口测试和 Mock,当时为什么没硬塞进去

这点我一开始就比较明确:后端接口不是我们维护的,就别假装前端 CI 能替整个后端背质量责任。

前端把自己该做的做扎实:组件逻辑、权限分支、登录跳转、浏览器链路、真实测试环境的关键入口。

Mock 也一样。不是说它不能用,而是不能拿它当默认答案。你把所有东西都 mock 了,最后很可能得到一个“假世界里一切正常”的测试结果。真环境一接,照样炸。


二、测试文件不统一,后面肯定会烂

这个仓库不是一个小页面。里面有公共登录、多个业务端、管理端、模板,还有公共包。

如果每个项目自己散着放测试,短期没感觉,后面就全是麻烦:

  • 新人不知道测试在哪;
  • 一次改动影响多个应用,不知道该跑哪几套;
  • 报告结构各不相同;
  • 以后项目负责人想做测试管理,根本没法统一展示。

所以先定了一件不算酷、但很重要的事:测试都放到根目录 test/ 下。

text
test/
  login/
  js-web/
  js-admin/
  ah-web/
  ah-admin/
  template-web/
  template-admin/
  packages/
  e2e/

统一放,不是说所有测试混成一锅粥。

本地开发时,我们希望一句命令能跑;但 CI 里必须分开。公共登录挂了,和江苏管理端挂了,处理的人、影响范围、排查方向都不一样。你给我一个叫 verify 的大 Job,红了以后什么都不说,我只会想骂人。


三、PM 测试管理系统,不是附属页面

统一测试目录,只是解决了“测试放在哪里”的问题。接下来还得解决更现实的一件事:谁来运行这些测试,又怎么知道它跑到了哪一步、为什么失败。

我们不是先有一条服务器流水线,再想着“要不要做个 PM 页面看一下”。顺序反过来:先把测试能力接到 PM 测试管理系统里,让人可以主动选测试、看过程、看报告;之后才把同一套能力搬到服务器,变成谁都绕不过去的底线。

原因很简单,也不用装高尚:人就是会偷懒。

赶进度时,“我本地看了一眼没报错”很容易变成默认动作;有的人只跑单测,不跑浏览器;有的人什么都不跑,觉得改得不多应该没事。不能指望每个人在每一次提交前都自觉把该做的验证做完。

所以 PM 和 GitLab CI 不是两套重复系统,而是同一套测试能力的两种使用方式:

位置它解决什么
PM 测试管理系统让开发、测试和项目负责人可以主动选择测试,盯着任务过程,看每一份报告
服务器 GitLab CI不管有没有人主动点,代码一进来就自动校验,给流程兜底

PM 测试管理系统里的“测试管理”页面,做的不是摆几颗按钮。它是 PM 里的一个测试入口:页面创建异步工作区任务,在仓库根目录执行选中的测试脚本。可以选默认测试、全部单元测试、E2E 冒烟、完整 E2E、覆盖率、受影响项目测试,或者把 CI 整套流程在 PM 所在机器上先跑一遍。

过程大概是这样:

text
在 PM 里选一个测试
→ 创建工作区任务
→ 根目录执行对应的 pnpm 测试命令
→ 实时日志进任务中心
→ 测试结果写入 test/reports/
→ PM 读取 summary.json
→ 在报告列表里看到这一次测试,再打开 HTML 详情
PM 测试进行中
PM 测试进行中

这件事的体验很重要。不是让人去终端敲半天命令、记住一堆参数;而是让他在 PM 测试管理系统里明确地知道:我现在跑的是什么、还在不在跑、为什么失败、这份报告属于哪一次。

报告列表按时间展示,能按标题、状态、日期、报告编号去找;点进去就是 index.html,需要时再单独打开完整报告。这样做以后,测试才第一次从“少数开发会用的命令”变成了 PM 系统里能被正常使用的能力。

PM 测试报告
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 里有三个角色,不分清真的会懵。

text
GitLab:负责接收提交、排任务、展示流水线
Runner:真的执行命令的机器或程序
Docker:给 Runner 准备一份临时、统一的运行环境

举个例子:

yaml
image: node:22-bullseye
script:
  - pnpm test

它不是 GitLab 自己突然有了 Node 22。是 Runner 拉了一个带 Node 22 的 Docker 镜像,然后在里面执行 pnpm test

这就是为什么 Docker 在 CI 里这么常见:不用赌宿主机今天装了什么、明天被谁改了什么。

但是,理论讲完,现实马上给我一拳。

当前 GitLab 服务端比较老(具体版本以你们环境为准)。一些新版本文档里很顺手的写法,放进去就是不认:YAML 校验不通过、Job 定义不兼容、配置行为不符合预期。

我一开始还想把 CI 拆成多个文件,再用 include 组合,用 needs 组织依赖。看上去很优雅。

但它不支持。

那就别自我感动了。最后回到最土、也最稳的方案:一个根 .gitlab-ci.yml,所有 Job 直接写进去,用 stage 保证顺序。

text
基础质量
→ 公共登录
→ 江苏客户端
→ 江苏管理端
→ 安徽客户端
→ 安徽管理端
→ Web 模板
→ 管理端模板
→ E2E 冒烟
→ 报告发布
→ 飞书通知

文件长了,但至少机器认得,人在 GitLab 页面上也看得懂。

这件事之后我记住一句话:

你写的 CI 不是写给最新文档看的,是写给你眼前这台老 GitLab 和这群 Runner 看的。


五、跑完测试以后,我想问自己:这他妈的到底测了什么?

这句问得一点问题没有。

最初报告做出来时,确实太像交差:一个结果,一个页面,告诉你“成功”或“失败”,然后没了。

可真实使用时,别人想知道的是:

  • 什么时候跑的?
  • 跑的是单测还是 E2E?
  • 对应哪个提交?
  • 测试了几个项目?
  • 总共多少用例?
  • 哪个步骤失败?
  • 覆盖率在哪?
  • 浏览器测试失败的截图、视频和 Trace 去哪看?

这些答不上来,报告再漂亮也没用。

于是测试报告被重新设计成每次独立存一份:

text
test/reports/2026-08-19_180000_e2e-smoke_abcd1234/

里面不只放一张 HTML:

text
index.html                 # 给人直接看的汇总页
summary.json               # 给 PM、Wiki、脚本读取的结构化数据
logs/                      # 每一个步骤的原始日志
vitest-junit.xml           # Vitest 结果
playwright-junit.xml       # Playwright 结果
playwright/                # Playwright HTML 报告
artifacts/                 # 失败截图、视频、Trace
coverage/                  # 覆盖率

报告目录不提交 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 的脚本,最好走官方软件源:

bash
# 准备 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-world

最后那条 hello-world 能跑,说明 Docker 基础环境至少没问题。

但别因为嫌麻烦,就把所有日常账号都丢进 docker 用户组。这个组的权限很高,CI 最好用专门机器、专门账号、最小权限。别拿生产机当实验田。


八、最离谱的一次:业务 Job 挂了,飞书通知也跟着挂了

这大概是整个过程里最让人无语的一段。

当时 Runner 比较老,实际日志里 Node 启动时创建后台线程失败。业务 Job 已经红了,理论上下一步应该赶紧发飞书告诉大家。

结果呢?

通知 Job 原来也是 Node 脚本,于是它也一起红了。

发个消息而已,居然还要经过:拉镜像、启动 Node、装依赖、跑脚本。任何一环出问题,消息就没了。

这就不对。

我重新问自己:为什么发飞书一定要用 Node?

答案是,不一定。

后面通知链路被单独拆出来:

text
独立 Shell Runner(标签 ci-notify)
→ POSIX sh
→ OpenSSL
→ 直接发 HTTPS 请求
→ 飞书群机器人

业务测试、构建、E2E 继续走 Docker Runner;飞书通知走单独故障域里的 Shell Runner。这个“单独”很重要:如果两个 Runner 仍在同一台磁盘已满或网络中断的机器上,通知一样可能发不出去。

不是把所有 CI 搬回本地,而是把那条“业务失败时仍然必须活着”的通知线单独拎出来。

这一下,业务 Docker Runner 就算因为镜像、磁盘、网络、资源问题不工作,通知至少还有另一条路。

飞书失败通知
飞书失败通知

Runner 到底怎么装、怎么注册?

Runner 不是 GitLab 里点一下就从天上掉下来的。你需要准备一台 Linux 机器,装 GitLab Runner,然后把它注册到项目、群组或实例里。

Ubuntu / Debian 上常见的安装过程是:

bash
# 下载软件源配置脚本后,先看一眼,不要闭眼执行
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-runner

然后去 GitLab 项目的 Runner 设置页创建 Runner,拿到认证 Token,再执行:

bash
sudo gitlab-runner register

它会一步步问你 GitLab 地址、Token、Runner 名称、标签和执行器。

这次我们最后分了两类 Runner:

Runner执行器负责什么
业务 RunnerDocker executorNode 构建、Vitest、Playwright E2E
通知 RunnerShell executor只发飞书通知

通知 Runner 只打一个 ci-notify 标签。CI 里也明确写它:

yaml
'校验|通知|失败通知':
  stage: 通知
  tags: [ci-notify]
  before_script: []
  dependencies: []
  script:
    - 'sh scripts/notify-feishu.sh failed'
  when: on_failure

before_script: []dependencies: [] 的意思很简单:别给通知 Job 装 pnpm,别让它下载上游制品,别再往这条轻量链路上绑垃圾。

对于仍然需要 Node 的业务 Job,则限制了后台线程:

yaml
NODE_OPTIONS: '--v8-pool-size=1'

它不是性能魔法,只是在低资源机器上让 Node 少一点启动时的线程压力。先活下来,再谈性能。


九、飞书机器人接入,其实不是“给机器人一个账号”

飞书机器人本质上就是一个群 Webhook。

CI 成功或失败时,脚本往那个 Webhook 发一个 HTTPS 请求,飞书群里就能收到卡片。

配置过程并不复杂:

  1. 在飞书群里添加自定义机器人;
  2. 生成 Webhook 地址;
  3. 如果启用了签名,再保存签名密钥;
  4. 去 GitLab 项目的 CI/CD Variables 里配置;
  5. 必须设成遮蔽变量,不要写进仓库,也不要打印到日志;是否设为受保护变量,要看通知 Job 是否需要在非保护分支运行。

变量名大概长这样:

text
FEISHU_WEBHOOK_URL
FEISHU_WEBHOOK_SECRET
FEISHU_PROJECT_NAME
WIKI_ACCESS_TOKEN
FEISHU_GITLAB_API_TOKEN

飞书通知后来不只说“失败了”。它会尽量说清:哪个分类、哪个 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 和飞书开始说同一种话。

GitLab 测试流水线
GitLab 测试流水线

现在报告会告诉我们:

  • 什么时候跑的;
  • 跑了哪些项目;
  • 哪个提交;
  • 用例通过、失败、跳过多少;
  • 每个步骤用了多久;
  • 各项目覆盖率怎样;
  • E2E 测了什么;
  • 失败时该去哪个 Job、哪个报告目录看。

飞书如果没法读取 GitLab Job 或日志详情,也会直接说明是权限问题,而不是假装一切正常。

历史流水线还加了定时清理:保留最近 100 条已经完成的流水线。正在跑、等待人工操作、当前清理任务本身不删。流水线记录和制品是一层,Runner 的 pnpm 缓存、Docker 镜像和工作目录是另一层,后者还需要单独设置清理策略。

不然服务器迟早又会被缓存、制品、镜像和老记录拖得喘不过气。


十三、这两周最后留下的,不是一份 YAML

最后留下来的,是一条能顺着查下去的路。

开发时,先在 PM 里主动选测试、盯任务、看报告;代码进入仓库后,GitLab 再把关键校验强制跑一遍。跑完有独立报告;需要排障看制品;团队看 Wiki;出错飞书会提醒;机器出问题也会尽量说明是机器,不会乱把锅甩给代码;通知不会再和业务 Job 一起陪葬。

这事最开始看着只是“接个自动化测试”。干到后面才发现,真正麻烦的从来不是写一条 pnpm test

而是你得让测试、报告、Runner、Docker、网络、磁盘、Token、Wiki、飞书,都别在关键时候突然装死。

现在还不能吹“万无一失”。真实账号、真实环境、发布后的人工验收,该做还得做。

但至少以后再看到一条红色流水线,不会只剩下一句毫无用处的“失败”。

我们终于知道该从哪儿开始骂,啊不,开始查了。