Python pytest 测试框架实战:从用例编写到接口自动化与 CI 集成
2026/9/20 4:56:07 网站建设 项目流程

测试框架选型这件事,我前前后后换过三轮。最早用unittest,写一个用例要继承TestCase、方法名必须test_开头、断言还得记一堆assertEqual/assertTrue的变体,写起来像在填表格。后来团队里有人推nose,结果它停更了。直到切到pytest,才真正体会到什么叫"写测试像写普通函数"。这篇就把我从零上手pytest到在真实项目里跑通接口自动化、数据驱动、报告输出的完整路径拆开讲,不管你是刚学 Python 想入门测试,还是已经写过一些用例想系统梳理,都能直接抄作业。

pytest是 Python 生态里目前使用最广的测试框架,核心卖点就三个:用原生assert断言、用函数写用例、用 fixture 管理依赖。它不像unittest那样强制你套模板,也不像某些框架需要大量配置文件才能跑起来。一个.py文件、一个test_开头的函数,pytest就能识别并执行。下面我按"环境搭建 → 用例编写 → 参数化 → fixture → 接口实战 → 报告与 CI"这条主线,把每个环节的坑和技巧都摊开说。

1. 环境搭建与第一个可运行用例

1.1 Python 环境准备:别在系统 Python 上直接装

很多人第一步就踩坑:直接用系统自带的 Python 装pytest,结果项目一多,包版本互相打架。我的习惯是每个项目一个虚拟环境。Windows 上用venv,命令如下:

# 创建虚拟环境 python -m venv venv # 激活(Windows) venv\Scripts\activate # 激活(macOS / Linux) source venv/bin/activate

激活后命令行前面会出现(venv)前缀,这时候装的包只影响当前项目。如果你用conda,那就conda create -n pytest-demo python=3.11conda activate pytest-demo,效果一样。

提示:Python 版本建议 3.8 以上,pytest7.x 对 3.7 的支持已经收尾,新项目直接上 3.10 或 3.11 更省心。

1.2 安装 pytest 与目录约定

pytest就一行:

pip install pytest

验证是否装好:

pytest --version

能打印出版本号就说明 OK。接下来是目录结构,pytest对目录没有强制要求,但有一套约定俗成的命名规则,遵守它能让框架自动发现用例,省去手动指定:

project/ ├── tests/ │ ├── test_login.py │ ├── test_order.py │ └── conftest.py ├── src/ │ └── your_module.py └── pytest.ini

关键规则有三条:文件名以test_开头或_test结尾;类名以Test开头且不能有__init__方法;函数名以test_开头。只要满足这些,在项目根目录敲pytest就能自动收集所有用例。

1.3 第一个用例:感受和 unittest 的差异

先写一个最简单的:

# tests/test_demo.py def add(a, b): return a + b def test_add(): assert add(1, 2) == 3 def test_add_negative(): assert add(-1, -1) == -2

运行pytest tests/test_demo.py -v,输出里每个用例一行,PASSED绿色。对比unittest,这里没有类、没有继承、没有self.assertEqual,就是普通函数加assertpytest重写 assert 语句,失败时自动展开表达式,比如assert add(1,2) == 4会告诉你左边是3、右边是4,比unittest3 != 4直观得多。

注意:pytest的 assert 重写只对测试文件生效,业务代码里的 assert 不会重写,所以别指望它在生产代码里给你详细报错。

2. 用例组织:从函数到类,再到跳过与预期失败

2.1 函数式 vs 类式:什么时候用哪个

pytest支持两种写法。函数式适合无状态、独立的用例,比如工具函数测试、纯逻辑校验。类式适合一组相关用例共享数据或生命周期的场景,比如一个登录模块的多个分支。

class TestLogin: def test_success(self): assert login("admin", "123456") is True def test_wrong_password(self): assert login("admin", "wrong") is False

类式写法里,pytest不会自动调用__init__,所以别在测试类里写构造函数。如果需要在类级别做初始化,用setup_method/teardown_method,或者更推荐用 fixture(后面讲)。

2.2 跳过、预期失败与条件跳过

真实项目里不是所有用例都能随时跑。比如依赖外部服务的用例,本地没网就得跳过:

import pytest import sys @pytest.mark.skip(reason="接口未就绪,暂不执行") def test_unfinished(): pass @pytest.mark.skipif(sys.version_info < (3, 10), reason="需要 3.10+") def test_new_syntax(): pass @pytest.mark.xfail(reason="已知 bug,修复前预期失败") def test_known_bug(): assert 1 == 2

skip是直接不跑,xfail是跑了但预期失败——如果它意外通过了,会显示XPASS,提醒你该把标记去掉了。这个机制在回归测试里特别有用:已知缺陷先挂xfail,修好后XPASS就是修复信号。

2.3 用例执行的顺序与依赖问题

pytest默认按文件内定义顺序执行,但不保证跨文件顺序。很多人想用pytest-ordering插件强制顺序,我的建议是:尽量别让用例之间有依赖。一个用例的通过与否不应该影响另一个用例。如果确实需要共享状态,用 fixture 的scope控制,而不是靠执行顺序。

实操心得:我见过团队用pytest-ordering把 200 个用例串成一条链,结果中间一个失败后面全挂,排查成本极高。正确做法是每个用例自己准备数据、自己清理,独立性优先。

3. 参数化:一份逻辑跑十组数据

3.1 parametrize 的基本用法

参数化是pytest最实用的功能之一。比如测试一个除法函数,要覆盖正常、除零、负数等多种情况:

import pytest @pytest.mark.parametrize("a, b, expected", [ (10, 2, 5), (9, 3, 3), (-6, 2, -3), (0, 5, 0), ]) def test_divide(a, b, expected): assert a / b == expected

运行后你会看到 4 个独立用例,每个都有自己的通过/失败状态。这比写 4 个函数清爽得多,而且失败时能精确定位到哪组数据

3.2 参数化叠加与 id 命名

多个parametrize叠加会做笛卡尔积:

@pytest.mark.parametrize("x", [1, 2]) @pytest.mark.parametrize("y", [10, 20]) def test_multi(x, y): assert x * y > 0

这会生成 4 个用例。默认用例 id 是x1-y10这种,可读性差。用ids参数自定义:

@pytest.mark.parametrize("a, b, expected", [ (10, 2, 5), (9, 3, 3), ], ids=["整除", "整除2"]) def test_divide(a, b, expected): assert a / b == expected

报告里就会显示test_divide[整除],一眼看懂。

3.3 数据驱动的三种数据来源

参数化的数据可以来自三处:代码内联、外部文件、fixture。内联适合少量固定数据;外部文件(JSON/YAML/CSV)适合大量数据或需要非技术人员维护的场景;fixture 适合数据需要动态生成的情况。

import json import pytest def load_cases(): with open("cases.json", encoding="utf-8") as f: return json.load(f) @pytest.mark.parametrize("case", load_cases(), ids=lambda c: c["name"]) def test_from_json(case): assert case["input"] == case["expected"]

注意:load_cases()收集阶段就执行了,如果文件不存在会直接报收集错误,而不是用例失败。所以外部数据文件要纳入版本管理,别放在.gitignore里。

4. fixture:pytest 的灵魂,也是最大的坑区

4.1 fixture 解决什么问题

传统unittest里,前置和后置逻辑靠setUp/tearDown,粒度粗、复用难。fixture把"准备数据"和"清理数据"封装成可复用的函数,用例通过参数名声明依赖,pytest自动注入。

import pytest @pytest.fixture def db_connection(): conn = create_connection() yield conn conn.close() def test_query(db_connection): result = db_connection.query("SELECT 1") assert result is not None

yield之前是 setup,之后是 teardown。即使用例失败,teardown 也会执行。

4.2 scope:控制 fixture 的生命周期

fixturescope决定它多久重建一次,这是性能与隔离的权衡

scope重建时机适用场景
function每个用例一次(默认)需要完全隔离的数据
class每个测试类一次类内共享的轻量资源
module每个文件一次模块级配置
session整个测试会话一次数据库连接、浏览器实例
@pytest.fixture(scope="session") def browser(): driver = start_browser() yield driver driver.quit()

session级别的浏览器只启动一次,几百个 UI 用例能省下大量时间。但代价是用例之间可能互相污染,比如前一个用例改了页面状态。所以session级别只放"只读"或"可重置"的资源。

4.3 conftest.py:fixture 的共享中心

conftest.pypytest的特殊文件,里面的 fixture 对同目录及子目录的用例自动可见,不需要 import。项目里通常这样分层:

tests/ ├── conftest.py # 全局 fixture:配置、日志 ├── api/ │ ├── conftest.py # API 专用:token、session │ └── test_user.py └── ui/ ├── conftest.py # UI 专用:browser └── test_login.py

实操心得:conftest.py不要写业务逻辑,只放 fixture 和 hook。我见过把断言工具、请求封装全塞进去的,最后这个文件 800 行,谁都不敢动。工具函数放utils/conftest.py只负责"组装"。

4.4 autouse 与 fixture 依赖

autouse=True让 fixture 自动应用到作用域内所有用例,适合日志、计时这类横切关注点:

@pytest.fixture(autouse=True) def log_test_name(request): print(f"\n开始执行: {request.node.name}") yield print(f"执行结束: {request.node.name}")

fixture 之间也能互相依赖,一个 fixture 的参数可以是另一个 fixture:

@pytest.fixture def token(api_client): resp = api_client.post("/login", json={"user": "admin"}) return resp.json()["token"] @pytest.fixture def auth_client(api_client, token): api_client.headers["Authorization"] = f"Bearer {token}" return api_client

这种链式依赖让"登录 → 拿 token → 带 token 请求"的流程变得非常清晰。

5. 接口自动化实战:从单请求到完整链路

5.1 请求封装:别在每个用例里写 requests

接口测试的核心是请求发送 + 响应断言。直接用requests也能跑,但每个用例都写requests.get(url, headers=...)会重复到吐。正确做法是封装一个ApiClient

import requests class ApiClient: def __init__(self, base_url): self.base_url = base_url self.session = requests.Session() def request(self, method, path, **kwargs): url = f"{self.base_url}{path}" resp = self.session.request(method, url, **kwargs) return resp def get(self, path, **kwargs): return self.request("GET", path, **kwargs) def post(self, path, **kwargs): return self.request("POST", path, **kwargs)

Session的好处是自动保持 cookie,登录后的会话能复用。然后把它做成 fixture:

@pytest.fixture(scope="session") def api_client(): return ApiClient("https://api.example.com")

5.2 响应断言:状态码只是起点

新手常犯的错是只断言resp.status_code == 200。真实接口测试要覆盖:状态码、业务码、关键字段、数据类型、响应时间。

def test_get_user(api_client): resp = api_client.get("/users/1") assert resp.status_code == 200 body = resp.json() assert body["code"] == 0 assert body["data"]["id"] == 1 assert isinstance(body["data"]["name"], str) assert resp.elapsed.total_seconds() < 2

resp.elapsed是响应耗时,加个上限断言能提前发现性能退化。

5.3 链路测试:登录 → 下单 → 查询

单个接口测完,真正有价值的是业务链路。用 fixture 串联:

@pytest.fixture def logged_client(api_client): resp = api_client.post("/login", json={"user": "admin", "pwd": "123456"}) token = resp.json()["data"]["token"] api_client.session.headers["Authorization"] = f"Bearer {token}" return api_client def test_order_flow(logged_client): # 下单 create = logged_client.post("/orders", json={"sku": "A001", "qty": 2}) assert create.status_code == 200 order_id = create.json()["data"]["order_id"] # 查询 query = logged_client.get(f"/orders/{order_id}") assert query.json()["data"]["status"] == "created"

链路测试要注意数据清理。下单会产生脏数据,用yieldfixture 在用例结束后删掉:

@pytest.fixture def temp_order(logged_client): order_id = create_order(logged_client) yield order_id logged_client.delete(f"/orders/{order_id}")

5.4 接口测试的常见坑

坑一:环境切换。测试环境、预发环境、生产环境的 base_url 不同,硬编码会出事。用命令行参数或环境变量:

# conftest.py def pytest_addoption(parser): parser.addoption("--env", default="test", choices=["test", "staging"]) @pytest.fixture(scope="session") def base_url(pytestconfig): env = pytestconfig.getoption("--env") return {"test": "https://test.api.com", "staging": "https://stg.api.com"}[env]

运行时pytest --env=staging即可切换。

坑二:token 过期session级别的 token 如果有效期短,跑到一半就失效。解决办法是在 fixture 里判断过期并刷新,或者把 token 的 scope 降到module

坑三:并发下的数据冲突。多个用例同时操作同一条数据会互相干扰。用uuid生成唯一标识,或者每个用例创建自己的数据。

6. 报告输出与持续集成

6.1 内置报告与详细程度控制

pytest自带报告够用,关键是-v-s的组合:

pytest -v # 每个用例一行 pytest -s # 显示 print 输出 pytest -v -s # 两者都要 pytest --tb=short # 精简 traceback pytest --tb=line # 只显示失败行 pytest -x # 第一个失败就停 pytest --maxfail=3 # 失败 3 个就停 pytest -k "login" # 只跑名字含 login 的 pytest -m "smoke" # 只跑 smoke 标记的

-k-m是日常用得最多的筛选手段。-m需要先注册标记,在pytest.ini里:

[pytest] markers = smoke: 冒烟测试 regression: 回归测试 slow: 慢速用例

然后给用例加@pytest.mark.smoke,就能按标记分组执行。

6.2 生成 HTML 报告

内置报告在 CI 里看还行,但要发给团队就得 HTML。装插件:

pip install pytest-html

运行:

pytest --html=report.html --self-contained-html

--self-contained-html把 CSS/JS 内联进去,单个文件就能打开,不用带一堆资源目录。

6.3 失败重试与并行执行

接口测试受网络波动影响,偶发失败很常见。pytest-rerunfailures能自动重试:

pip install pytest-rerunfailures pytest --reruns 2 --reruns-delay 1

失败后等 1 秒重试 2 次,还是失败才算真失败。但要注意:重试会掩盖真实 bug,只对网络类用例开,逻辑类用例别开。

用例多了执行慢,用pytest-xdist并行:

pip install pytest-xdist pytest -n 4

4 个进程并行跑。但并行会打乱执行顺序,有依赖的用例不能并行,而且共享资源(如数据库)要做好隔离。

6.4 接入 CI 的最小配置

以 GitHub Actions 为例,一个最小工作流:

name: pytest on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.11" - run: pip install -r requirements.txt - run: pytest --html=report.html --self-contained-html - uses: actions/upload-artifact@v4 if: always() with: name: pytest-report path: report.html

if: always()保证用例失败时报告也上传,方便排查。

实操心得:CI 里跑测试一定要加--maxfail,否则一个环境问题导致 500 个用例全挂,日志能刷几千行。我一般设--maxfail=10,前 10 个失败足够定位问题。

7. 那些文档里不写、但一定会遇到的坑

7.1 import 路径问题

最常见的报错:ModuleNotFoundError: No module named 'src'。原因是pytest执行时的工作目录和 Python 的sys.path不一致。三种解法:

  1. 在项目根目录放conftest.py(空文件也行),pytest会把根目录加入sys.path
  2. pytest.ini配置pythonpath = .(需要pytest7.0+)。
  3. 把项目装成可编辑包:pip install -e .

我推荐第二种,最干净。

7.2 fixture 找不到的排查顺序

fixture 'xxx' not found时,按这个顺序查:

  • fixture 是否定义在conftest.py或当前测试文件里?
  • conftest.py的目录层级是否覆盖了当前用例?
  • fixture 名字拼写是否一致(大小写敏感)?
  • 是否在conftest.py里但被if __name__包住了?

90% 的情况是层级问题——子目录的conftest.py对父目录用例不可见。

7.3 断言失败信息不够详细

assert resp.json()["data"]["id"] == 1失败时,如果data里没有id,会报KeyError而不是断言失败。用.get()加默认值,或者先断言结构:

body = resp.json() assert "data" in body, f"响应缺少 data 字段: {body}" assert body["data"].get("id") == 1, f"id 不匹配: {body['data']}"

body打进断言消息,失败时一眼看到实际返回。

7.4 测试数据污染

跑完测试数据库里一堆脏数据,下次跑就冲突。两个原则:用例自建数据、用例自清数据。用yieldfixture 保证清理,或者用事务回滚——每个用例在事务里跑,结束回滚,数据库永远干净。

@pytest.fixture def db_session(): session = Session() session.begin_nested() yield session session.rollback() session.close()

7.5 慢用例拖垮整个套件

一个用例跑 30 秒,100 个就是 50 分钟。先找出慢用例:

pytest --durations=10

打印最慢的 10 个。然后针对性优化:能 mock 的外部调用就 mock,能并行的用xdist,实在慢的标记@pytest.mark.slow放到 nightly 跑。

8. 从会写到写好:几个提升质量的习惯

8.1 一个用例只验证一件事

test_login_and_order_and_pay这种用例,失败了你不知道是哪步挂的。拆成三个独立用例,失败定位成本从"翻日志"降到"看名字"。

8.2 用例名要能当文档读

test_1test_case2这种名字等于没写。好的名字:test_login_with_wrong_password_returns_401,不看代码就知道测什么。

8.3 用 fixture 消除重复,而不是复制粘贴

看到两个用例里有相同的 5 行准备代码,就该抽 fixture 了。重复的准备代码是维护噩梦——改一处漏一处。

8.4 断言要有"业务含义"

assert resp.status_code == 200是技术断言,assert body["data"]["balance"] == 100是业务断言。两者都要有,但业务断言才是真正验证功能正确性的。

8.5 定期清理失效用例

被注释掉的用例、永远 skip 的用例、xfail 了半年没修的用例,都是技术债。每个月过一遍,该删删、该修修。

9. 进阶方向:插件生态与框架扩展

pytest的插件生态是它最大的护城河。除了前面提到的htmlxdistrerunfailures,还有几个值得关注:

  • pytest-cov:覆盖率统计,pytest --cov=src --cov-report=html,生成覆盖率报告。
  • pytest-mock:集成unittest.mock,用mockerfixture 替代手动 patch。
  • pytest-asyncio:测试异步代码,加@pytest.mark.asyncio即可。
  • pytest-bdd:行为驱动开发,用 Gherkin 语法写用例。
  • allure-pytest:生成 Allure 报告,适合需要精美报告的场景。

写自定义插件也不难,核心是 hook 函数。比如想在每个用例开始时打印分隔线:

# conftest.py def pytest_runtest_setup(item): print(f"\n{'='*40}\n运行: {item.name}\n{'='*40}")

pytest有几十个 hook,覆盖收集、执行、报告各个阶段,需要时查官方文档的 hook 列表即可。

10. 我踩过的三个真实坑

第一个坑:在conftest.py里 import 测试文件。当时想复用某个测试类里的辅助函数,结果pytest收集时把那个文件当测试文件又跑了一遍,用例数翻倍。教训是:conftest.py只 import 业务代码和工具,绝不 import 测试文件。

第二个坑:session级别的 fixture 里做断言。fixture 里断言失败,报错信息会指向 fixture 而不是用例,排查时一脸懵。fixture 只做"准备",断言留给用例。

第三个坑:参数化数据用可变对象@pytest.mark.parametrize("data", [{"a": 1}])里传字典,如果用例修改了它,下一组数据可能受影响(取决于pytest版本和对象复用)。参数化数据尽量用不可变类型,或者每组数据独立构造。

11. 一套可以直接抄的项目模板

把上面所有东西串起来,一个完整的项目结构长这样:

project/ ├── src/ │ └── app.py ├── tests/ │ ├── conftest.py # 全局 fixture:base_url、api_client │ ├── api/ │ │ ├── conftest.py # API fixture:token、logged_client │ │ ├── test_user.py │ │ └── test_order.py │ └── data/ │ └── cases.json # 参数化数据 ├── pytest.ini # 标记注册、pythonpath ├── requirements.txt └── .github/workflows/test.yml

pytest.ini内容:

[pytest] pythonpath = . testpaths = tests markers = smoke: 冒烟测试 regression: 回归测试 slow: 慢速用例 addopts = -v --tb=short --strict-markers

--strict-markers能防止标记拼写错误被静默忽略,这个选项强烈建议开。

requirements.txt

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

日常执行命令:

pytest # 全量 pytest -m smoke # 冒烟 pytest -n 4 --reruns 2 # 并行 + 重试 pytest --html=report.html --self-contained-html

这套模板我在三个项目里用过,从几十个用例到上千个用例都能撑住。关键不是配置多复杂,而是约定清晰、职责分明conftest.py管组装,tests/管用例,src/管业务,各司其职。

最后分享一个我用了很久的小技巧:在conftest.py里加一个pytest_terminal_summaryhook,跑完后自动打印失败用例列表,省得在几百行输出里翻:

def pytest_terminal_summary(terminalreporter): failed = terminalreporter.stats.get("failed", []) if failed: print(f"\n失败用例共 {len(failed)} 个:") for item in failed: print(f" - {item.nodeid}")

跑完测试一眼看到哪些挂了,直接复制用例名去单独跑,效率提升非常明显。

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

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

立即咨询