Python测试框架pytest实战:从基础到自动化测试框架搭建

Python测试框架pytest实战:从基础到自动化测试框架搭建

1. 项目概述:为什么是pytest?

如果你写过Python代码,尤其是写过一些需要长期维护的项目,那你一定对“测试”这两个字又爱又恨。爱的是,它能在你修改代码后给你一份安心,告诉你“功能没坏”;恨的是,写测试本身常常让人觉得繁琐、枯燥,像是在做重复劳动。我自己在很长一段时间里,用的都是Python自带的unittest框架,虽然能用,但总觉得写起来不够“Pythonic”,尤其是当测试用例多起来、依赖复杂起来的时候,代码就显得有些臃肿。

直到我遇到了pytest。第一次用的时候,感觉就像是从手动挡换成了自动挡。它没有强制要求你继承某个特定的类,写测试函数就像写普通函数一样自然;它的断言失败信息清晰得让人感动,直接告诉你期望值和实际值是什么,而不是一个冷冰冰的AssertionError;它的插件生态更是强大到离谱,几乎你能想到的任何测试需求,都有现成的插件可以帮你搞定。从简单的单元测试到复杂的集成测试、接口自动化,甚至UI自动化,pytest都能优雅地胜任。

所以,这篇内容不是一份冰冷的官方文档翻译,而是我作为一个从unittest迁移过来,并在多个实际项目中深度使用pytest的开发者,分享的一线实战经验和避坑指南。无论你是刚接触测试的新手,还是想从其他框架切换到pytest的老手,我希望你能在这里找到直接能“抄作业”的配置、理解背后的设计哲学,并避开那些我当年踩过的坑。我们会从最基础的安装和环境配置讲起,一步步深入到夹具(fixture)的妙用、参数化测试、插件体系,最后搭建一个可复用的自动化测试框架雏形。我们的目标很简单:让你能用最Pythonic的方式,写出最可靠、最易维护的测试代码。

2. 环境搭建与基础概念

工欲善其事,必先利其器。在开始写pytest测试之前,我们需要一个干净、可管理的工作环境。这一步看似简单,但却是后续所有操作稳定的基石。

2.1 Python环境与pytest安装

首先,确保你有一个合适的Python环境。我个人强烈建议使用pyenv(Linux/macOS)或pyenv-win(Windows)来管理多个Python版本,或者至少使用venv创建独立的虚拟环境。这能避免项目间的包依赖冲突。这里假设你已经安装了Python 3.8或更高版本(pytest对现代Python版本支持更好)。

打开你的终端或命令行,创建一个新的虚拟环境并激活它:

# 创建虚拟环境,命名为 venv_pytest python -m venv venv_pytest # 激活虚拟环境 # Windows venv_pytest\Scripts\activate # Linux/macOS source venv_pytest/bin/activate

激活后,你的命令行提示符前通常会显示环境名(venv_pytest)

接下来,安装pytest。虽然用pip install pytest最简单,但在实际项目中,我们通常会把依赖固定下来。建议创建一个requirements.txt文件,或者使用pipenv/poetry这类更现代的工具。这里我们用最直接的方式:

pip install pytest

安装完成后,验证一下:

pytest --version

如果能看到类似pytest 7.x.x的版本信息,说明安装成功。

注意:有些系统全局Python环境比较复杂,或者安装了Anaconda,一定要确认你是在目标虚拟环境下操作的。一个常见的坑就是在虚拟环境外安装了pytest,导致运行时找不到或版本不对。激活虚拟环境后,which pytest(Linux/macOS)或where pytest(Windows)命令可以帮你确认pytest命令的来源是否在当前虚拟环境的Scriptsbin目录下。

2.2 第一个测试用例与运行

pytest的哲学是“约定优于配置”。它默认会递归查找当前目录及其子目录下,所有以test_开头或者以_test结尾的.py文件,并执行其中所有以test_开头的函数。

让我们创建一个最简单的测试文件test_basic.py

# test_basic.py def test_addition(): """测试加法""" assert 1 + 1 == 2 def test_subtraction(): """测试减法""" assert 5 - 3 == 2 def test_failure_example(): """这是一个会失败的测试示例""" result = 10 / 2 assert result == 4 # 故意写错,5 != 4

保存文件后,在终端中直接运行pytest命令:

pytest

你会看到类似这样的输出:

============================= test session starts ============================== platform linux -- Python 3.9.0, pytest-7.0.0, pluggy-1.0.0 rootdir: /your/project/path collected 3 items test_basic.py .F. [100%] =================================== FAILURES =================================== _____________________________ test_failure_example _____________________________ def test_failure_example(): """这是一个会失败的测试示例""" result = 10 / 2 > assert result == 4 # 故意写错,5 != 4 E assert 5 == 4 test_basic.py:11: AssertionError =========================== short test summary info ============================ FAILED test_basic.py::test_failure_example - assert 5 == 4 ========================= 1 failed, 2 passed in 0.05s =========================

看,pytest自动发现并运行了3个测试。它用.表示通过,F表示失败。对于失败的测试,它清晰地指出了失败的位置(test_basic.py:11)和原因(assert 5 == 4),甚至帮你计算出了实际值5。这比unittest那个简单的AssertionError友好太多了。

你可以通过参数控制测试运行:

  • pytest test_basic.py:只运行特定文件。
  • pytest test_basic.py::test_addition:只运行特定文件中的特定测试函数。
  • pytest -v:使用详细模式,会列出每个测试用例的名字和结果。
  • pytest -k “addition”:只运行名字中包含“addition”的测试用例(模糊匹配)。
  • pytest -x:遇到第一个失败就停止测试。

2.3 断言的艺术:pytest断言 vs 普通assert

你可能注意到了,我们直接使用了Python原生的assert语句。这是pytest的一大魅力。unittest需要你使用self.assertEqual()self.assertTrue()等一系列断言方法,而pytest通过重写断言语句,让原生的assert变得无比强大。

pytest的断言失败信息是动态生成的。当你写assert a == b失败时,pytest会智能地比较ab,并展示它们的差异。对于列表、字典、对象等复杂数据结构,这个差异对比功能尤其有用。

def test_complex_assertion(): expected = {"name": "Alice", "age": 30, "hobbies": ["reading", "hiking"]} actual = {"name": "Alice", "age": 31, "hobbies": ["reading", "swimming"]} assert expected == actual

运行这个测试,pytest会清晰地输出两个字典的差异,精确到哪个键的值不同,列表里哪个元素不一样,一目了然。你不再需要手动去写循环打印对比了。

此外,pytest还通过pytest.raises来优雅地测试异常:

import pytest def test_zero_division(): """测试除以零是否抛出异常""" with pytest.raises(ZeroDivisionError) as exc_info: 1 / 0 # 你还可以进一步检查异常信息 assert str(exc_info.value) == “division by zero”

这种写法比unittestassertRaises更符合Python上下文管理器的习惯,也更清晰。

3. 核心机制深度解析:Fixture与参数化

如果说简单的test_函数是pytest的“肉体”,那么Fixture(夹具)和参数化就是它的“灵魂”。理解了它们,你才算真正掌握了pytest。

3.1 Fixture:测试的基石与依赖注入

Fixture的官方定义是“为可靠、可重复的测试提供固定基线的一种函数”。说人话就是:用来准备测试数据和清理测试环境的工具函数。比如,测试数据库操作前需要连接数据库,测试完后需要关闭连接;测试Web接口需要先启动服务;测试文件操作需要创建临时文件。

unittest里,我们通常在setUptearDown方法里做这些事。但pytest的Fixture更灵活、更强大。它通过装饰器@pytest.fixture来定义。

一个简单的Fixture例子:

import pytest @pytest.fixture def sample_data(): """提供一个简单的数据列表""" return [1, 2, 3, 4, 5] def test_sum(sample_data): # 将fixture名作为参数传入,pytest会自动注入 assert sum(sample_data) == 15 def test_length(sample_data): assert len(sample_data) == 5

在这里,sample_data是一个Fixture。当test_sumtest_length函数将它作为参数时,pytest会在运行每个测试函数前,先执行sample_data()函数,并将其返回值(列表[1,2,3,4,5])注入给测试函数。这实现了依赖注入,测试函数只需要声明它需要什么,而不需要关心数据从哪里来、怎么构造。

Fixture的作用域(scope):这是Fixture非常关键的一个特性。默认作用域是function,即每个测试函数都会执行一次Fixture。但有些资源创建成本很高(比如数据库连接、启动浏览器),我们希望能复用。pytest提供了几种作用域:

  • function(默认):每个测试函数运行一次。
  • class:每个测试类运行一次(所有该类中的方法共享)。
  • module:每个.py文件运行一次。
  • package:每个包(目录)运行一次。
  • session:一次测试会话(即一次pytest命令)只运行一次。
import pytest import time @pytest.fixture(scope=“session”) def expensive_resource(): """模拟一个创建成本很高的资源,比如数据库连接池""" print(“\n创建昂贵的资源...(这只会发生一次)”) resource = {“id”: 1, “status”: “ready”} yield resource # 使用yield实现setup和teardown print(“\n清理昂贵的资源...(这也只会发生一次)”) resource[“status”] = “closed” def test_one(expensive_resource): assert expensive_resource[“status”] == “ready” # 模拟使用资源 expensive_resource[“last_used”] = “test_one” def test_two(expensive_resource): # test_two 使用的是同一个resource对象! assert “last_used” in expensive_resource assert expensive_resource[“last_used”] == “test_one”

注意这里用了yieldyield之前的代码是setup(资源创建),yield返回的值会注入给测试函数,yield之后的代码是teardown(资源清理)。无论测试成功还是失败,teardown部分的代码都会执行,这保证了资源的正确释放,避免了资源泄漏。

实操心得:对于数据库连接、HTTP会话、浏览器驱动这类重量级资源,务必使用scope=“session”scope=“module”,并配合yield使用。这能极大提升测试套件的运行速度。我曾经在一个有上千个测试用例的项目中,通过将数据库Fixture的作用域从function改为session,整体测试时间从15分钟缩短到了2分钟。

3.2 参数化测试:一份代码,多组数据

当你需要对同一个功能用多组不同的输入输出进行测试时,如果为每组数据都写一个测试函数,代码会非常冗余。pytest的@pytest.mark.parametrize装饰器完美解决了这个问题。

基本用法:

import pytest # 定义一个简单的函数用于测试 def is_even(n): return n % 2 == 0 @pytest.mark.parametrize(“number, expected”, [ (2, True), (3, False), (0, True), (-4, True), (-7, False), ]) def test_is_even(number, expected): assert is_even(number) == expected

@pytest.mark.parametrize的第一个参数是一个字符串,定义了注入测试函数的参数名(“number, expected”)。第二个参数是一个列表,列表中的每个元组对应一组测试数据。pytest会为每一组数据单独运行一次test_is_even函数,并将元组中的值解包注入。在测试报告中,你会看到test_is_even[2-True]test_is_even[3-False]等独立的测试项,任何一组失败都不会影响其他组。

参数化的高级用法:

  1. 对测试类进行参数化:装饰器可以应用在类上,那么这个类中的所有测试方法都会接收参数。
  2. 多维度参数化:可以使用多个parametrize装饰器,实现参数的笛卡尔积。
    @pytest.mark.parametrize(“x”, [1, 2]) @pytest.mark.parametrize(“y”, [“a”, “b”]) def test_multi_params(x, y): print(f“x={x}, y={y}”)
    这会运行4次测试:(1,‘a’),(1,‘b’),(2,‘a’),(2,‘b’)
  3. 从函数动态获取参数:参数列表可以是一个返回列表的函数,这在需要从文件或数据库读取测试数据时非常有用。
    def load_test_cases(): # 可以从JSON、YAML、CSV文件或数据库中加载 return [(1, 2, 3), (5, 5, 10), (-1, 1, 0)] @pytest.mark.parametrize(“a,b,expected”, load_test_cases()) def test_dynamic_data(a, b, expected): assert a + b == expected

Fixture与参数化的结合:这是更强大的模式。Fixture可以为参数化的测试提供动态的、有状态的依赖。

import pytest @pytest.fixture(params=[“chrome”, “firefox”, “edge”]) def browser(request): # request是一个内置fixture,可以访问当前测试的上下文 browser_name = request.param print(f“\n启动 {browser_name} 浏览器”) # 这里模拟根据browser_name初始化不同的WebDriver driver = {“name”: browser_name, “status”: “open”} yield driver print(f“\n关闭 {browser_name} 浏览器”) driver[“status”] = “closed” def test_with_browser(browser): # 这个测试会运行三次,每次browser fixture返回不同的driver字典 assert browser[“status”] == “open” print(f“正在使用 {browser[‘name’]} 进行测试”)

通过params参数,browser这个Fixture会依次使用列表中的每个值来运行它自己,从而生成多个不同的Fixture实例。测试函数test_with_browser因此也会被自动执行三次,每次接收到不同的browser值。这在做跨浏览器UI自动化测试时是标准做法。

4. 项目实战:构建一个接口自动化测试框架

了解了核心概念后,我们通过一个实战项目来串联所有知识。假设我们要为一个简单的用户管理REST API编写自动化测试。这个API提供了用户注册、登录、查询信息等功能。我们将使用pytest+requests库来构建测试框架。

4.1 项目结构与配置

一个清晰的项目结构是维护性的关键。我推荐如下结构:

api_test_project/ ├── conftest.py # pytest的本地配置文件,存放全局fixture和钩子 ├── pytest.ini # pytest的主配置文件 ├── requirements.txt # 项目依赖 ├── tests/ # 测试用例目录 │ ├── __init__.py │ ├── conftest.py # 测试目录下的局部配置(可选) │ ├── test_auth.py # 认证相关测试 │ └── test_user.py # 用户管理相关测试 └── utils/ # 工具函数目录 ├── __init__.py └── client.py # 封装的API请求客户端

1. 依赖文件requirements.txt:

pytest>=7.0.0 requests>=2.28.0 pytest-html>=3.0.0 # 生成HTML报告 pytest-xdist>=3.0.0 # 分布式测试 pytest-ordering>=0.6.0 # 控制测试顺序(谨慎使用) pytest-rerunfailures>=10.0 # 失败重试

2. 主配置文件pytest.ini:

[pytest] # 指定测试文件搜索模式 testpaths = tests # 自动发现测试文件的模式 python_files = test_*.py python_classes = Test* python_functions = test_* # 添加命令行默认选项 addopts = -v --tb=short --strict-markers # 自定义标记,防止未注册的标记被使用 markers = smoke: 冒烟测试用例 regression: 回归测试用例 slow: 运行缓慢的测试

--tb=short使得错误回溯信息更简洁。--strict-markers要求所有使用的@pytest.mark.xxx标记必须在pytest.ini中声明,这能防止拼写错误。

4.2 核心工具与Fixture设计

1. 封装API客户端utils/client.py:不要在每个测试用例里直接写requests.get()。封装一个客户端类,统一处理URL拼接、请求头、认证、日志和异常。

# utils/client.py import requests import logging from typing import Optional, Dict, Any logger = logging.getLogger(__name__) class ApiClient: def __init__(self, base_url: str): self.base_url = base_url.rstrip(‘/’) self.session = requests.Session() # 可以在这里设置公共请求头,如 Content-Type self.session.headers.update({“Content-Type”: “application/json”}) self.token: Optional[str] = None def set_auth_token(self, token: str): """设置认证token""" self.token = token self.session.headers.update({“Authorization”: f“Bearer {token}”}) def request(self, method: str, endpoint: str, **kwargs) -> requests.Response: """发送请求的统一入口""" url = f“{self.base_url}/{endpoint.lstrip(‘/’)}” # 如果有token,确保Authorization头存在(set_auth_token已设置) # 可以在这里添加请求日志、耗时统计等 logger.info(f“Request: {method} {url}”) try: resp = self.session.request(method, url, **kwargs) resp.raise_for_status() # 如果状态码不是2xx,抛出HTTPError logger.info(f“Response: {resp.status_code}”) return resp except requests.exceptions.RequestException as e: logger.error(f“Request failed: {e}”) raise # 将异常抛给上层测试用例处理 # 便捷方法 def get(self, endpoint: str, **kwargs): return self.request(“GET”, endpoint, **kwargs) def post(self, endpoint: str, json_data: Optional[Dict[str, Any]] = None, **kwargs): return self.request(“POST”, endpoint, json=json_data, **kwargs) def put(self, endpoint: str, json_data: Optional[Dict[str, Any]] = None, **kwargs): return self.request(“PUT”, endpoint, json=json_data, **kwargs) def delete(self, endpoint: str, **kwargs): return self.request(“DELETE”, endpoint, **kwargs)

2. 全局Fixtureconftest.py:conftest.py是pytest的“魔法”文件,其中定义的Fixture可以被同一目录及子目录下的所有测试文件自动发现和使用。根目录下的conftest.py是全局的。

# conftest.py import pytest import os from utils.client import ApiClient # 从环境变量读取基础URL,便于在不同环境(测试/预生产)切换 @pytest.fixture(scope=“session”) def base_url(): return os.getenv(“API_BASE_URL”, “http://localhost:5000/api”) # 默认值 # 最重要的Fixture:API客户端,整个测试会话只创建一个 @pytest.fixture(scope=“session”) def api_client(base_url): """返回一个配置好的API客户端实例""" client = ApiClient(base_url) yield client # 如果需要,可以在这里做会话级别的清理,比如登出所有用户 client.session.close() print(“\nAPI客户端会话结束。”) # 一个用于每个测试用例的临时用户Fixture @pytest.fixture def random_user(api_client): """注册一个随机用户,测试完成后删除它。确保测试隔离性。""" import random import string username = “test_” + ‘’.join(random.choices(string.ascii_lowercase, k=8)) email = f“{username}@example.com” password = “TestPass123!” # 1. 注册用户 reg_data = {“username”: username, “email”: email, “password”: password} resp = api_client.post(“/auth/register”, json_data=reg_data) assert resp.status_code == 201, f“用户注册失败: {resp.text}” user_id = resp.json().get(“id”) # 将用户信息yield给测试用例 user_info = { “id”: user_id, “username”: username, “email”: email, “password”: password } yield user_info # 2. 测试完成后,清理用户(teardown) # 注意:这里需要管理员权限或用户自己注销。假设我们有删除接口 # 在实际项目中,这里可能是调用删除API,或者连接测试数据库直接删除 print(f“\n清理测试用户: {username}”) # api_client.delete(f“/admin/users/{user_id}”) # 示例

这个random_userFixture体现了测试的“自清洁”原则。每个需要用户的测试用例,都会得到一个全新的、独立的用户数据,测试完成后自动清理,避免了测试用例间的相互污染,这对于并行测试至关重要。

4.3 编写真正的测试用例

有了强大的Fixture和客户端,编写测试用例就变得非常清晰和简单。

1. 认证测试tests/test_auth.py:

# tests/test_auth.py import pytest class TestAuthentication: """认证相关测试类""" @pytest.mark.smoke # 使用自定义标记,可以通过 `pytest -m smoke` 只运行冒烟测试 def test_register_and_login(self, api_client): """测试注册新用户并登录""" # 使用一个独立的随机用户,避免和别的测试冲突 import random import string username = “test_” + ‘’.join(random.choices(string.ascii_lowercase, k=8)) email = f“{username}@example.com” password = “MySecurePass!123” # 测试注册 reg_data = {“username”: username, “email”: email, “password”: password} resp = api_client.post(“/auth/register”, json_data=reg_data) assert resp.status_code == 201 reg_result = resp.json() assert “id” in reg_result assert reg_result[“username”] == username assert “password” not in reg_result # 确保密码没有返回 # 测试登录 login_data = {“username”: username, “password”: password} resp = api_client.post(“/auth/login”, json_data=login_data) assert resp.status_code == 200 login_result = resp.json() assert “access_token” in login_result token = login_result[“access_token”] # 验证token有效性:使用token访问一个受保护端点 api_client.set_auth_token(token) resp = api_client.get(“/auth/me”) assert resp.status_code == 200 me_info = resp.json() assert me_info[“username”] == username @pytest.mark.parametrize(“invalid_data, expected_status”, [ ({“username”: “”, “password”: “pass”}, 400), # 用户名为空 ({“username”: “user”, “password”: “”}, 400), # 密码为空 ({“username”: “a”, “password”: “pass”}, 400), # 用户名太短 ({“username”: “existing_user”, “password”: “pass”}, 409), # 用户已存在(假设) ]) def test_register_validation(self, api_client, invalid_data, expected_status): """测试注册接口的输入验证""" resp = api_client.post(“/auth/register”, json_data=invalid_data) assert resp.status_code == expected_status

2. 用户管理测试tests/test_user.py:

# tests/test_user.py import pytest class TestUserManagement: """用户管理测试""" def test_get_user_profile(self, api_client, random_user): """测试获取用户个人信息""" # random_user fixture已经提供了注册好的用户信息和初始客户端(未登录) user = random_user # 先登录获取token login_resp = api_client.post(“/auth/login”, json_data={ “username”: user[“username”], “password”: user[“password”] }) token = login_resp.json()[“access_token”] api_client.set_auth_token(token) # 获取个人信息 resp = api_client.get(f“/users/{user[‘id’]}") assert resp.status_code == 200 profile = resp.json() assert profile[“id”] == user[“id”] assert profile[“username”] == user[“username”] # 确保敏感信息(如密码哈希、邮箱)没有被意外暴露 assert “password” not in profile # 邮箱可能根据业务决定是否暴露,这里只是示例 # assert profile.get(“email”) == user[“email”] def test_update_user_profile(self, api_client, random_user): """测试更新用户信息""" user = random_user login_resp = api_client.post(“/auth/login”, json_data={ “username”: user[“username”], “password”: user[“password”] }) token = login_resp.json()[“access_token”] api_client.set_auth_token(token) new_bio = “A passionate tester.” update_data = {“bio”: new_bio} resp = api_client.put(f“/users/{user[‘id’]}”, json_data=update_data) assert resp.status_code == 200 # 验证更新是否生效 resp = api_client.get(f“/users/{user[‘id’]}") updated_profile = resp.json() assert updated_profile[“bio”] == new_bio

4.4 运行测试与生成报告

配置好一切后,运行测试就非常简单了。在项目根目录下:

  1. 运行所有测试pytest
  2. 运行特定标记的测试pytest -m smoke(只运行冒烟测试)
  3. 运行并生成HTML报告pytest --html=report.html --self-contained-html。这需要pytest-html插件。生成的report.html文件可以在浏览器中打开,直观地查看通过率、失败详情、执行时间等。
  4. 并行运行测试pytest -n auto(使用pytest-xdist插件,auto会根据CPU核心数自动分配进程)。对于大量独立测试,这能成倍缩短执行时间。
  5. 失败重试pytest --reruns 2 --reruns-delay 1(使用pytest-rerunfailures插件,对失败的测试重试2次,每次间隔1秒)。这对于测试一些不稳定的外部依赖(如网络请求)非常有用。

注意事项:并行测试和Fixture作用域需要小心配合。如果Fixture的作用域是sessionmodule,并且Fixture本身不是线程/进程安全的(比如一个共享的文件句柄),那么在并行运行时可能会引发竞态条件。通常,对于数据库连接、HTTP客户端,建议使用scope=“session”但确保它们是线程安全的,或者为每个进程创建独立的实例。pytest-xdist的每个工作进程是独立的Python解释器,因此session作用域的Fixture会在每个工作进程中单独初始化一次,这通常是安全的。

5. 高级技巧与插件生态

pytest的强大,一半在于其核心设计,另一半在于其丰富的插件生态。这里介绍几个在工程化实践中必不可少的插件和高级用法。

5.1 常用插件推荐

  1. pytest-html:前面提到过,生成美观的HTML测试报告,是向团队展示测试结果的标准方式。
  2. pytest-xdist:分布式测试,支持并行运行,极大提升测试速度。
  3. pytest-rerunfailures:失败用例重试,应对偶发性失败。
  4. pytest-cov:集成coverage.py,生成代码覆盖率报告。命令:pytest --cov=your_module tests/。这是衡量测试完备性的关键指标。
  5. pytest-mock:更优雅地使用unittest.mock。它提供了一个mockerFixture,简化了模拟对象的创建和注入。
    def test_with_mock(mocker): # 模拟一个函数 mock_requests_get = mocker.patch(“requests.get”) mock_requests_get.return_value.status_code = 200 mock_requests_get.return_value.json.return_value = {“key”: “value”} # 调用被测代码,它会使用我们模拟的requests.get # ... assert ...
  6. pytest-django / pytest-flask:如果你做Django或Flask开发,这些插件提供了与Web框架深度集成的Fixture和工具,比如测试客户端、数据库事务处理等。

5.2 自定义标记与条件跳过

pytest允许你自定义标记来分类测试,并且可以根据条件跳过某些测试。

自定义标记:已经在pytest.ini中定义过smokeregression等标记。你可以用@pytest.mark.smoke装饰测试函数或类。然后通过pytest -m “smoke and not slow”来运行冒烟测试中非慢速的用例。

条件跳过

import pytest import sys @pytest.mark.skip(reason=“此功能在API v2中尚未实现”) def test_new_feature(): ... @pytest.mark.skipif(sys.version_info < (3, 8), reason=“需要Python 3.8及以上版本”) def test_using_walrus(): # 使用海象运算符 ... # 更复杂的运行时跳过 def check_api_available(): import requests try: requests.get(“http://localhost:5000/health”, timeout=2) return True except: return False @pytest.mark.skipif(not check_api_available(), reason=“测试API服务不可用”) class TestIntegrationWithLiveAPI: # 这类测试只在真实服务可用时才运行 ...

5.3 钩子函数(Hooks)定制pytest行为

pytest的插件系统本身就是基于钩子函数构建的。我们也可以在conftest.py中定义钩子来定制pytest的行为。这是一个高级功能,但非常强大。

例如,我们可以在测试运行开始时动态加载环境变量,或者在测试收集后修改测试项:

# conftest.py def pytest_configure(config): """在测试配置初始化后调用,可以在这里添加自定义配置""" print(“Pytest配置初始化完成!”) # 可以在这里根据环境变量设置自定义标记 if os.getenv(“RUN_SMOKE_ONLY”): config.option.markexpr = “smoke” def pytest_collection_modifyitems(config, items): """修改收集到的测试用例列表""" # 例如,将标记为‘slow’的测试移到列表最后执行 slow_items = [] other_items = [] for item in items: if “slow” in item.keywords: slow_items.append(item) else: other_items.append(item) items[:] = other_items + slow_items # 原地修改items列表 def pytest_terminal_summary(terminalreporter, exitstatus): """在终端报告生成后调用,可以添加自定义总结信息""" terminalreporter.write_sep(“=”, “自定义测试总结”) passed = len(terminalreporter.stats.get(“passed”, [])) failed = len(terminalreporter.stats.get(“failed”, [])) terminalreporter.write_line(f“总用例数: {passed + failed}, 通过: {passed}, 失败: {failed}”)

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

在实际使用中,你一定会遇到各种奇怪的问题。这里记录了一些高频问题的排查思路。

6.1 Fixture作用域与生命周期陷阱

问题:测试用例B依赖于测试用例A创建的数据,但运行时B却失败了,提示数据不存在。排查:这几乎肯定是Fixture作用域问题。如果创建数据的Fixture作用域是function,那么每个测试用例得到的是独立的、全新的数据,用例A创建的数据对用例B不可见。解决

  1. 如果这两个测试确实需要共享状态,考虑将Fixture的作用域提升到classmodule
  2. 更推荐的做法是,让每个测试用例完全独立。用例B不应该依赖用例A的状态。如果它们需要相同的基础数据,应该从一个scope=“session”scope=“module”的Fixture中获取原始数据,然后各自创建自己的测试实例。就像我们之前random_userFixture做的那样,每个测试用例注册一个全新的用户。

问题:使用了scope=“session”的Fixture来打开数据库连接,但测试中出现了连接超时或断开。排查:长时间运行的会话Fixture可能因为网络波动、数据库超时设置而断开。解决:在Fixture内部实现简单的连接重试或心跳机制。或者,更简单的方法是,使用scope=“function”scope=“module”,虽然会牺牲一些速度,但稳定性更高。对于数据库,也可以使用连接池Fixture。

6.2 测试依赖与执行顺序

pytest默认的测试发现顺序是文件系统顺序,执行顺序是收集到的顺序,这通常是不确定的。不要依赖测试的执行顺序来编写用例

问题:测试用例有时成功有时失败,似乎没有规律。排查:检查测试用例之间是否有隐藏的依赖。比如,测试A修改了某个全局变量或数据库中的某条记录,测试B的运行结果依赖于这个被修改后的状态。解决

  1. 彻底隔离:使用Fixture为每个测试提供干净的环境。对于数据库,可以使用事务回滚(如pytest-djangodjango_dbFixture)或每个测试前清空/重建表。
  2. 使用pytest-ordering插件(谨慎):如果某些测试真的有严格的顺序要求(比如集成测试流程),可以用@pytest.mark.run(order=1)来指定顺序。但这应该是最后的手段,因为它破坏了测试的独立性。

6.3 断言失败信息不够清晰

问题assert response.json() == expected_data失败时,输出是一大坨难以阅读的JSON。解决:pytest已经做了很好的差异对比。但对于特别复杂的嵌套结构,可以使用pytest-assume插件(允许一个测试函数中有多个断言,所有断言都会执行后再报告失败),或者将断言拆解:

resp_json = response.json() assert resp_json[“status”] == expected_data[“status”] assert resp_json[“data”][“id”] == expected_data[“data”][“id”] # ... 或者使用deepdiff库进行深度比较 from deepdiff import DeepDiff diff = DeepDiff(resp_json, expected_data, ignore_order=True) assert not diff, f“响应与预期不符: {diff}”

6.4 测试速度优化

当测试套件变得庞大时,速度会成为痛点。

  1. 使用pytest-xdist并行运行:这是最有效的提速手段。
  2. 优化Fixture作用域:将创建成本高的资源(数据库连接、浏览器启动)设置为sessionmodule级别。
  3. 使用Mock:对于外部HTTP服务、第三方API调用、文件IO等慢速或不可靠的操作,使用pytest-mock进行模拟,让测试专注于业务逻辑。
  4. 选择性运行:使用-k进行关键字过滤,或-m进行标记过滤,只运行你关心的测试。
  5. 定期清理:删除不再需要的测试用例和测试数据。

6.5 集成到CI/CD流水线

在持续集成环境中运行pytest,通常需要关注以下几点:

  1. 命令示例
    # 安装依赖 pip install -r requirements.txt # 运行测试,生成JUnit XML报告(很多CI工具如Jenkins, GitLab CI支持) pytest --junitxml=report.xml # 同时生成HTML和覆盖率报告 pytest --html=report.html --self-contained-html --cov=src --cov-report=xml:coverage.xml
  2. 环境变量:通过CI的环境变量设置API_BASE_URL、数据库连接字符串等配置,使测试能指向不同的环境(如测试环境、预发布环境)。
  3. 失败重试:在CI中启用--reruns,可以减少因环境瞬时波动导致的构建失败。
  4. 缓存:利用CI系统的缓存功能,缓存Python的site-packages目录,可以大幅加速依赖安装过程。

从最初的一个test_函数,到构建一个结构清晰、可维护、可扩展的自动化测试框架,pytest提供的不仅仅是一个测试运行器,更是一套提升代码质量和开发效率的工程实践。它的简洁语法和强大功能,让编写测试从负担变成了乐趣。记住,好的测试不是写出来的,而是通过像pytest这样优秀的工具,自然地“生长”出来的。