Docker自动化测试CI怎么稳定落地?
很多团队把测试搬进 Docker 后,第一反应是“终于不用在 CI 机器上装一堆依赖了”。但只把 npm test 或 pytest 放进容器,还不能算真正的 Docker 自动化测试。更关键的是:测试依赖怎么启动、数据库怎么隔离、报告怎么拿出来、并行任务怎么互不影响,以及失败后现场是否还能定位。
Docker 适合放在哪些测试环节里
Docker 最适合解决两类问题:环境不一致和依赖难复现。本地能过、CI 挂掉,往往不是代码突然变坏,而是 Node、JDK、浏览器、数据库版本或系统库不一样。把测试环境写进镜像后,测试运行条件就从“某台机器刚好装好了”变成“镜像里明确声明了”。
常见做法可以分成四层:
- 单元测试:在测试镜像中运行 Jest、Vitest、pytest、JUnit 等,不依赖外部服务,执行最快。
- 集成测试:用 Docker Compose 或 Testcontainers 启动数据库、Redis、MQ、对象存储等依赖,验证真实交互。
- E2E / 浏览器测试:用 Selenium Grid、Playwright 容器或内置浏览器镜像跑完整页面流程。
- 性能测试:用 JMeter、k6 或 Locust 容器生成压力,配合独立网络和报告目录保存结果。
这几层不要混在一个命令里。单元测试应该几分钟内结束;集成测试可以慢一点,但要稳定;E2E 和性能测试成本高,通常放在合并前、夜间任务或发布前流水线里。
单元测试:先把运行环境固定住
单元测试最简单,核心是用测试镜像锁定语言版本和依赖安装方式。比如 Node 项目可以这样写一个多阶段测试镜像:
dockerfileFROM node:20-alpine AS deps WORKDIR /app COPY package*.json ./ RUN npm ci FROM deps AS test COPY . . ENV NODE_ENV=test CMD npm test -- --runInBand FROM deps AS build COPY . . RUN npm run build
多阶段构建的好处是,依赖安装、测试、构建可以复用同一套基础层。CI 里缓存 Docker layer 后,速度通常比每次从零安装依赖更稳。对 Java、Go、Python 项目也一样:测试阶段保留测试工具和调试符号,生产阶段只复制构建产物,避免把测试依赖带进运行镜像。
单元测试容器里最好显式传入环境变量,例如:
bashdocker run --rm \ -e NODE_ENV=test \ -e TZ=Asia/Shanghai \ -v ./reports:/app/reports \ myapp:test
-v ./reports:/app/reports 很重要。容器退出后文件系统会消失,测试报告、覆盖率、截图、trace 如果不挂载出来,CI 页面上只能看到一段失败日志,排查会很痛苦。
集成测试:Docker Compose 管依赖,健康检查管时机
集成测试难点不在测试代码,而在“依赖什么时候真的可用”。数据库容器启动完成不代表可以接收连接,消息队列端口打开也不代表 topic 已经初始化。所以 Compose 文件里应该写 healthcheck,并让测试服务等待依赖健康后再跑。
yamlservices: db: image: postgres:16-alpine environment: POSTGRES_DB: app_test POSTGRES_USER: test POSTGRES_PASSWORD: test healthcheck: test: pg_isready -U test -d app_test interval: 3s timeout: 3s retries: 20 tmpfs: - /var/lib/postgresql/data redis: image: redis:7-alpine healthcheck: test: redis-cli ping interval: 3s timeout: 3s retries: 20 test: build: context: . target: test depends_on: db: condition: service_healthy redis: condition: service_healthy environment: DATABASE_URL: postgres://test:test@db:5432/app_test REDIS_URL: redis://redis:6379 NODE_ENV: test volumes: - ./reports:/app/reports command: npm run test:integration
这里的 tmpfs 会让 PostgreSQL 数据写在内存里,测试结束后自然消失,适合临时数据库。也可以每个测试任务创建随机库名,例如 app_test_${CI_JOB_ID},跑完再 drop。原则只有一个:测试数据必须是一次性的,不能让上一次失败污染下一次结果。
如果项目语言支持,Testcontainers 更灵活。它可以在测试代码里按需启动容器,拿到动态端口,再把连接信息注入测试。适合需要并行跑很多测试文件,或每个测试套件都要独立数据库的场景。
javascriptimport { PostgreSqlContainer } from "@testcontainers/postgresql"; const db = await new PostgreSqlContainer("postgres:16-alpine").start(); process.env.DATABASE_URL = db.getConnectionUri(); // run integration tests await db.stop();
Compose 更像“固定一套测试环境”,Testcontainers 更像“测试自己声明需要什么依赖”。团队规模小、依赖固定时用 Compose 就够;测试隔离要求高、并行任务多时,Testcontainers 会更省心。
E2E 和浏览器测试:别忘了浏览器也是依赖
端到端测试经常因为浏览器版本、字体、系统库不同而误报。把 Playwright 或 Selenium 放进容器,可以把浏览器、驱动和系统依赖一起锁住。
Playwright 官方镜像已经带好浏览器:
yamlservices: app: build: . environment: NODE_ENV: test healthcheck: test: wget -qO- http://localhost:3000/health || exit 1 interval: 5s timeout: 3s retries: 30 e2e: image: mcr.microsoft.com/playwright:v1.44.0-jammy working_dir: /work volumes: - ./:/work - ./reports/e2e:/work/playwright-report environment: BASE_URL: http://app:3000 depends_on: app: condition: service_healthy command: npx playwright test --reporter=html
Selenium 也类似,可以用 selenium/standalone-chrome 或 Selenium Grid。重点是测试代码访问服务时不要写 localhost:3000。在 Compose 网络里,localhost 指的是当前容器自己,应该用服务名,比如 http://app:3000。
E2E 报告建议至少保留三样东西:失败截图、视频或 trace、浏览器控制台日志。很多 UI 问题只看断言信息看不出来,trace 文件往往能省半小时。
性能测试:容器化工具不等于随便压测
JMeter、Locust、k6 都可以容器化运行,但性能测试要特别注意网络和资源隔离。压测容器和被测服务如果跑在同一台 CI 机器上,CPU 抢占会让结果变形;如果目标是外部测试环境,又要避免多个流水线同时压测同一套服务。
Locust 的例子:
yamlservices: locust: image: locustio/locust:2.31.1 volumes: - ./perf:/mnt/locust - ./reports/perf:/reports environment: TARGET_HOST: http://app:3000 command: -f /mnt/locust/locustfile.py --headless -u 100 -r 10 -t 5m --html /reports/locust.html
JMeter 也可以把 .jmx 脚本和结果目录挂进去,输出 JTL、HTML 报告。性能测试不要只看平均响应时间,至少要记录 p95、p99、错误率和吞吐量。CI 里可以设置门槛,例如错误率超过 1% 或 p95 超过 800ms 就失败。
CI/CD 里怎么串起来
一条实用的流水线通常是这样的:
yamlstages: - unit - integration - e2e - performance unit: script: - docker build --target test -t myapp:test . - docker run --rm -v $PWD/reports:/app/reports myapp:test integration: script: - docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from test after_script: - docker compose -f docker-compose.test.yml down -v --remove-orphans
--abort-on-container-exit 可以在测试容器结束后停止依赖服务,--exit-code-from test 能把测试失败正确传给 CI。down -v --remove-orphans 则负责清理匿名卷、网络和残留容器。很多“偶发失败”其实是清理不干净造成的,比如旧数据库卷还在、旧网络里有同名服务、上一次的测试容器没退出。
并行测试时要避免共享固定资源。常见做法包括:
- 用
COMPOSE_PROJECT_NAME=$CI_JOB_ID给每个任务生成独立网络和容器名。 - 给数据库名、Redis key 前缀、对象存储 bucket 加随机后缀。
- 不把端口映射到宿主机,容器之间走内部网络,减少端口冲突。
- 报告目录按任务拆开,例如
reports/$CI_NODE_INDEX。
Docker 自动化测试的优势
把测试放进 Docker 后,收益通常很直接:
- 环境一致:本地、CI、预发用同一份镜像,减少“只在某台机器失败”。
- 依赖可复制:数据库、缓存、浏览器、压测工具都能随测试启动。
- 清理成本低:容器、网络、临时卷可以在任务结束后统一销毁。
- 并行更容易:每个任务一套隔离网络和临时数据库,互不抢状态。
- 报告可沉淀:覆盖率、JUnit XML、HTML 报告、trace 都能通过 volume 交给 CI 归档。
这些优势不是 Docker 自动发生的,需要在镜像、网络、数据和报告目录上提前设计。否则容器只是把混乱从宿主机搬进了另一个黑盒。
常见坑
第一,依赖没健康就开始测。 只写 depends_on 不够,要配 healthcheck,或者在测试入口里等待服务可用。
第二,测试数据不隔离。 共享一个长期数据库最容易制造偶发失败。优先用临时库、tmpfs、事务回滚或 Testcontainers。
第三,报告留在容器里。 容器退出后现场没了,记得把 reports、coverage、playwright-report 挂载到宿主机或 CI artifact。
第四,网络理解错。 Compose 里服务互访用服务名,不是宿主机的 localhost。需要访问宿主机时,再考虑 host.docker.internal 或显式网络配置。
第五,清理命令太温柔。 CI 失败后也要执行 docker compose down -v --remove-orphans,否则下一次任务可能接住上一次的脏状态。
什么时候不该全都放进容器
Docker 很适合标准化测试环境,但不是越多越好。纯函数单元测试如果本机运行只要 10 秒,没必要每次都强制走 Compose。性能测试如果需要稳定数据,也不应该和普通 PR 流水线挤在同一台 runner 上。更合理的方式是分层:提交时跑快速单元测试和必要集成测试,合并前跑 E2E,发布前或定时跑性能测试。
最终目标不是“所有测试都容器化”,而是让每类测试都有可复现的环境、清晰的隔离边界和可追踪的结果。做到这三点,Docker 自动化测试才真正能在 CI/CD 里长期稳定运行。