1. 先聊聊我在CI里摔过的跟头——本地绿、CI红是常态做自动化测试这几年我见过太多这样的场景开发在本地跑pytest全绿信心满满地提交代码结果CI流水线跑起来红得那叫一个彻底。你问他怎么回事他第一反应永远是“代码没问题啊本地都过了”。然后你让他去CI看日志一半时间卡在一些莫名其妙的错误上——ModuleNotFoundError、Connection refused、chromedriver not found、测试数据对不上、端口被占用。折腾半天最后发现根本不是代码的问题是环境的问题。这个事的本质在于自动化测试本来就是一件“换台机器就失灵”的事。本地你能跑是因为你的机器上装好了Python、装好了依赖、数据库里有你想要的数据、浏览器版本和WebDriver能匹配上、端口也没人跟你抢。可CI是另一台全新的机器它每次运行都默认给你一个干干净净的环境你没配置好的东西它统统没有。而大多数测试失败恰恰就失败在这些“你没配置好的东西”上。所以这篇文章我想围绕“环境隔离”好好拆一下。说白了环境隔离不是说你非得上容器、上K8s而是你要想清楚一件事你的测试到底依赖了什么环境状态以及这些状态是不是每次运行前都被可靠地建立、使用和清理了。这套逻辑想明白了你的CI失败率能掉一大截。2. CI环境与本地环境到底差在哪五个你容易忽略的变量先说个反直觉的结论CI机器和本地机器的差异往往比两台不同人的开发机的差异还大。本地机器是你天天用、天天调的环境各种隐性的状态都“养”在那里了而CI每次都是一次“接客”跑完就走环境状态不可积累。我盘了一下最常见的差异集中在五个地方。2.1 端口与资源没人跟本地抢CI里全是邻居本地跑测试你的应用监听8080基本就你一个人用。但CI的runner通常是共享的同一个机器上可能同时跑其他项目的构建任务端口冲突是常有的事。尤其是用Selenium Grid或者Appium的时候4723、4444这类默认端口撞车的概率非常高。我建议的做法是所有服务和测试要用到的端口要么在流水线里动态分配要么明确写死在配置里而不是用默认值。如果你用的是Docker那更简单——-p 0:8080这种随机映射然后通过Docker的端口发现机制拿到实际端口。别觉得这是小事CI里“端口被占用导致连不上”的问题排起查来极其恶心因为它不是必现的是偶发的偶发问题最浪费人生。2.2 文件路径与环境变量Windows和Linux的恩怨很多测试代码里写着C:\Users\xxx\...或者E:\test_data\...这类硬编码路径在本地Windows上跑没问题但CI通常是Linux容器路径直接就不存在。更隐蔽的是环境变量——本地你可能是IDE里配置好了DATABASE_URL、APP_ENV、API_TOKEN但CI里这些变量根本没设置于是一进到测试代码取到的值是None然后连锁报错。我的经验是所有路径必须用相对路径或者基于项目根目录动态拼接环境变量绝不直接硬编码在代码里而是统一走配置文件加环境变量覆盖。测试代码里凡是要读外部资源的地方一律先判断环境变量存在不存在不存在就给一个明确的报错信息而不是让它一路崩到让人猜。2.3 系统依赖与浏览器最经典的“我机器上明明能跑”Web自动化这块系统依赖的差异最致命。本地有Chrome有对应版本的chromedriver有各种系统库CI的容器里可能只有Firefox或者Chrome版本跟driver不匹配甚至缺了运行浏览器需要的依赖库比如libnss3、libatk浏览器启动都起不来。这个问题没有银弹但有个特别实用的习惯你的CI配置里一定要显式地安装和固定浏览器及Driver版本。不要用latest不要用“系统自带”要有一个明确的版本号列表。比如你的项目锁定Chrome122.x那就要在CI脚本里写清楚用什么方式装这个版本、装哪个版本的Driver。这地方我也会在后面的章节给出一套能直接抄的配置。2.4 时间与随机性你没想到的“不稳定源”CI失败里有一类特别邪门的跟时间有关。比如测试里断言了某个日志时间戳本地跑是几秒内完成的CI里机器负载高一卡就是十几秒时间对不上了。再比如有些测试依赖随机数、依赖当前日期或者依赖某些数据的生成顺序这在稳定环境里没事CI一抖动就红了。这块的解法就是测试代码里不要把“当前时间”“随机值”“执行顺序”作为隐性依赖。需要断言时间的地方用注入的方式传固定时间需要随机数据的先用固定seed生成需要顺序的显式排序。这一步做不做直接决定你的CI是“基本稳定”还是“三天两头无缘无故红”。2.5 状态残留上一次运行的脏数据还有一个非常容易被忽略的点CI机器如果不是每次全新容器而是复用的那上一次跑测试残留的进程、数据库记录、临时文件、缓存全都可能影响这次的结果。一个典型的场景你的测试框架用同一个数据库上一次跑完有一条数据没清理干净这次跑的时候查到了一个多余的结果断言失败。所以环境隔离里很重要的一环是“可重复的初始状态”——要么每次CI都用全新环境要么在测试之前做一次彻底的状态重置。后面我会详细讲这个怎么落地。3. 依赖隔离从“我机器上能跑”到“它机器上也能跑”前面说的是“差异”现在开始说“怎么处理”。第一个要处理的就是依赖。3.1 requirements.txt锁版本别让“顺手升级”毁了你的CI很多项目管依赖还停留在pip install -r requirements.txt然后requirements里写的是requests2.0这种范围版本。这种写法在本地没问题因为本地已经装好了某个具体版本但CI里每次装都是装当时的最新版。某天一个依赖库升级了API变了你的测试代码没跟上CI就红了——但这不是你的代码的问题是版本漂移的问题。所以第一步一定用锁版本。推荐的做法是开发时用pip freeze生成requirements.txt里面是精确版本号。或者用pipenv/poetry用 lock 文件锁定依赖树。如果项目比较复杂可以考虑pip-tools把requirements.in直接依赖和requirements.txt完整锁定的依赖树分开管理。有个小建议CI里安装依赖的时候加一个--no-cache-dir。我踩过这个坑——CI缓存了旧包新代码用了新接口结果装了个旧版本莫名其妙地挂。加了这参数后虽然每次装得慢一点但至少不会“被缓存坑”。3.2 虚拟环境CI里别直接用全局Python还有人在CI里直接pip install到全局Python环境。这在小项目里可能还能跑但一旦项目多了、依赖冲突了你就知道什么叫“环境像一团乱麻”。所有流行CI方案都支持虚拟环境用python -m venv建一个把所有依赖装进去跑完就丢掉互不污染。这里有个更现代的方案用uv或者poetry这一类的工具它们装依赖的速度比pip快很多在CI里能省不少时间。特别是uv它自带锁文件概念装出来的环境一致性非常高我现在的项目基本都用它。3.3 容器化Docker是“环境隔离”的最强形态如果你用了Docker来跑测试那依赖隔离的问题会简单很多。因为你不再是在某个机器上装环境而是把你的整个运行环境“写成代码”然后用同一个镜像在不同机器上跑出一样的结果。这本质上是把环境从一个“状态”变成了一个“定义”。但用Docker也有讲究不是说你写了Dockerfile就万事大吉了。几个容易踩的坑镜像的base要固定tag不要用python:latest要用python:3.11-slim这种明确的版本。安装依赖的步骤要放在代码复制之前利用Docker层的缓存机制不然每次改代码都要重新装一遍依赖CI跑到你怀疑人生。如果你们团队用的镜像仓库记得给镜像打commit sha或日期标签别都用latest不然哪天基础镜像变了你的CI就“莫名其妙”红了。4. 数据与配置隔离数据库、环境变量、测试数据的三重隔离依赖隔离解决的是“装没装”的问题但测试跑起来还要面对“能不能连”“有没有数据”的问题。这就是数据与配置的隔离。4.1 测试数据库绝不要连开发库或生产库我见过不少项目测试环境配的数据库跟开发环境是同一个。这会造成两个后果一是测试数据会被开发的日常操作搞乱二是测试里的清理逻辑可能会把开发的数据给删了——这种事故出一次够你喝一壶。正确做法是测试用独立的数据库而且最好是每次CI跑起来的时候现建一个、跑完直接删掉。MySQL的话可以在CI脚本里加上CREATE DATABASE test_db跑完DROP DATABASE test_db。如果不想每次重建那就必须保证测试开始前有完整的“清库造数”流程。再提醒一句不要用数据库事务回滚来替代清理。看起来很高端实际在分布式、多线程、异步场景下各种翻车老老实实写清理逻辑或者重建库是最稳的。4.2 环境变量配置三套环境三套配置一套机制配置管理这块我推荐一个特别简单的模式项目里维护一份config目录里面放base.py、ci.py、local.py分别对应公共配置、CI环境配置、本地配置。然后通过环境变量APP_ENV来决定加载哪一套。这套模式的好处是加了新配置项之后你不会忘记在CI里加上。因为CI配置里明确有一个ci.py所有CI会用到的变量都有一个显式的值。一旦你在代码里用了os.getenv(XXX)而CI里没配那直接在ci.py里就会暴露出来而不是等你跑测试的时候才报错。4.3 测试数据的生成与清理每个用例的“自包含”原则我反复跟团队强调一个原则测试用例应该自包含。什么叫自包含就是一个用例跑之前它需要的数据由它自己去造跑之后它留下的数据由它自己去清。不要去依赖“数据库里已经有一条id1的用户”这种预置数据。因为预置数据是环境状态而环境状态是不可控的。今天CI里数据库是空的明天可能有人跑挂了一半数据没清后天可能另一个用例改了那条数据。一旦发生你的用例就会以极其诡异的方式失败排错排到崩溃。实际操作上我建议用fixture来做数据的创建和清理。以pytest为例import pytest import requests pytest.fixture def created_user(): # 造数据 response requests.post(http://app:8080/api/users, json{name: test_user}) user_id response.json()[id] yield user_id # 清理数据 requests.delete(fhttp://app:8080/api/users/{user_id})这样一个用例用到用户数据时直接依赖created_userfixture数据自己造、自己清互不干扰。想给这个fixture加个“账号前缀”用来标识数据来源也方便出问题的时候排查。4.4 云端测试数据用API造数据别直接操作数据库有些团队喜欢直接在数据库里INSERT测试数据省事是真省事但有个坏处如果业务逻辑变了比如新建用户时要额外写一张表那你的INSERT语句就跟不上了而且直接在库里造的数据可能绕过了一些业务校验导致测试跑出来的结果跟真实用户操作对不上。我更推荐的是通过业务API来造数据。你用创建用户的接口来创建用户用创建订单的接口来创建订单。虽然慢一点但更接近真实用户路径不容易出现“数据库里看起来有数据、但接口查不到”的诡异问题。这在接口自动化测试里尤其重要。5. 浏览器与并发隔离Web UI自动化在CI里的特殊问题如果你做的是Web UI自动化那环境隔离还有一层特殊的挑战浏览器。这一节专门展开讲。5.1 WebDriver版本匹配最烦人的版本地狱做Selenium的人应该都被“SessionNotCreatedException”折磨过——Chrome升级了chromedriver没跟上本地还好说CI里更是一头雾水。要解决这个问题有几个方案用Selenium ManagerSelenium 4.6之后自带它会自动下载匹配的driver省心很多。或者用webdriver-manager这个库在代码里自动管理和下载driver。如果要更稳就固定浏览器版本比如Dockerfile里显式安装指定版本的Chromium和对应driver。我个人推荐给CI环境用Docker镜像来跑浏览器测试。有个现成的镜像叫selenium/standalone-chrome你直接用selenium/standalone-chrome:122.0这种带版本标签的镜像配合Selenium的Remote WebDriver去连。这样浏览器版本、driver版本、系统依赖全都在镜像里配好了CI只是负责把镜像拉下来跑而已版本地狱问题直接绕开。from selenium import webdriver from selenium.webdriver.chrome.options import Options options Options() options.add_argument(--headless) options.add_argument(--no-sandbox) options.add_argument(--disable-dev-shm-usage) driver webdriver.Remote( command_executorhttp://selenium-chrome:4444/wd/hub, optionsoptions )5.2 Headless模式与资源限制容器里跑浏览器的正确姿势在CI里跑浏览器默认一定要用headless模式否则根本没有显示环境给你弹窗口。但headless也有坑——它跟有头模式的行为偶尔会有差异比如某些元素的位置、滚动条的渲染、下载文件的处理。我建议在本地有头调通以后在CI里跑headless时重点检查几类用例涉及弹窗、涉及hover、涉及文件的。另外容器里跑浏览器要特别注意资源限制。Chrome默认每个tab一个进程如果在容器里不加--disable-dev-shm-usage很容易因为/dev/shm太小直接崩溃。上面代码里我已经写了这个参数这是无数人踩坑总结出来的经验。5.3 并发跑用例pytest-xdist与浏览器会话的相爱相杀CI里跑UI自动化比较慢大家自然会想到并发。pytest用pytest-xdist加-n auto确实能让用例飞起来但浏览器测试并发有一个问题如果你的用例共享同一个WebDriver会话那并发就白搭还会互相干扰。正确的姿势是每个用例或者每个worker进程一个独立的driver会话。pytest-xdist天然是进程级别的隔离只要你的fixture是函数级别的每个用例都会创建一个新的driver实例那并发就是安全的。但要注意两点并发数量要控制不要一下子开20个浏览器实例CI机器内存不够直接OOM。一般-n 4或者-n 8是比较合理的。如果用例都要连远程Grid那Grid那边的并发能力也要匹配否则一堆用例涌进来排队超时一堆。5.4 失败现场截图和录屏是你的救命稻草环境问题导致的失败最大的痛点是“重现不了”。本地跑一次是好的CI里红了你看日志也看不出所以然。所以强烈建议在CI里做两件事用例失败时自动截图。用例跑完自动把页面HTML dump下来。pytest里可以写一个hookimport pytest from pathlib import Path pytest.hookimpl(hookwrapperTrue) def pytest_runtest_makereport(item, call): outcome yield report outcome.get_result() if report.when call and report.failed: driver item.funcargs.get(driver) if driver: screenshot_dir Path(reports/screenshots) screenshot_dir.mkdir(parentsTrue, exist_okTrue) driver.save_screenshot(str(screenshot_dir / f{item.name}.png))有了截图和HTMLCI里的失败就不再是“玄学”你可以清楚地看到失败发生时页面到底长什么样。这一招对排查环境类问题尤其高效。6. 一套可落地的GitLab CI配置pytest Docker 独立测试库实战前面说了那么多理论和原则这一节我们来看一套能直接抄的完整配置。这套方案的核心思路是所有环境都是代码定义的测试只依赖环境变量不依赖任何机器上的隐性状态。我以GitLab CI为例GitHub Actions的原理也差不多。6.1 项目结构先看项目结构一个典型的Python Web自动化测试项目project/ ├── .gitlab-ci.yml ├── docker-compose.test.yml ├── requirements.txt ├── config/ │ ├── __init__.py │ ├── base.py │ ├── ci.py │ └── local.py ├── tests/ │ ├── conftest.py │ ├── test_api/ │ └── test_ui/ └── app/ └── main.py6.2 GitLab CI流水线配置image: python:3.11-slim services: - name: mysql:8.0 alias: mysql stages: - test before_script: - apt-get update apt-get install -y --no-install-recommends libgl1 libglib2.0-0 - pip install --no-cache-dir -r requirements.txt test: stage: test variables: APP_ENV: ci DATABASE_URL: mysqlpymysql://root:passwordmysql:3306/test_db?charsetutf8mb4 script: - python -c from app import main; main.create_tables() - pytest tests/ -n 4 --maxfail3 artifacts: when: always paths: - reports/ expire_in: 2 weeks这套配置里有几个关键点services里定义了MySQL这是GitLab CI的服务容器机制测试容器可以通过mysql这个主机名直接连上数据库端口、网络全都由CI平台帮你隔离好了。DATABASE_URL直接指向mysql:3306用的是服务容器的别名不需要任何localhost的假设。APP_ENVci会让应用加载config/ci.py里面所有配置值都是显式写好的。create_tables()里建表跑完整个流水线容器一销毁环境全没了下一次又是全新的。6.3 用docker-compose在本地复现CI环境有一个很妙的做法用同一个docker-compose.test.yml文件在本地也可以跑出一套跟CI一模一样的环境。这样你在本地复现CI问题的时候不需要再靠“猜”直接把CI那套环境拉下来跑一遍就行。version: 3.8 services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: test_db ports: - 3306:3306 test: build: . depends_on: - mysql environment: APP_ENV: ci DATABASE_URL: mysqlpymysql://root:passwordmysql:3306/test_db?charsetutf8mb4 command: sh -c python -c from app import main; main.create_tables() pytest tests/ -n 4 --maxfail3 volumes: - ./reports:/app/reports这个文件的好处是你本地需要排查CI问题的时候直接docker-compose -f docker-compose.test.yml run test跑出来的环境跟CI几乎一模一样。环境隔离的最高境界就是——环境可以被任何人在任何时间重建而不是某人机器上有。6.4 缓存依赖的取舍CI每次全量装依赖确实比较慢所以GitLab CI支持缓存。我的做法是缓存虚拟环境目录但有一个前提锁定版本的requirements必须进了仓库否则缓存可能把旧依赖带回来。cache: key: $CI_COMMIT_REF_SLUG paths: - .venv/这个缓存的key按分支区分避免不同的分支用同一份缓存互相污染。另外提醒一下缓存的是依赖安装的结果不是测试结果。测试结果绝对不能缓存否则你等于没跑测试。7. 快速排查方法论CI失败后先看这五个地方就算你做了环境隔离CI还是会失败——毕竟世上没有100%稳定的测试。但如果你能有一套高效的排查顺序每次失败都可以快速定位到根因而不是像无头苍蝇一样乱试。我这几年总结的排查顺序是这样的按优先级从高到低7.1 先看是不是环境依赖的锅打开CI日志第一眼找三样东西依赖安装阶段有没有报错服务启动阶段有没有报错环境变量相关有没有None、KeyError、Connection refused这三类错误大概率是环境隔离问题跟你的测试代码关系不大。如果确认是环境问题看一下是不是有人改了依赖版本、改了Docker镜像tag、改了CI配置。很多时候CI环境突然之间大面积飘红都是CR配置变更引起的。7.2 再看是不是测试之间互相污染如果只有个别用例失败且失败模式是“数据相关”的——比如查出来的结果多了一条、or少了某条、or“该用户已存在”——那大概率是测试数据没隔离好。排查思路是找到这个用例的依赖数据看是谁创建了它又是谁可能动过它。这类问题我推荐一个辅助手段在造数据的时候带上唯一的标记比如用户名带时间戳加随机数跑完之后去数据库里搜这个标记就能看出数据从哪里来的、被谁改了。7.3 复现优先于猜测很多人在CI失败后第一反应是“改代码”“加重试”我强烈不建议。改动之前请先尝试复现。三个复现手段按成本排序本地跑一遍这个用例用跟CI相同环境变量。用docker-compose跑一遍完全复刻CI环境。在CI里加打印、加日志跑一次带详细输出的版本。能复现的问题就能定位不能复现的问题只能等下一次出现。加重试是最low的解法——它掩盖了问题却没有解决任何东西会让系统的稳定性变得越来越差。7.4 看截图和HTML存档如果涉及UI测试直接看失败截图和HTML dump。这一步能帮你快速判断是页面没加载出来是登录态丢了还是元素真的不存在三种情况的处理思路完全不同。页面没加载出来大概率是网络、DNS、前端的依赖服务没起好——环境类问题登录态丢了大概率是cookie和session的处理在CI和本地不一致——配置类问题元素真的不存在才轮到怀疑你的选择器或者业务逻辑。7.5 别忽略“偶发失败”如果同一个用例上次红了这次绿了没有任何代码变更那它基本可以判定为“不稳定测试”而“不稳定测试”的根因90%以上还是环境问题时序、并发、数据残留、服务启动慢。我的建议是不稳定测试绝不能因为“多跑几次就过了”而放过。它会成为定时炸弹每次CI里总有几个不稳定的用例在红红绿绿大家会逐渐失去对流水的信任。正确做法是揪出根因后如果不能马上修也要先把它标记为skip或者单独跑清理出主流水线。8. 一点实际体会环境隔离的核心不是工具是意识最后说点我个人这几年做自动化测试的体会环境隔离这个问题工具和方案都是次要的真正重要的是你有没有这个意识。我见过很多团队环境隔离做到最后其实是靠“恐惧”驱动的——怕CI红所以加了各种重试、各种sleep(5)、各种“多跑几次就好了”的魔法。这些做法表面上让CI绿了但本质上只是把问题藏起来了哪天你换一台机器、换一个runner、换一个浏览器版本它又会冒出来。反过来如果你建立了环境意识做任何一件事之前都问自己一句“这个步骤依赖了什么外部状态换一台机器它还成立吗”那你会发现写测试代码的时候你会更谨慎写CI配置的时候你会更细心出现问题的时候你会更冷静。回到标题那句话你的自动化测试总在CI里失败大概率是因为你没做环境隔离。但“做环境隔离”不是一个动作不是一个工具而是一整套设计和习惯——依赖怎么锁、配置怎么管、数据怎么造、浏览器怎么跑、失败怎么查。把这一整套串起来你的CI会从“三天两头红、每次都要猜”变成“红一次就能准确定位”这才算真正做到位了。