☰
pytest实战:从unittest迁移到fixture,实现接口与UI自动化
2026/9/28 15:42:53 网站建设 项目流程

做测试自动化的朋友应该都听过 pytest,如果你还没用过它,那我强烈建议你认真看完这篇。pytest 是目前 Python 生态里最主流、最灵活的自动化测试框架,不管你是做单元测试、接口测试还是 UI 自动化,它都能帮你把用例组织得明明白白。我最早接触 pytest 的时候还在用 unittest 写用例,写起来又长又啰嗦,setup 和 teardown 来回折腾。换成 pytest 之后,最直观的感受就是代码量少了一半,断言直接用 assert 关键字,fixture 机制更是把测试数据的准备和清理变得优雅。这篇文章不扯理论,直接结合我实际项目里的经验,从安装配置、第一个用例、fixture 核武器,到接口自动化和 UI 自动化的完整落地,把 pytest 这套东西讲透。不管你是刚入行的测试新人,还是写了不少用例的老手,这篇文章应该都能帮你少走一些弯路。

1. 为什么选择 pytest:抛开 unittest 和 nose 的纠结

很多初学者会纠结该选哪个测试框架,我当年也纠结过。其实答案很明确:现在做 Python 自动化测试,选 pytest 基本不会错。它不是什么新玩意儿,但生态和社区活跃度一直排在前面,尤其在接口测试和 UI 自动化这两个领域,pytest 几乎成了事实标准。

1.1 一个简单用例,看三种框架的差距

先看一段最直观的对比。同样写一个简单的加法函数测试,用 unittest 大概是这个画风:

import unittest def add(a, b): return a + b class TestAdd(unittest.TestCase): def setUp(self): # 每次用例前的准备工作 self.a = 1 self.b = 2 def tearDown(self): # 每次用例后的清理工作 pass def test_add(self): result = add(self.a, self.b) self.assertEqual(result, 3) if __name__ == '__main__': unittest.main()

换成 pytest,同样的事情只需要这样:

def add(a, b): return a + b def test_add(): assert add(1, 2) == 3

区别是肉眼可见的:unittest 要求你写类、继承 TestCase,断言得用 self.assertEqual 这一套 API,代码量翻倍不说,可读性也差。pytest 直接写普通函数,断言就是 Python 原生的 assert 关键字,失败信息还特别详细,能自动告诉你左边等于什么、右边等于什么、中间差在哪。我当时把项目从 unittest 迁到 pytest,几百条测试用例实际改起来很快,大部分工作就是把 self.assertEqual 改成 assert,再把 setUp 和 tearDown 换成 fixture。

1.2 插件生态:从报告到重试,一站式解决

pytest 真正拉开差距的地方是插件生态。与其说 pytest 是一个框架,不如说它是一个平台。官方和社区贡献的插件覆盖了几乎所有测试场景,我日常用到的有这么几个:

  • pytest-html:生成 HTML 格式的测试报告,一条命令搞定,不需要额外写代码。
  • pytest-xdist:分布式执行用例,多核机器上能把用例跑出好几倍的加速效果。
  • pytest-rerunfailures:用例失败自动重试,做接口自动化的时候特别有用,能有效应对网络抖动。
  • pytest-cov:集成 coverage.py,查看代码覆盖率。
  • pytest-playwright:UI 自动化直接提供浏览器 fixture,后面会细讲。
  • allure-pytest:接入 Allure 报告,生成超豪华的可视化报告,适合交付给团队或客户看。

这还只是冰山一角。unittest 解决这些需求几乎都得自己造轮子,要么写一个基类封装公共逻辑,要么引入一堆第三方工具再拼拼凑凑,维护成本很高。pytest 提供了一个统一的入口,装好插件就能直接跑,这是它在工程效率上最大的优势。

1.3 社区现状与长期维护价值

还有一个很实际的考量:招人容易、资料好搜。现在简历上写熟悉 pytest 的测试工程师明显比写 unittest 的多。GitHub 上开源项目用 pytest 做 CI 的占比也非常高,JetBrains 的 PyCharm 直接内置了对 pytest 的完善支持,Visual Studio Code 的 Python 插件同样默认推荐 pytest 配置。作为测试工程师,掌握 pytest 更像是掌握一项基础技能,而不是只会一个冷门工具。

2. 环境准备与第一个用例:从 pip 到 Pycharm 跑通

先把环境搭起来。这一步看着简单,但我见过太多新手卡在这里,原因五花八门:装到了错误的 Python 环境、PyCharm 里测试运行器没配好、文件名不符合 pytest 的收集规则。一个个说。

2.1 安装 pytest 的正确姿势

安装本身不复杂,打开命令行,执行:

pip install pytest

如果你想装最新版本,或者后续还需要那些常用插件,可以一次性装齐:

pip install pytest pytest-html pytest-xdist pytest-rerunfailures

但这里有个非常关键的前提:务必先确认你当前用的是哪个 Python 环境。很多新人喜欢直接在系统 Python 里装,结果后面项目多了,A 项目要 pytest 6,B 项目要 pytest 8,冲突就来了。我的建议是从一开始就养成用虚拟环境的习惯:

python -m venv .venv source .venv/bin/activate # Windows 下是 .venv\Scripts\activate pip install pytest

装完以后验证一下版本,确认装到了正确的环境里:

pytest --version

如果命令行提示找不到 pytest,大概率是环境变量或者当前 shell 没激活虚拟环境。我自己踩过的一个坑是:明明刚 pip install 过 pytest,命令行执行 pytest 却报 command not found。后来一看,原来是 pip 装到了某个全局 Python 路径,而当前 shell 使用的是另一个 Python 解释器。解决办法很简单,用 python -m pytest 来替代 pytest 命令:

python -m pytest

用这种写法,Python 会自动从当前环境查找 pytest,基本不会出现认错环境的问题。

2.2 第一个测试用例:断言与运行方式

新建一个文件 test_demo.py,注意文件名必须以 test_ 开头,或者 _test.py 结尾,这是 pytest 的默认收集规则。文件内容可以是最简单的:

def test_demo(): assert 1 + 1 == 2

在命令行运行:

python -m pytest test_demo.py -v

-v 参数会输出详细的用例名和执行结果,你会看到:

collected 1 item test_demo.py::test_demo PASSED

写好了测试用例,用 PyCharm 跑也很方便。但这里有一个我见到频率最高的问题:PyCharm 里右键运行测试时,有时会默认调用 unittest 运行器,导致明明写的是 pytest 用例却不被识别,提示 No tests were found。解决办法是手动指定测试运行器。

打开 PyCharm 的设置界面(macOS 上是 PyCharm > Preferences,Windows 上是 File > Settings),找到 Tools > Python Integrated Tools > Testing。在默认测试运行器(Default test runner)下拉框里选择 pytest,保存。以后再右键运行测试文件,PyCharm 就会使用 pytest 运行器,并且会自动帮你生成一个运行配置,里面默认加上了 --no-header --no-summary -q 之类的参数,方便在面板里输出简洁结果。还有一个细节:如果你的项目目录结构不是标准的,记得在配置里把 Working directory 设置成项目根目录,不然 pytest 可能找不到 conftest.py 或者测试数据文件。

2.3 用例发现规则与命名约束

pytest 能自动找到你的用例,靠的是一套隐式约定,理解这套约定就能避免大量"为什么我的测试没跑"的问题。

默认规则总结成三句话,按优先级排列:

  1. 文件名匹配:文件名为 test_*.py 或 *_test.py 的会被收集,其他文件一律忽略。
  2. 函数名匹配:文件中以 test_ 开头的函数会被视为测试用例。
  3. 类名匹配:文件中以 Test 开头的类,其内部的 test_ 开头的方法会被视为测试用例,但类不能有init方法。

这套约定初看会觉得"隐式"得有点不踏实,但它的价值恰恰在零配置。你只要按规则命名,pytest 就能自动把整个项目目录扫描一遍,把所有用例找出来。我接手过一个大项目,测试用例分布在十几个目录里,靠这套规则直接全部发现了,根本不需要在配置里逐个注册,省心得很。

2.4 常用命令行参数快速上手

命令行参数是日常使用频率最高的东西,先列几个最实用的:

-v 输出更详细的用例执行信息 -k "关键词" 按表达式筛选用例,例如 -k "login or register" -m "冒号表达式" 按标记筛选用例,例如 -m "slow" -x 第一次失败就停止执行,适合快速回归 --lf 只运行上一次失败的用例 --tb=short 输出简短的 traceback 信息 -s 显示 print 输出,调试时特别有用 -q 简化输出,只显示最终结果

我调试用例的时候最常用的是 -s 加上 print,因为 pytest 默认会捕获输出,不加 -s 的话 print 内容不会显示,很容易让新手误以为代码没执行到。

3. fixture 机制:pytest 的核武器

如果说 pytest 只能选一个最值得学习的特性,我的答案一定是 fixture。很多从 unittest 转过来的同学一开始不太理解 fixture 到底是个什么东西,总觉得不如 setUp 和 tearDown 直观。但我用一句话概括:fixture 就是一个可以被测试函数自动注入的"依赖准备函数"。理解这句话,后面就顺了。

3.1 fixture 是什么:从 setup/teardown 的痛点说起

先回顾 unittest 的老写法,每一个测试类里面都要写 setUp 和 tearDown,如果几个测试类都需要准备相同的数据,就只能写一个公共基类,然后让测试类继承。这种继承关系一旦超过两层,代码就变得很难维护。更麻烦的是,setUp 和 tearDown 是成对出现的,你没法说"这个用例只需要准备 A 数据,不需要清理 A 数据,只需要清理 B 数据"。所有用例用同一套准备和清理逻辑,灵活性很差。

fixture 的写法长这样:

import pytest @pytest.fixture def user(): """准备一个测试用户""" u = {"name": "admin", "role": "admin"} return u def test_check_admin(user): assert user["role"] == "admin"

这个 user 函数就是一个 fixture。当一个测试函数的参数列表里写上了 user 这个名字,pytest 就会自动去查找同名 fixture,执行它,然后把返回值注入给测试函数。这本质上是依赖注入,而不是继承。好处很明显:每个测试函数只声明自己需要的依赖,其他的一概不管。

3.2 明确 fixture 作用域与自动使用

fixture 还有一个非常好的参数叫 scope,用来控制 fixture 的生命周期,可选值有四个:

scope 值生命周期典型场景
function(默认)每个测试用例执行前后各调用一次临时数据、独立环境
class每个测试类执行期间调用一次类级共享资源,如类级浏览器实例
module每个模块(文件)执行期间调用一次模块内共享的数据集
session整个测试会话期间只调用一次数据库连接、登录 token、全局配置

用法非常简单:

@pytest.fixture(scope="session") def login_token(): """整个测试会话只登录一次,返回 token""" token = "fake-token" return token def test_get_user_info(login_token): assert login_token

我实际项目里的经验是:如果接口测试用例非常多,每个用例都登录一次,时间成本完全无法接受。这时候把登录 token 设计成 session 级别的 fixture,登录一次,所有用例共用,整个测试套件的时间能缩短一个数量级。但对应的代价是用例之间的隔离性变差了——如果某个用例改动了用户状态,后面的用例可能会受影响。所以建议对共享的 fixture,要约定只能做只读操作,或者使用不可变的数据结构。

还有一种情况是用 autouse,它让 fixture 自动生效,甚至不需要测试函数在参数里声明:

@pytest.fixture(autouse=True) def track_running(): """自动记录当前正在执行的用例名""" print("开始执行")

outouse 适合用来统一做一些环境准备、环境检查、日志标记一类的事。但我不建议大量使用 autouse,因为它会让 fixture 的执行变得隐式,反而失去显式依赖声明带来的可读性。

3.3 conftest.py:跨文件共享的魔法

fixture 写在测试文件里只能作用于当前文件,要实现跨文件共享,pytest 给出了一个约定:conftest.py。这个文件会被 pytest 自动加载,里面的 fixture 对当前目录及其子目录下的所有测试文件都可见。

目录结构可以是这样的:

project/ ├── conftest.py # 全局共享 fixture ├── testcases/ │ ├── conftest.py # 局部共享 fixture,只对 testcases 目录生效 │ ├── test_login.py │ └── test_order.py └── data/ └── test_data.json

全局的 fixture 放在项目根目录的 conftest.py 里,比如数据库连接、全局配置、日志对象。局部业务相关的 fixture 放在对应模块目录的 conftest.py 里,比如登录的 token、创建订单的辅助函数。pytest 在查找 fixture 时,会从测试文件所在目录逐级向上查找 conftest.py,找到一个就用一个,所以同名 fixture 里,离测试文件更近的那一个优先生效。

这里有个非常隐蔽的坑:conftest.py 文件本身不会被收集为测试用例,如果你在里面写了测试函数,不会被执行。另外,conftest.py 这个名字不能改,改了 pytest 就不认了。

3.4 参数化:用一组数据测多个场景

接口测试里最常见的需求就是:同一个接口,用多组入参验证不同场景。这时候就要用参数化。

import pytest import requests @pytest.mark.parametrize("account, password, expected_code", [ ("admin", "correct", 200), ("admin", "wrong", 401), ("", "", 400), (None, "password", 422), ]) def test_login_cases(account, password, expected_code): resp = requests.post("http://localhost:8000/api/login", json={"account": account, "password": password}) assert resp.status_code == expected_code

这样写一遍,pytest 会自动生成 4 条用例记录,每条用例的失败信息都是独立的,互不影响。多参数可以组合使用:

@pytest.mark.parametrize("env", ["test", "dev"]) @pytest.mark.parametrize("user", ["admin", "guest"]) def test_combination(env, user): pass

这两层参数化会产生笛卡尔积的效果,2 × 2 = 4 条用例。参数多的时候要留意组合数,我自己遇到过 3 层参数化直接生成几百条用例的情况,跑起来一时半会儿结束不了,后来才学会用 -k 表达式筛选特定参数组合先做快速验证。

4. 接口自动化实战:从零搭建一个可维护的测试体系

聊完 fixture 的原理,我们来点实际的。我根据自己做过的一个项目,完整拆解一下用 pytest 做接口自动化测试的落地过程。这个项目的背景是:被测系统是一个前后端分离的 Web 服务,后端提供 RESTful API,大概三十多个接口,涉及登录、用户、订单、支付几个核心模块。团队希望在版本迭代时能快速回归接口功能。

4.1 目录结构与依赖设计

好的测试项目,目录结构一开始就要设计好,不能想到哪写到哪。我常用的是这套结构:

api_test_project/ ├── requirements.txt ├── pytest.ini ├── conftest.py ├── api/ # 封装接口请求 │ ├── __init__.py │ ├── base.py │ ├── user_api.py │ └── order_api.py ├── data/ # 测试数据 │ ├── user_data.json │ └── order_data.json ├── testcases/ # 测试用例 │ ├── __init__.py │ ├── test_login.py │ ├── test_user.py │ └── test_order.py ├── utils/ # 通用工具 │ ├── __init__.py │ └── helpers.py └── reports/ # 测试报告输出

三层分离的思想:api 层负责和 HTTP 接口打交道,testcases 层只关注业务断言,data 层负责测试数据。这样分工的好处是,接口的 URL 改了只需要改 api 层,字段校验逻辑变了只需要改 testcases 层,新增测试数据只需要在 data 层加数据。

requirements.txt 里把依赖写清楚:

pytest>=8.0 requests>=2.31 pytest-html>=4.0 pytest-rerunfailures>=13.0 pytest-xdist>=3.5

pytest.ini 配置文件里可以放一些固定参数,避免每次都在命令行里重复敲:

[pytest] testpaths = testcases addopts = -v --tb=short --html=reports/result.html --self-contained-html

注意这里面加了一个 --self-contained-html 参数,它的作用是让 pytest-html 生成的报告里带上所有 CSS 和 JS,单独打开 HTML 文件就能看完整样式,而不是依赖一堆外部资源文件。不加这个参数,报告发给同事的时候经常样式丢失,坑得很。

4.2 接口测试断言技巧:不只是状态码

接口测试第一反应是断言状态码 200,但只断状态码远远不够。我见过最典型的翻车场景是:接口返回了 200,但业务码是错误码,响应体里是 { "code": 50001, "message": "参数错误" }。所以断言要分层,先验证 HTTP 状态码,再验证业务码,再验证关键字段值。

我习惯用 requests 库封装一个简单的基础请求模块:

# api/base.py import requests class BaseAPI: def __init__(self, base_url, token=None): self.base_url = base_url self.session = requests.Session() if token: self.session.headers.update({"Authorization": f"Bearer {token}"}) def get(self, path, **kwargs): return self.session.get(self.base_url + path, timeout=10, **kwargs) def post(self, path, **kwargs): return self.session.post(self.base_url + path, timeout=10, **kwargs)

超时设置是重中之重,不加 timeout 的请求,一旦服务端响应很慢,整个测试会一直卡在那里,而且毫无提示。我这里统一设置 10 秒超时,还可以结合 pytest-rerunfailures 做失败重试,应对偶发性的网络抖动。

在断言业务字段的时候,我后来发现一个很实用的简化思路:不需要每次都写复杂的嵌套取值代码。如果是 JSON 响应,直接取键即可:

def test_create_order_return_order_id(): resp = order_api.create_order(...) assert resp.status_code == 200 body = resp.json() assert body["code"] == 0 assert body["data"]["orderId"] > 0

但对于嵌套特别深的响应结构,与其写一长串 ["data"]["xxx"]["yyy"],不如写一个工具函数来取动态路径:

# utils/helpers.py def get_value_by_path(data, path): """按点号分隔的路径取值,如 "data.order.id" """ keys = path.split(".") cur = data for k in keys: if isinstance(cur, dict) and k in cur: cur = cur[k] else: return None return cur

然后在测试里:

assert get_value_by_path(body, "data.order.id") == expected_order_id

这个思路是从 JSONPath 简化来的,代码短了一大截,够用且好维护。

4.3 数据驱动:从 JSON 文件读取测试数据

工程化到一定规模后,参数化数据不应该写死在代码里,而是放进数据文件。以用户模块为例,data/user_data.json 里存放:

{ "invalid_user": [ {"account": "", "password": "123456", "expected_code": 400}, {"account": "admin", "password": "", "expected_code": 400}, {"account": "admin", "password": "wrong", "expected_code": 401} ] }

测试文件里读取这些数据再交给参数化:

import json import pytest from api.user_api import UserAPI with open("data/user_data.json", encoding="utf-8") as f: user_data = json.load(f) @pytest.mark.parametrize("case", user_data["invalid_user"]) def test_invalid_user(case): resp = UserAPI.login(case["account"], case["password"]) assert resp.status_code == case["expected_code"]

这种方式有一个隐性问题要注意:模块加载时读取 JSON 文件意味着文件路径是相对于进程的工作目录。如果 pytest 不是从项目根目录启动的,路径会报错。我的经验是始终在 pytest.ini 里配置好 testpaths,或者用 pathlib 基于当前文件位置计算绝对路径:

import json from pathlib import Path DATA_DIR = Path(__file__).resolve().parent.parent / "data" with open(DATA_DIR / "user_data.json", encoding="utf-8") as f: user_data = json.load(f)

用 Path(file) 定位数据文件,不会因为当前工作目录不同而找不到文件,这个细节能省掉很多莫名其妙的报错。

4.4 测试报告与持续集成的衔接

接口测试跑完之后,第一件事就是看报告。pytest-html 生成的报告比较朴素,但胜在零配置。命令行直接跑:

python -m pytest --html=reports/result.html --self-contained-html

跑完以后,reports/result.html 里会有每个用例的通过/失败状态、执行时间、失败时的 traceback 信息,足够日常回归使用。

如果团队或者客户需要更美观的报告,Allure 是另一个方案。Allure 本质上是一个报告服务,pytest 只负责生成包含测试结果的数据文件,再由 Allure 命令行工具渲染成网页。基本步骤是:

pip install allure-pytest

在 pytest.ini 里加上 --alluredir=reports/allure-results,跑完测试后,在项目根目录执行:

allure generate reports/allure-results -o reports/allure-report --clean allure open reports/allure-report

前面那个 --clean 参数很关键,它先清空输出目录再生成,避免旧报告残留导致渲染错乱。Allure 的报告比 pytest-html 丰富很多,能看到用例分类、历史趋势、执行时间分布。但它的依赖也重,本地得装 Java 和 allure 命令行工具,所以小项目我更推荐先用 pytest-html,等报告确实不够用了再上 Allure。

在 CI 里跑接口自动化也简单。以常见的 Jenkins 或 GitLab CI 为例,核心就是执行同一行命令,再把报告文件归档。我一般会在 CI 脚本里加上这两个参数:

python -m pytest --maxfail=5 --reruns=2 --html=reports/result.html --self-contained-html

--maxfail=5 表示失败 5 个用例就停止,避免大量失败用例刷屏浪费时间;--reruns=2 表示每个失败用例自动重试两次,可以在网络抖动场景下减少偶发失败。这里有一个点想提醒大家:重试适合用在稳定的接口回归,如果接口本身有 bug,重试只是拖慢测试时间,不会有任何帮助。CI 上建议把重试次数调到 1 或者 0,真正发现问题就让它快速失败。

4.5 fixture 的接口实战组合案例

把前面的内容串联成一个完整场景:测试下单流程,需要先登录拿到 token,再用 token 创建订单。

import pytest from api.user_api import UserAPI from api.order_api import OrderAPI @pytest.fixture(scope="session") def login_token(): """整个测试会话期间只登录一次,返回 token""" resp = UserAPI.login("admin", "password") assert resp.status_code == 200 assert resp.json()["code"] == 0 return resp.json()["data"]["token"] @pytest.fixture() def order_api(login_token): """每个用例创建一个独立的 OrderAPI 实例""" return OrderAPI(base_url="http://localhost:8000", token=login_token) def test_create_order(order_api): resp = order_api.create_order({"productId": 1, "quantity": 2}) assert resp.status_code == 200 body = resp.json() assert body["code"] == 0 assert body["data"]["orderId"] > 0 def test_query_order(order_api): resp = order_api.query_order("order-123") assert resp.status_code == 200 assert resp.json()["code"] == 0

注意到一个细节:order_api 这个 fixture 是 function 作用域,每个用例单独创建 OrderAPI 实例,但 token 是 session 作用域,整个测试过程只登录一次。这种"大共享 + 小隔离"的设计是我实际操作下来最舒服的状态:既省了重复登录的时间,又避免了用例之间因共用实例而互相污染状态的情况。

5. UI 自动化扩展:pytest 是 selenium/playwright 的最佳搭档

接口自动化到位之后,UI 自动化是很多团队的下一步。这时候 pytest 的价值同样明显,因为 Selenium 和 Playwright 本身都不是测试框架,它们只是自动化操作浏览器的库,测试的组织、断言、报告、重试还得靠 pytest 来管。

5.1 为什么 UI 自动化要用 pytest 管理

很多新手刚开始玩 Selenium,写出来的脚本是线性代码:打开浏览器、操作、关闭、跑完。这种脚本最大的问题是:没有用例概念,没有断言体系,出错就不知道自己错在哪。pytest 正好补上这块拼图。

一个经典的 Selenium + pytest 的 fixture 结构长这样:

import pytest from selenium import webdriver @pytest.fixture() def driver(): driver = webdriver.Chrome() driver.implicitly_wait(10) yield driver driver.quit() def test_search_keyword(driver): driver.get("https://example.com") assert "Example" in driver.title

这里用到 yield 的关键用法:yield 之前的代码在用例开始前执行,yield 之后的代码在用例结束后执行。这就完美替代了 unittest 里的 setUp 和 tearDown。每个用例拿到的 driver 都是独立的,用例结束后浏览器会自动关闭,不会出现浏览器越开越多、内存爆掉的问题。

5.2 playwright 的好搭档:更高的集成度

最近一两年,Playwright 的热度非常高,它的浏览器操作更稳定,而且天生支持多浏览器和并发。pytest-playwright 插件直接把两者打通了:安装插件之后,pytest 会自动管理浏览器生命周期,测试函数里直接声明 page 参数即可。

import pytest def test_example(page): page.goto("https://example.com") assert page.title() == "Example Domain"

这背后和 Selenium 的思路是一致的,只是通过 pytest-playwright 把浏览器和页面对象的创建、回收全部接管了。试想,如果用纯 Playwright 脚本手动创建浏览器上下文,代码会多一大截,而且每个用例都要重复处理启动和关闭。用 pytest 之后这些操作都变成了一件"默认已经准备好"的事。UI 自动化的用例组织、断言、报告体系,全部可以直接复用 pytest 的能力。从维护成本看,这是目前性价比最高的 UI 自动化落地方案之一。

5.3 避免的坑:用例隔离与浏览器实例复用

UI 自动化有一个真实的教训:为了追求性能,把浏览器实例的作用域设成 session,所有用例共用同一个浏览器。这种做法最开始跑得挺快,但用例一旦变多,问题就来了——上一个用例的登录状态、弹窗、页面跳转都会对下一个用例造成干扰,用例失败率直线上升。最难受的是,这些问题还不是每次必现的,排查起来特别折磨人。

我的建议很明确:UI 自动化默认用 function 作用域,每个用例一个干净的浏览器环境。虽然启动浏览器的开销高一些,但换来的是用例之间的完全隔离。等用例量级上来之后,再考虑用 pytest-xdist 做并发执行来弥补性能开销,而不是牺牲隔离性。并发跑多个浏览器时,每个 worker 进程里都有独立的浏览器实例,互不干扰。

6. 常见问题与排查技巧实录

最后这一部分,我把自己和周围同事踩过的坑集中整理一遍。很多问题你搜资料也能搜到,但散落在不同地方,这里我尽量一条条说清楚。

6.1 PyCharm 运行不识别测试用例

症状:在 PyCharm 里右键运行一个 test_demo.py 文件,控制台提示 No tests were found,但明明文件里有 test_ 开头的函数。

排查思路按优先级排:

  1. 检查运行配置里的测试运行器。最常见的原因就是默认运行器还是 unittest。按照前面说的,在 Settings > Tools > Python Integrated Tools > Testing 里改成 pytest。
  2. 检查文件名。文件是不是叫 test_demo.py?py 文件名的前缀或后缀必须符合规则。
  3. 如果文件在某个包目录下,确认目录下有没有init.py,没有的话 pytest 也能识别,但某些 IDE 的右键运行行为会变得奇怪。
  4. 检查 pytest 是否安装到了当前环境。命令行执行 python -m pytest test_demo.py,如果命令行能跑通、IDE 不能,那就是 IDE 的解释器配置不对,确认 PyCharm 里选的是同一个虚拟环境。

另外,如果项目里同时有 unittest 和 pytest 混用,右键运行的时候偶尔会跑到 unittest 运行器去收集用例,然后报错。这种情况直接把运行器统一成 pytest 就好。

6.2 fixture 重名和 conftest 加载顺序困惑

症状:明明在 conftest.py 里定义了一个 fixture,但测试文件里死活调不到,或者两个同名 fixture 互相覆盖,不知道哪个生效。

首先,pytest 查找 fixture 的机制是从测试文件所在目录开始往上查找。比如测试文件在 project/testcases/test_user.py,pytest 会先找 project/testcases/conftest.py,再找 project/conftest.py。如果上下层都定义了同名 fixture,离测试文件更近的那个会获胜。这不是 bug,而是刻意设计的层级覆盖能力。

其次,如果你非常确定 conftest.py 就在那,但就是调不到,先检查一下文件名拼写是否准确,conftest.py 拼错一个字母,pytest 就完全不认它了。还有一个易错点:同一级目录下有多个 conftest.py 且内容有误,pytest 在导入时可能直接报错,但报错信息会被隐藏,表现出来是"fixture 找不到"。遇到这种情况,先试试在命令行跑一遍 pytest --collect-only,它会强制 pytest 去加载所有测试模块和 conftest,并显示详细导入错误。

6.3 断言失败看不懂

症状:用例失败,控制台报了一大堆 traceback,看半天没明白断言到底哪一步错了。

pytest 的断言失败信息本身带 diff 能力,但如果断言在复杂的表达式里,比如 assert a == b and c == d,它只会告诉你整条表达式为 False,但不告诉你哪一段失败。解决办法是拆成多条断言,一条断言只验证一件事:

assert resp.status_code == 200 assert resp.json()["code"] == 0 assert resp.json()["data"]["count"] >= 1

这样失败信息立刻能定位。还可以加自定义消息:

assert body["code"] == 0, f"业务码异常,期望 0,实际 {body['code']},响应体:{body}"

实际排查时,--tb=short 能让 traceback 信息精简很多,--locals 能额外显示每一条局部变量的值,这两个参数配合基本能解决九成的"不知道失败现场长什么样"的问题。

6.4 参数化一多就混乱

症状:一个参数化的用例生成了几十条记录,跑的时候分不清哪组数据对应哪条失败。

解决方案是给每组参数起一个可读的 id。pytest 支持在参数中直接指定 id:

@pytest.mark.parametrize("account,password,expected_code", [ pytest.param("admin", "correct", 200, id="correct_password"), pytest.param("admin", "wrong", 401, id="wrong_password"), pytest.param("", "", 400, id="empty_fields"), ]) def test_login(account, password, expected_code): ...

如果不使用 ids,pytest 会自动给用例名加上参数值,但如果参数太长或者包含不可读的特殊字符,报告里会很难看。加了 id 之后,报告里显示的就是 test_login[correct_password] 这种一目了然的名字,失败定位快很多。

6.5 测试顺序依赖问题

症状:单独跑某个用例能通过,全量跑却失败,说明用例之间有顺序依赖。

这是测试领域最经典的禁忌。pytest 默认按照文件名的字典序执行测试,例如 test_b.py 会在 test_a.py 之后执行,同文件内按函数定义顺序执行。如果你在某个用例里写了改变全局状态的操作,就可能影响后续用例。解决办法是:每个用例必须自包含前置条件和清理逻辑,能用 fixture 就用 fixture,用 yield 清理状态。对于需要有序执行的场景,不要因为"习惯上应该先登录再测下单"就把用例写在一个文件里,而是用 fixture 的依赖关系来保证。如果实在难以避免,才使用 pytest-ordering 插件通过 @pytest.mark.order(n) 指定执行顺序,但这属于非常规手段,能不用就不用。

写在最后的一点私货

说句个人体会:pytest 上手成本很低,但能走多远取决于你对 fixture 和工程化的理解。很多人学会一套接口自动化模板之后就一直用那一套,时间长了就会觉得 pytest 不过如此。其实它的能力远不止于此,从插件开发到自定义标记、从 hooks 到集成报告服务,每一层都有值得深挖的东西。我自己实操下来,最推荐的做法是拿一个真实的项目练手,从几十条用例开始,逐步加上参数化、fixture 分层、数据驱动和 CI 集成,你会发现测试框架这件事,越用越顺手。最后再分享一个小技巧:去 pytest 的官方文档里翻一翻内置 fixtures 的列表,tmp_path、capsys、monkeypatch 这些内置工具非常能打,用好它们能省掉很多自造轮子的时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询