“我本地跑得好好的,线上怎么崩了?”这句话我在自动化测试这行听了快十年,自己也说过不少次。多数情况下,问题根本不在代码逻辑,而在运行环境——本地装了某个版本的浏览器驱动,本地数据库里有几条测试数据,本地系统的字符集恰好支持中文,这些“恰好”一到干净的测试环境里就全部失效。自动化测试要想真正自动化,环境必须能一键还原,而 Docker 部署恰恰是解决这个问题的标准答案。这篇内容我会用自己的一个真实测试项目为例,把整套方案拆开揉碎讲清楚,目标是让任何人照着做,都能在 5 分钟内把一套自动化测试环境跑起来,不再被“环境问题”拖后腿。
1. “本地能跑”不等于“环境正确”:环境漂移在自动化测试里的真实代价
很多人对 Docker 的第一印象是“部署工具”,是用来上线 Java、Python 服务的,跟测试关系不大。但做自动化测试久了你会发现,测试环境才是最容易产生环境漂移的地方,因为测试脚本对环境的敏感度极高,而测试环境的标准化程度往往又最低。
1.1 环境漂移的本质:不是代码不同,而是环境不同
拿我之前负责的一个商城项目来举例。回归测试脚本用 pytest + Selenium 写,里面覆盖了登录、结算、订单查询几条核心链路。刚开始一切都好,直到某个周五,测试同事告诉我:“昨天用例还全过,今天同一套代码,挂了 6 条。”
我去排查,最后定位到原因:那台共享测试机上,有人手动升级了 Chrome 浏览器,从 114 升到 116,而脚本里写死的 ChromeDriver 还停留在 114,驱动和浏览器版本不匹配,Selenium 直接连浏览器都拉不起来。这就是典型的环境漂移——代码一行没改,运行环境变了,结果就变了。
环境漂移的类型远比想象中多,我整理了一张表,基本覆盖了自动化测试常见的“变数”:
| 漂移类型 | 具体表现 | 为什么难排查 |
|---|---|---|
| 依赖版本漂移 | requirements.txt 没锁死小版本,pip 装了新版本库 | 没有把完整依赖快照固化进镜像 |
| 浏览器驱动漂移 | Chrome 自动更新,ChromeDriver 版本没跟上 | 驱动的版本匹配是隐式约束,不报错时谁也不会注意 |
| 系统级依赖缺失 | 容器或新机器缺少 libnss3、libgbm 等浏览器运行库 | Selenium 启动浏览器时才报错,而且报错信息很抽象 |
| 配置文件漂移 | 本地 .env、config.yaml 和测试环境不一致 | 配置文件常被 git ignore,跟不上镜像 |
| 测试数据漂移 | 本地数据库有数据,线上库是空的 | 测试前置数据依赖手工灌入 |
| 时区和区域设置 | 本地是 UTC+8,容器默认 UTC,时间断言全挂 | 断言用本地时间,运行环境却用 UTC |
这六类里,除了测试数据需要专门管理,其余五类都是 Docker 镜像可以固化的。换句话说,Docker 部署解决的不是“写测试”的问题,而是“让测试跑在任何机器上都一样”的问题。
1.2 为什么虚拟环境不够,必须上容器
很多测试项目用 venv 管理 Python 环境,这当然比裸机好,但 venv 只能锁定 Python 包,锁不了操作系统层面的东西。我用 venv 时踩过的坑包括:代码里调用了系统命令ffmpeg,本地装过,新同事的电脑没装;测试数据连的是 localhost 的 MySQL,别人的机器上 MySQL 版本是 8.0 而我是 5.7,某些 SQL 语法直接不兼容。
Docker 和 venv 的本质区别在于:venv 共享宿主机的内核和系统库,Docker 连系统库、时区、用户、网络配置一起打包。镜像一旦构建成功,里面的东西就是固定的,不管放在谁的机器上、哪个 CI 平台上,运行结果都一样。这就是 Docker 部署能解决“本地跑通、线上崩”的根本原因。
2. 动手前的核对单:你的自动化测试到底依赖哪些“环境”
先说清楚一个原则:Docker 化改造不需要一次性搞定所有测试,先挑最痛的部分。我拿商城项目的 UI 回归测试举例,改造前先列了一张“环境依赖清单”,这一步是整个方案的地基,直接影响后续 Dockerfile 怎么写。
2.1 三层依赖:语言层、浏览器层、服务层
自动化测试环境不是只有 Python 包,它通常横跨三层:
首先是语言层。项目用的是 Python 3.11,依赖包括 pytest、selenium、pymysql、redis 客户端、requests。这层依赖的坑在于间接依赖,直接依赖写在 requirements.txt 里,但 selenium 依赖的 urllib3、certifi 这些间接包,如果不锁版本,哪天pip install解析出新版本,行为可能就变了。解决办法是生成本机的完整依赖快照:pip freeze > requirements.lock,镜像构建时用这个 lock 文件而不是靠手维护的 requirements.txt。
其次是浏览器层。Web UI 自动化必须要浏览器,要么 Chrome + ChromeDriver,要么 Firefox + GeckoDriver,要么 Chromium + chromium-driver。麻烦的是驱动和浏览器版本必须严格匹配,差一个主版本号就启动失败。这个层在 Docker 里的解决思路是:不用宿主机浏览器,而是直接在镜像里装 Chromium 和对应驱动,通过apt-get install chromium chromium-driver安装,让包管理器保证两者版本一致性。
第三是服务层。结算流程的用例需要连数据库和 Redis,测试环境里这些服务可能是独立部署的,代码里配置了 IP 和端口。Docker 化时要把这些服务也容器化,并在编排层面让它们和测试容器同时在同一个网络里跑,这样才能做到“一键拉起一套完整的测试环境”。
2.2 从零想清楚:哪些东西进镜像,哪些东西不进镜像
这部分是很多人一开始容易搞混的。镜像里放的是“代码 + 依赖 + 运行环境”,但测试数据和测试报告不应该放进镜像。
测试数据放进镜像的后果是:镜像一构建,数据就被写死了,没法在跑测试的时候灵活调整;而且数据容易污染镜像层,每次改动数据都要重新构建。正确的做法是把数据库初始化脚本挂载进容器,或者由测试脚本连接外部已经初始化的数据库。
测试报告同理。容器是无状态的,容器一删报告就没了。正确做法是用 volume 把容器内的报告目录映射到宿主机,或者推到报告服务里。我一般在 docker-compose 里给测试服务配置volumes: - ./reports:/app/reports,这样 pytest 生成的 HTML 报告和截图能直接留在项目目录里,方便随时查看。
依赖清单列完后,我当时的结论是:需要固化的包括 Python 3.11 解释器、全部 Python 依赖(锁版本)、Chromium 和 chromium-driver、项目的测试代码、以及时区设置。数据库和 Redis 单独跑容器,不进测试镜像。
3. 5分钟构建测试镜像:从空目录到能跑的Dockerfile
现在进入实操环节。下面这套 Dockerfile 是我跑了半年多、在十几个用例上验证过的方案,可以直接拿去改。
3.1 选基础镜像:为什么是 python:3.11-slim 而不是 alpine
很多人一上来就选python:3.11-alpine,因为镜像小。但我在实测中发现,alpine 的 musl libc 跟很多 Python 二进制包不兼容,比如lxml、pycryptodome这些需要底层编译的包,在 alpine 上要么装不上、要么得走编译安装,时间成本极高。测试镜像不需要极致的小体积,稳定和快才是第一位的。
我选的是python:3.11-slim。它是 Debian 的裁剪版,保留了 apt 包管理器,装 Chromium 很方便,glibc 兼容性也好,几乎所有预编译 wheel 都能直接用。实际构建后的镜像大概 1.2GB,对测试环境来说完全可以接受。
3.2 Dockerfile 全文与逐段拆解
这是我的测试镜像 Dockerfile 的核心版本:
FROM python:3.11-slim ENV DEBIAN_FRONTEND=noninteractive \ TZ=Asia/Shanghai \ LANG=C.UTF-8 \ LC_ALL=C.UTF-8 WORKDIR /app # 系统级依赖:Chromium 和必要的运行库 RUN apt-get update && apt-get install -y --no-install-recommends \ chromium \ chromium-driver \ fonts-liberation \ libnss3 \ libgbm1 \ libasound2 \ libatk-bridge2.0-0 \ libxkbcommon0 \ && rm -rf /var/lib/apt/lists/* # 先复制依赖声明文件,利用 Docker 层缓存加速后续构建 COPY requirements.lock . RUN pip install --no-cache-dir -r requirements.lock # 再复制测试代码 COPY tests/ tests/ COPY pytest.ini . COPY config/ config/ # 以非 root 用户运行,降低安全性风险 RUN useradd -m tester USER tester CMD ["pytest", "tests/", "-v", "--html=reports/report.html"]逐段说几个关键点:
ENV DEBIAN_FRONTEND=noninteractive是必须的,否则 apt 安装时可能卡在时区交互询问上;TZ=Asia/Shanghai解决了时间断言问题,容器默认 UTC,如果你的用例断言了本地时间,没有这一行必挂。
WORKDIR /app设置工作目录,后续所有相对路径都以此为基准,避免pwd混乱。
apt-get install -y --no-install-recommends里的--no-install-recommends可以砍掉一大批用不上的推荐包,实测能把镜像体积缩小 300MB 左右;rm -rf /var/lib/apt/lists/*清理 apt 缓存,也是为体积服务的。这几个是镜像瘦身的标准操作。
COPY requirements.lock .放在COPY tests/前面是刻意的。Docker 构建的每一层都有缓存,只要这一层的输入没变,就不会重新执行。依赖文件不常变,测试代码天天变,把不常变的放前面,代码改动时就能直接命中缓存,跳过 pip install,构建时间从几分钟降到几秒。
最后用USER tester切到普通用户,是因为不 root 跑 Selenium 更安全。但注意,Chromium 在 Linux 容器里以 root 身份跑会有沙箱问题,普通用户身份反而更省事。这个后面展开。
3.3 构建并跑通:5分钟是怎么做到的
第一次构建因为要下基础镜像和 apt 包,可能要 8-10 分钟;但一旦镜像在本地缓存里,后续每次构建只需重新拷贝代码层,30 秒到 1 分钟内完成。我平时提到“5分钟搞定环境”,指的是从空白机器上把这个镜像构建出来并跑通用例的完整过程。
构建命令和跑测试命令都很简单:
# 构建镜像 docker build -t shop-tests:latest . # 跑测试(数据库和 Redis 先通过 compose 起起来) docker run --rm --network shop-test-net \ -v "$(pwd)/reports:/app/reports" \ -e DB_HOST=mysql \ -e REDIS_HOST=redis \ shop-tests:latest这里有个很关键的习惯:调试时用 docker run,正式跑用 docker-compose。docker run 适合快速验证单容器行为,比如看镜像里缺不缺某个库;而多服务一起拉起时,用 compose 才能管理好网络和启动顺序。还有一点,--rm一定不要省,否则跑完的容器会占用磁盘空间,积累多了很可怕。
4. 容器里跑Selenium的三道坎:沙箱、共享内存与Chromium版本
这章不是讲理论,都是实打实踩过的坑。我第一次把 Selenium 用例塞进容器里跑,前前后后折腾了两天,最后发现全是操作系统层面的隐形问题。在这里统一说清楚,能让后来的人少走两天弯路。
4.1 第一道坎:Chromium 的沙箱机制
Selenium 在 Linux 容器里启动 Chromium 时,最常见的报错是这样的:
Failed to move to new namespace: Operation not permitted问题根源在于:Chromium 默认启用 Linux 沙箱(namespace sandbox),而 Docker 容器默认没有权限创建新的 namespace。解决方式有两种:要么给容器加--privileged参数,要么启动浏览器时加--no-sandbox标志。我强烈建议后者,因为--privileged会让容器拥有宿主机所有权限,不符合最小权限原则,而且在 CI 平台上经常不允许这么干。
在用例里配置浏览器选项:
from selenium import webdriver from selenium.webdriver.chrome.options import Options options = Options() options.add_argument("--headless=new") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") options.add_argument("--disable-gpu") options.add_argument("--window-size=1920,1080") driver = webdriver.Chrome(options=options)这几行几乎是容器里跑 Selenium 的“标准咒语”,每条都有明确用途。--headless=new是无头模式,新版的写法,替代老的--headless;--disable-dev-shm-usage解决容器共享内存过小的问题;--disable-gpu在无头模式下避免 GPU 相关告警。
4.2 第二道坎:容器内的 /dev/shm 只有 64MB
这是个典型的 Docker 坑。容器默认的/dev/shm(共享内存)只有 64MB,而 Chromium 渲染页面时非常依赖共享内存。页面复杂一点,浏览器就会莫名崩溃,报错信息经常是Render process gone或者直接挂掉,看起来完全不知道是哪一步的问题。
解决方式有两种。一种是在上面的 option 里加--disable-dev-shm-usage,让 Chromium 改到/tmp写临时文件,绕开共享内存限制,这个对大多数场景都够用;另一种是在 docker run 时指定更大的共享内存:--shm-size=2g。我个人的习惯是两种都加上,option 里加参数是为了让镜像在默认配置下也能工作,--shm-size是给复杂页面场景兜底。如果你跑的是大型后台页面或者大量截图用例,强烈建议给容器加 2GB 以上的共享内存。
4.3 第三道坎:Chromium 和 chromium-driver 的版本联动
我在 1.1 节提到本地环境被 Chrome 自动更新搞挂的事。在容器里,这个问题反而简单了——镜像构建时用apt-get install chromium chromium-driver一起装,Debina 源里这两个包的版本是绑定的,装完天然匹配,不存在驱动版本漂移。
但这里有个新坑:不同版本的 Debian(注意是 Debian,不是 Python 版本)源里的 Chromium 版本差异很大。有些旧版源里的 Chromium 很老,可能不兼容新版 Selenium。我建议先跑一个探针脚本确认版本:
docker run --rm shop-tests:latest sh -c "chromium --version && chromedriver --version"我见过有人 Selenium 4 配了个很老的 chromium-driver,结果一堆新 API 用不了。如果版本偏低,就别用 apt 装了,改用受控的方式:从官方地址下载指定版本的 Chrome for Testing 和对应 chromedriver,然后在 Dockerfile 里用COPY放进去。这个方案可控性最强,只是要多研究一下对应关系。实际项目中 90% 的场景用 apt 就够,除非你有特殊版本需求。
5. docker-compose编排多服务:让数据库、缓存和测试脚本按顺序就位
UI 回归测试很少只依赖被测应用本身,它通常还需要数据库、Redis、甚至被测 Web 服务。这时候光有测试镜像还不够,需要一套编排方案,把这些服务一次性拉起来。
5.1 一份能直接起飞的 docker-compose.yml
下面是我的项目里的精简版 compose 配置,完整覆盖了“被测服务 + 数据库 + Redis + 测试容器”四个角色:
services: web: build: ./web environment: DB_HOST: mysql REDIS_HOST: redis depends_on: - mysql - redis mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: shop_test MYSQL_USER: tester MYSQL_PASSWORD: testpass healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-prootpass"] interval: 5s timeout: 3s retries: 10 redis: image: redis:7-alpine healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 10 test: build: ./tests depends_on: web: condition: service_healthy mysql: condition: service_healthy redis: condition: service_healthy environment: BASE_URL: http://web:8000 DB_HOST: mysql REDIS_HOST: redis volumes: - ./reports:/app/reports这份配置里最值得学习的不是镜像本身,而是depends_on和healthcheck的组合用法。
5.2 depends_on 的谎言:它只管启动顺序,不管就绪状态
很多新手在 compose 里写depends_on: - mysql,以为这样就能保证 MySQL 准备好了再启动测试容器。实际上,depends_on只保证容器的启动顺序,不保证服务内部就绪。MySQL 容器起来了,不代表 MySQL 已经能接受连接。测试脚本一跑,数据库连接失败,还是白搭。
Docker Compose 的解决方案是服务健康检查 + condition。先给数据库服务定义healthcheck,用mysqladmin ping探活;再在测试服务的depends_on里写mysql: condition: service_healthy。这样 compose 会等到 MySQL 真正可以被连接,才启动测试容器。我加的 healthcheck 参数里,interval是探活间隔,timeout是单次探活超时,retries是连续失败多少次判定不健康,这三个参数建议明确指定,不要用默认值。
对于被测 Web 服务,健康检查通常用 HTTP 探活:
healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"] interval: 10s timeout: 5s retries: 5这样测试容器启动时,被测服务已经真正可以响应请求,避免“刚起就跑、一跑就挂”的尴尬。
5.3 网络与端口:记住容器之间用服务名通信
容器间的通信逻辑和宿主机完全不同。测试容器访问数据库,不能用localhost:3306,因为 localhost 指向容器自己。compose 会自动建一个共享网络,容器之间用服务名互相访问,比如测试容器里访问数据库用mysql:3306,访问被测 Web 用web:8000。
这个设计有一个隐藏好处:根本不涉及端口冲突问题。MySQL 容器不需要向宿主机暴露 3306 端口,Redis 也不需要暴露 6379,只有需要从浏览器手动访问的服务才映射端口。自动化测试跑在容器内部网络里,跟宿主机端口完全隔离。我之前遇到过测试机和被测服务端口冲突的破事,容器化之后就再也没有了。
6. 把镜像接入CI:让每次提交都自动验证“容器里的环境”
Docker 化测试环境的最大收益,不在本地跑通的那一次,而在接入 CI 流水线之后——每次代码提交,环境都是从镜像重新创建的,跑出来的结果才真正具备参考价值。
6.1 接入CI的两个核心动作:构建镜像、跑测试容器
不同 CI 平台的写法不同,但核心动作都一样。以常见的 GitHub Actions 为例,关键步骤是:
jobs: test: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Build test image run: docker build -t shop-tests:ci . - name: Run tests in containers run: | docker compose up -d mysql redis docker compose up --exit-code-from test test重点说--exit-code-from test这个参数。它表示 compose 命令的退出码由 test 容器决定。测试全部通过,容器退出码 0,CI 任务绿;有失败,退出码非 0,CI 任务红。这比在 CI 里跑docker run更规范,因为 compose 会先把依赖服务带起来,跑完再统一清理。
6.2 镜像Tag策略:不要迷信 latest
用 Docker 部署测试环境,镜像 tag 管理很容易被忽略。常见做法是 push 一个latest上去,但这在 CI 里会导致一个问题:两次构建的latest可能是不同内容,CI 因为缓存命中了旧镜像,跑的根本不是最新代码。
我现在的做法是用 Git 提交哈希做镜像 tag,比如shop-tests:7f3a2b9,CI 里每次都用git rev-parse --short HEAD生成 tag 并强制重新构建。这样每个提交对应唯一的镜像,排查问题时能准确知道跑的是哪个版本。本地调试可以沿用latest,但 CI 里一定不要依赖它。
6.3 CI 里的测试报告收集:容器跑完别把报告留在容器里
测试跑完得能看到结果。容器是无状态的,报告如果不导出就是白跑。我习惯在 CI 的步骤里加上 report 上传动作——把reports/目录里的 HTML 报告和截图作为 artifact 打包,测试失败时下载下来慢慢看。
本地看报告的方式更直接:compose 里已经挂载了./reports:/app/reports,跑完后直接打开reports/report.html就能看到图表化的执行结果,失败用例的截图也会同步在目录里。这一步看着小,实际用起来特别重要。没有报告收集,自动化测试跑得再多也只是个红绿灯,定位问题全靠猜。
7. 版本很容易被忽略的细节:镜像瘦身、缓存与清理
这部分内容不算 Docker 部署的核心,但很影响长期使用的体验。我把实践中总结的经验集中放在这里,一次性说完。
7.1 镜像瘦身的两个有效手段
首先看系统依赖精简。apt-get install -y --no-install-recommends是首选;其次,构建完的镜像里会残留 pip 缓存,加--no-cache-dir能省下几十 MB。
再一个是把报告目录挂载到宿主机而不是留在镜像里。这样做除了防止报告丢失,还能让测试容器的镜像层保持干净,不会因为跑了多次测试导致镜像体积越来越大。
7.2 pip 缓存与构建缓存的取舍
前面说 Dockerfile 把COPY requirements.lock放在前面可以利用构建缓存,但缓存有利也有弊。CI 的构建机有时候要求“每次都从零构建,不依赖缓存”,那样反而更保险,因为缓存出问题是出了名的难排查。我的建议是:本地开发保留缓存,CI 构建机不定期docker builder prune -f清一遍,防缓存不新鲜的问题。
7.3 定期清理,别让镜像和容器堆满磁盘
测试环境用 Docker 有个隐形代价:镜像、容器、volume 会不断堆积。docker ps -a一查全是退出状态的容器,磁盘不知不觉就满了。我每个月固定跑一次:
docker system prune -f docker volume prune -f这两个命令能清掉悬空镜像、停止的容器和没被引用的卷。注意volume prune会把没有被容器使用的卷一并删除,跑之前确认有需要保留的数据卷不要误删,我的习惯是先docker volume ls看一眼再动手。
7.4 时区别嫌麻烦:测试断言会狠狠地教育你
前面在 Dockerfile 里写了TZ=Asia/Shanghai,这里再强调一次,因为它太容易被忽略了。容器默认时区是 UTC,比北京时间慢 8 小时。如果测试用例里有“创建订单时间应该在当前时间附近”这样的断言,本地跑是过的,一进容器就“慢 8 小时”,必挂无疑。我的经验是:在 Dockerfile 里设置 ENV TZ,或者用 compose 的 environment 注入时区,两条路选一条,必须做。
拿我自己的体会来说,Docker 化改造这套测试环境,前期最痛苦的不是写 Dockerfile,而是把散落在各处的依赖一颗颗找齐、锁死、搬进镜像。但熬过那几天之后,收益是碾压性的:之前每次跑回归前光装环境、对版本、起服务就要半个多小时,现在一条命令全部就位;新同事入职,不用再发一份更长的环境搭建文档,直接docker compose up就能干活。最后分享一个我自己一直在用的小习惯:每次项目依赖有变动,顺手跑一遍pip freeze > requirements.lock提交进仓库,这个 lock 文件就是镜像的“配方”;只要配方不变,镜像不变,环境就永远坚如磐石。