1. 项目概述:为什么选择 pytest + requests 做自动化?

如果你正在为如何高效、稳定地编写接口自动化测试代码而头疼,那么 pytest 搭配 requests 的组合,很可能是你当前阶段最务实、最高效的选择。这不是一个花哨的框架,而是一套经过无数项目验证的“黄金搭档”。我见过太多团队一开始追求大而全的自动化平台,结果陷入维护泥潭,最终回归到这种轻量、灵活、开发体验极佳的模式。

简单来说, requests 库负责处理 HTTP 请求,它用起来就像用 Python 的字典和列表一样自然,让你能专注于业务逻辑,而不是底层网络细节。而 pytest 则是一个功能强大的测试框架,它不仅仅能“运行测试”,更重要的是,它提供了一套优雅的代码组织方式、丰富的断言机制、灵活的夹具(fixture)系统以及强大的插件生态。当这两者结合,你得到的不是一个僵化的“框架”,而是一个可以根据项目需求自由裁剪和扩展的“工具箱”。

这个组合特别适合以下场景:项目迭代快,接口变动频繁,需要快速响应;团队规模不大,测试人员需要兼顾功能测试和自动化脚本编写;或者你希望自动化代码本身易于阅读、维护和调试。它不强制你遵循某种复杂的模式(比如一开始就上 PO 模型),而是允许你从最简单的脚本开始,随着项目复杂度的提升,逐步引入更结构化的设计,平滑演进。接下来,我会详细拆解如何从零开始,构建一个既健壮又灵活的自动化代码编写思路。

2. 核心思路与框架设计:从脚本到工程

很多新手会直接把 requests 调用和断言塞进一个 .py 文件里,然后手动运行。这作为探索是可以的,但绝不是可持续的工程实践。我们的目标是建立一套可维护、可复用、可报告的代码结构。核心思路可以概括为: “数据与逻辑分离,用例与配置解耦,通过夹具管理资源”

2.1 分层架构设计

一个典型的、易于维护的 pytest + requests 项目会采用分层设计,这能让你的代码条理清晰,各司其职。

第一层:测试数据层。 这是最容易出问题的地方。切忌把测试数据(如 URL、请求参数、预期结果)硬编码在测试用例函数里。我们应该将它们剥离出来。对于简单的项目,可以使用 YAML JSON 文件;对于需要关联、动态生成的数据,可以结合 Python 字典或 CSV 。我个人的习惯是,将接口的基本信息(如路径、方法)放在一个 config 模块或 YAML 中,而将具体的测试用例数据(尤其是多种参数组合)放在 data 目录下的独立文件里。这样做的好处是,当接口变更时,你通常只需要修改一两个数据文件,而不是在成百上千行测试代码里搜索替换。

第二层:核心请求层。 这一层是对 requests 库的封装。我们不应该在每个测试用例里都写 requests.get(url, params=params, headers=headers) 。而是应该创建一个通用的“请求客户端”。这个客户端会统一处理一些公共逻辑,比如:

  • 基础 URL 的拼接。
  • 通用请求头(如 Content-Type, Authorization Token)的管理。
  • 统一的超时、重试策略。
  • 全局的日志记录和请求/响应信息的打印(便于调试)。
  • 对响应结果进行初步处理,比如自动将 JSON 字符串转为 Python 字典。

封装后,你的测试用例调用起来会非常简洁: client.send_request(api_name, data) 。这极大地减少了代码重复,也使得后续更换底层 HTTP 库(虽然 requests 很难被替代)或增加统一功能(如加密签名)变得容易。

第三层:测试用例层。 这是 pytest 的主场。在这一层,你编写以 test_ 开头的函数或方法。每个函数应该专注于一个具体的测试场景。用例函数内部逻辑应该尽量简单:准备测试数据 -> 调用封装好的请求客户端 -> 对响应进行断言。复杂的准备和清理工作,应该交给 pytest fixture

第四层:夹具与钩子层。 这是 pytest 的精髓,也是区分“会用”和“精通”的关键。 Fixture 可以理解为测试的“脚手架”或“资源管理器”。你可以用它来:

  • 初始化测试所需的数据库连接、测试用户。
  • 为每个用例提供一个干净的、带特定 Token 的请求客户端。
  • 在用例执行前后进行特定的设置和清理,比如创建测试订单并在测试后删除。 通过 @pytest.fixture 装饰器定义,然后在测试用例函数参数中声明使用, pytest 会自动帮你调用和管理它们的生命周期。

2.2 目录结构示例

一个清晰的目录结构是良好设计的开始。我推荐如下结构:

project_root/
├── conftest.py           # 全局 pytest 配置和 fixture 定义
├── pytest.ini           # pytest 配置文件
├── requirements.txt     # 项目依赖
├── common/              # 公共模块
│   ├── __init__.py
│   ├── client.py        # 封装的 requests 客户端
│   └── logger.py        # 日志配置
├── config/              # 配置层
│   ├── __init__.py
│   └── api_config.yaml  # 接口基础配置
├── test_data/           # 数据层
│   ├── __init__.py
│   ├── case_data_login.yaml
│   └── case_data_order.yaml
├── test_cases/          # 用例层
│   ├── __init__.py
│   ├── test_login.py
│   └── test_order.py
└── reports/             # 测试报告(自动生成)
    └── html/

conftest.py 文件特别重要,它可以被放置在任何目录,其内部定义的 fixture 对该目录及其子目录下的所有测试文件生效。通常我们把最通用的 fixture (如请求客户端、日志对象)放在项目根目录的 conftest.py 中。

3. 核心工具深度解析:requests 封装与 pytest 夹具

3.1 如何封装一个健壮的 requests 客户端

直接使用 requests 虽然简单,但在自动化测试中远远不够。下面是一个我经过多次迭代后形成的客户端封装示例,它包含了几个关键特性:

# common/client.py
import requests
import json
import logging
from typing import Any, Dict, Optional, Union

class APIClient:
    def __init__(self, base_url: str):
        self.base_url = base_url.rstrip('/')  # 去除末尾斜杠
        self.session = requests.Session()  # 使用 Session 保持连接,提升性能
        self.logger = logging.getLogger(__name__)
        # 设置默认请求头
        self.session.headers.update({
            'Content-Type': 'application/json; charset=utf-8',
            'User-Agent': 'Pytest-Requests-Auto/1.0'
        })

    def _send_request(self,
                      method: str,
                      endpoint: str,
                      params: Optional[Dict] = None,
                      json_data: Optional[Dict] = None,
                      data: Optional[Dict] = None,
                      headers: Optional[Dict] = None,
                      **kwargs) -> requests.Response:
        """发送请求的核心方法"""
        url = f"{self.base_url}/{endpoint.lstrip('/')}"
        req_headers = {**self.session.headers, **(headers or {})}

        # 记录请求日志(注意脱敏,不要打印密码等敏感信息)
        self.logger.info(f"Request: {method.upper()} {url}")
        if params:
            self.logger.debug(f"Request Params: {params}")
        if json_data:
            self.logger.debug(f"Request JSON Body: {self._mask_sensitive_data(json_data)}")
        if data:
            self.logger.debug(f"Request Form Data: {self._mask_sensitive_data(data)}")

        try:
            # 统一增加超时设置,避免请求卡死
            kwargs.setdefault('timeout', (10, 30))  # (连接超时, 读取超时)
            resp = self.session.request(
                method=method,
                url=url,
                params=params,
                json=json_data,
                data=data,
                headers=req_headers,
                **kwargs
            )
            # 记录响应日志
            self.logger.info(f"Response Status: {resp.status_code}")
            # 尝试记录响应体,对于非文本内容进行截断
            try:
                resp_body = resp.json()
                self.logger.debug(f"Response Body (JSON): {json.dumps(resp_body, ensure_ascii=False)[:500]}...")  # 截断防止日志过长
            except json.JSONDecodeError:
                self.logger.debug(f"Response Body (Text): {resp.text[:500]}...")
            return resp
        except requests.exceptions.Timeout:
            self.logger.error(f"Request timeout: {method} {url}")
            raise
        except requests.exceptions.ConnectionError:
            self.logger.error(f"Connection error: {method} {url}")
            raise
        except Exception as e:
            self.logger.exception(f"Unexpected error during request: {e}")
            raise

    def _mask_sensitive_data(self, data: Dict) -> Dict:
        """简单的敏感信息脱敏,防止密码等写入日志"""
        masked_data = data.copy()
        sensitive_keys = ['password', 'token', 'authorization', 'secret']
        for key in sensitive_keys:
            if key in masked_data:
                masked_data[key] = '***MASKED***'
        return masked_data

    # 提供便捷方法
    def get(self, endpoint: str, **kwargs) -> requests.Response:
        return self._send_request('GET', endpoint, **kwargs)

    def post(self, endpoint: str, **kwargs) -> requests.Response:
        return self._send_request('POST', endpoint, **kwargs)

    def put(self, endpoint: str, **kwargs) -> requests.Response:
        return self._send_request('PUT', endpoint, **kwargs)

    def delete(self, endpoint: str, **kwargs) -> requests.Response:
        return self._send_request('DELETE', endpoint, **kwargs)

    # 一个常用的扩展:获取响应并自动解析为 JSON,失败时抛出清晰异常
    def request_and_parse(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
        resp = self._send_request(method, endpoint, **kwargs)
        resp.raise_for_status()  # 如果状态码不是 2xx,抛出 HTTPError
        try:
            return resp.json()
        except json.JSONDecodeError as e:
            self.logger.error(f"Failed to parse response as JSON: {resp.text[:200]}")
            raise ValueError(f"Response is not valid JSON: {e}") from e

封装要点解析:

  1. 使用 Session requests.Session() 可以复用底层的 TCP 连接,对于连续调用同一 host 的接口,能显著提升性能。
  2. 统一超时 :网络请求必须设置超时。 timeout=(10, 30) 表示连接阶段10秒超时,接收数据阶段30秒超时。这是避免自动化任务因某个接口挂起而“卡死”的关键。
  3. 结构化日志 :详细的日志是调试的救命稻草。务必记录请求和响应的关键信息。注意使用 debug 级别记录可能较大的请求体/响应体,并用 _mask_sensitive_data 方法对密码等字段进行脱敏,这是安全红线。
  4. 异常处理 :区分不同类型的网络异常(超时、连接错误),并记录完整的异常信息( logger.exception ),便于快速定位是网络问题、服务问题还是脚本问题。
  5. 便捷方法与增强 :提供 get , post 等快捷方法,并可以像 request_and_parse 一样,封装“发送请求+状态码检查+JSON解析”这个最常见组合,让用例代码更简洁。

3.2 pytest fixture 的实战应用

Fixture 是 pytest 的灵魂。理解它的作用域(scope)和生命周期是高效使用的关键。

作用域(Scope):

  • function (默认):每个测试函数运行一次。
  • class :每个测试类运行一次。
  • module :每个 .py 文件运行一次。
  • package :每个包运行一次。
  • session :整个 pytest 执行过程运行一次。

一个综合性的 conftest.py 示例:

# conftest.py
import pytest
import logging
from common.client import APIClient

# 读取配置文件,这里用简单示例,实际可用 yaml、ini 或环境变量
BASE_URL = "https://api.example.com/v1"

@pytest.fixture(scope="session")
def logger():
    """会话级别的日志器 fixture"""
    log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
    logging.basicConfig(level=logging.INFO, format=log_format)
    # 可以添加文件处理器,将日志写入文件
    # file_handler = logging.FileHandler('test_run.log')
    # file_handler.setFormatter(logging.Formatter(log_format))
    # logging.getLogger().addHandler(file_handler)
    return logging.getLogger(__name__)

@pytest.fixture(scope="session")
def api_client():
    """创建一个全局的 API 客户端,整个测试会话只初始化一次"""
    client = APIClient(BASE_URL)
    yield client  # yield 之前是 setup,之后是 teardown
    # 如果需要,可以在这里执行会话结束后的清理,比如关闭连接
    client.session.close()
    print("\n>>> 所有测试执行完毕,API 客户端连接已关闭。")

@pytest.fixture(scope="function")
def authenticated_client(api_client):
    """
    基于全局客户端,为每个测试函数生成一个已认证的客户端。
    这是一个典型的 fixture 依赖链:authenticated_client -> api_client
    """
    # 假设我们需要先调用登录接口获取 token
    login_payload = {"username": "test_user", "password": "test_pass123"}
    # 注意:实际项目中,密码应从安全的环境变量或配置中心读取,绝不能硬编码!
    try:
        resp = api_client.post("/auth/login", json=login_payload)
        token = resp.json()["data"]["token"]
        # 将 token 设置到 session 的 headers 中
        api_client.session.headers.update({'Authorization': f'Bearer {token}'})
    except Exception as e:
        pytest.fail(f"用户登录失败,无法获取 token: {e}")
    yield api_client  # 将携带了 token 的客户端提供给测试用例使用
    # 每个用例执行完后,清理 token,避免影响下一个用例(如果需要隔离的话)
    api_client.session.headers.pop('Authorization', None)

@pytest.fixture
def create_test_data():
    """一个用于准备测试数据的 fixture"""
    data = {"name": f"test_item_{pytest.current_test_name}"}
    yield data
    # 测试后清理数据(这里只是示例,实际需要调用删除接口)
    print(f"清理测试数据: {data}")

# 钩子函数示例:在每个测试开始和结束时打印信息
def pytest_runtest_logstart(nodeid, location):
    print(f"\n=== 开始测试: {nodeid} ===")

def pytest_runtest_logfinish(nodeid, location):
    print(f"=== 结束测试: {nodeid} ===\n")

Fixture 使用心得:

  • yield 魔法 yield 语句将 fixture 分为两部分。 yield 之前的代码是“设置”, yield 返回的值是提供给测试用例使用的对象, yield 之后的代码是“清理”。这比旧的 request.addfinalizer 方式更清晰。
  • 作用域选择 api_client 用了 session 作用域,因为创建 HTTP 会话成本较高,且测试间通常无需隔离。 authenticated_client 用了 function 作用域,因为每个测试用例可能需要独立的认证状态(比如用不同权限的用户测试)。错误的作用域选择会导致测试相互污染或效率低下。
  • Fixture 依赖 authenticated_client 依赖 api_client ,只需在参数列表中声明。pytest 会自动按依赖关系顺序调用。
  • 失败处理 :在 authenticated_client 中,如果登录失败,我们使用 pytest.fail 直接让依赖它的测试用例标记为失败,这比让用例自己去处理“客户端未初始化”的错误更清晰。

4. 测试用例编写与断言艺术

有了强大的客户端和灵活的 fixture,编写测试用例就变成了一件愉快的事情。核心是让用例函数保持简洁、可读。

4.1 数据驱动测试

这是提高用例覆盖率和维护性的关键。pytest 可以通过 @pytest.mark.parametrize 装饰器轻松实现。

# test_cases/test_login.py
import pytest
import allure  # 可选,用于生成更美观的 Allure 报告

class TestLogin:
    """登录模块测试"""

    # 用例数据可以定义在类内部,或者从外部文件加载
    @pytest.mark.parametrize("username, password, expected_code, expected_msg", [
        ("correct_user", "correct_pwd", 200, "success"),
        ("wrong_user", "correct_pwd", 401, "用户名或密码错误"),
        ("correct_user", "", 400, "密码不能为空"),
        ("", "correct_pwd", 400, "用户名不能为空"),
        ("a" * 101, "correct_pwd", 400, "用户名长度超限"), # 边界值测试
    ])
    def test_login_with_different_input(self, api_client, username, password, expected_code, expected_msg):
        """测试不同输入组合下的登录行为"""
        payload = {"username": username, "password": password}
        resp = api_client.post("/auth/login", json=payload)

        # 断言1:状态码
        assert resp.status_code == expected_code, f"预期状态码{expected_code},实际为{resp.status_code},响应:{resp.text}"

        # 断言2:响应消息(如果状态码非200,响应结构可能不同,需要安全访问)
        resp_json = resp.json()
        if resp.status_code == 200:
            assert resp_json["message"] == expected_msg
            assert "token" in resp_json["data"]  # 成功时应返回 token
            assert isinstance(resp_json["data"]["token"], str) and len(resp_json["data"]["token"]) > 10
        else:
            # 失败时,断言错误信息包含预期内容(有时是模糊匹配)
            assert expected_msg in resp_json.get("message", "")

    # 使用从 YAML 文件加载的数据
    @pytest.mark.parametrize("case_data", pytest.data_loader.load_yaml("test_data/case_data_login.yaml"))
    def test_login_with_yaml_data(self, api_client, case_data):
        """使用外部 YAML 文件驱动测试"""
        resp = api_client.request_and_parse(
            method=case_data["method"],
            endpoint=case_data["endpoint"],
            json=case_data["request"]
        )
        # 使用深层断言,验证响应中的特定字段
        assert resp["code"] == case_data["expected"]["code"]
        assert resp["data"]["userId"] == case_data["expected"]["userId"]
        # 可以验证更多字段...

数据驱动要点:

  • 参数化装饰器 @pytest.mark.parametrize 的第一个参数是字符串,定义了注入到测试函数中的参数名,第二个参数是一个可迭代对象(列表、元组),每个元素是一组测试数据。
  • 清晰的断言信息 :在 assert 语句后添加自定义的错误信息(如 f”预期…实际…” ),这在断言失败时能提供极其有价值的上下文,让你一眼就知道是哪个数据组出了问题,而不需要再去翻看请求日志。
  • 灵活的数据源 :数据可以直接写在代码里(适合简单、少变的场景),也可以从 YAML JSON Excel 甚至数据库中加载。我通常会写一个简单的 pytest 插件或 fixture (如上面的 pytest.data_loader 假设)来统一加载数据。

4.2 断言:不仅仅是 assert

Python 自带的 assert 很简单,但在复杂的响应断言中可能力不从心。 pytest 自身提供了一些增强,但更推荐使用专门的断言库,如 assertpy pytest-assume (用于软断言)。

import pytest
from assertpy import assert_that

def test_complex_response_assertion(authenticated_client):
    """测试获取用户信息的复杂断言"""
    resp_data = authenticated_client.request_and_parse("GET", "/user/profile")

    # 使用 assertpy 进行流式、可读性更强的断言
    assert_that(resp_data).is_not_none()
    assert_that(resp_data).contains_key('data')
    user_data = resp_data['data']
    assert_that(user_data).contains_key('id', 'username', 'email')
    assert_that(user_data['id']).is_type_of(int).is_greater_than(0)
    assert_that(user_data['username']).is_length(5, 20)  # 用户名长度在5-20之间
    assert_that(user_data['email']).matches(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')  # 邮箱格式正则匹配

    # 使用 pytest-assume 进行软断言(所有断言都会执行,最后汇总失败)
    # 需要先安装 pytest-assume
    # import pytest_assume
    # pytest.assume(user_data['status'] == 'active')
    # pytest.assume(user_data['phone'] is not None)
    # 如果上面两个断言一个失败一个成功,测试不会在第一个失败处停止,而是继续执行第二个。

断言策略:

  • 基础断言 :对于简单、关键的断言(如状态码必须为200),使用原生 assert 并附加清晰错误信息。
  • 复杂对象断言 :对于需要验证多个字段、类型、范围的复杂响应,使用 assertpy 能让代码更清晰、更接近自然语言。
  • 软断言 :在需要验证一个接口返回的多个相互独立的字段时,使用 pytest-assume 。这样即使第一个字段验证失败,后面的验证仍会执行,你可以在一次测试执行中看到所有不符合预期的点,而不是修好一个错误跑一次测试。

5. 高级技巧与实战问题排查

5.1 处理动态依赖与测试数据隔离

自动化测试中最棘手的问题之一就是测试数据。比如测试“删除订单”接口,你需要先有一个订单。这个订单不能是线上环境的真实数据,也不能是写死的固定ID(可能被其他人删除)。

解决方案:夹具组合与清理。

import pytest
import random
import string

@pytest.fixture
def random_order_id():
    """生成一个随机的订单ID,用于测试"""
    return f"TEST_ORDER_{''.join(random.choices(string.ascii_uppercase + string.digits, k=8))}"

@pytest.fixture
def created_order(authenticated_client, random_order_id):
    """创建一个测试订单 fixture,测试后自动清理"""
    # 1. 准备创建订单的数据
    order_data = {
        "orderId": random_order_id,
        "productId": "prod_001",
        "quantity": 2
    }
    # 2. 调用创建订单接口
    create_resp = authenticated_client.post("/orders", json=order_data)
    assert create_resp.status_code == 201, f"创建订单失败: {create_resp.text}"
    created_order_info = create_resp.json()["data"]

    # 3. 将创建好的订单信息 yield 给测试用例使用
    yield created_order_info

    # 4. 测试用例执行完毕后,清理(删除)这个订单
    print(f"\n>>> 开始清理测试订单: {created_order_info['id']}")
    try:
        delete_resp = authenticated_client.delete(f"/orders/{created_order_info['id']}")
        # 这里可以断言删除成功,但即使失败也不应影响测试结果主体,记录警告即可
        if delete_resp.status_code not in [200, 204]:
            print(f"警告:清理订单 {created_order_info['id']} 失败,状态码: {delete_resp.status_code}")
    except Exception as e:
        print(f"警告:清理订单 {created_order_info['id']} 时发生异常: {e}")

def test_delete_order(created_order):
    """测试删除订单,依赖于 created_order fixture"""
    order_id = created_order["id"]
    # 这里可以直接测试删除,或者测试其他关于这个订单的操作
    # 因为 created_order fixture 已经确保了订单存在
    # 测试完成后,fixture 的 teardown 部分会自动删除订单
    print(f"测试正在使用订单: {order_id}")
    # ... 具体的删除断言逻辑

关键点:

  • random_order_id :生成唯一标识,避免冲突。
  • created_order :这是一个“创建-清理”模式的经典 fixture。它在 yield 前创建资源,提供给测试用例;在 yield 后执行清理。即使测试用例执行失败, pytest 也会保证清理代码被执行(除非整个进程崩溃)。
  • 清理的健壮性 :清理操作(删除订单)本身也可能失败。在实际项目中,我们通常不会因为清理失败而让测试用例失败( assert ),而是记录警告日志。更复杂的场景下,可以设置一个“待清理队列”,在 session 级别的 fixture 的 teardown 中统一进行最终清理。

5.2 常见问题与排查技巧实录

在实际编写和运行 pytest + requests 自动化脚本时,你会遇到各种各样的问题。下面是我踩过的一些坑和总结的技巧。

问题1:测试用例相互污染。

  • 现象 :用例A修改了全局配置(如请求头的 Token),导致用例B运行异常。
  • 根因 fixture 作用域设置不当,或者直接在测试用例中修改了 fixture 返回的可变对象(如 api_client.session.headers )。
  • 解决
    1. 为需要隔离状态的测试使用 function 作用域的 fixture。例如,每个用例使用独立的 authenticated_client
    2. fixture yield 之后(teardown 部分)重置状态,如上文 authenticated_client 中清理 Token 的操作。
    3. 避免直接修改 fixture 返回的对象内部状态。如果必须,考虑使用 copy.deepcopy 返回一个副本。

问题2:遇到 429 Too Many Requests 错误。

  • 现象 :在快速连续运行测试时,服务端返回 HTTP 429 状态码。
  • 根因 :触发了服务端的限流策略。
  • 解决
    1. 降低请求频率 :在客户端封装层( APIClient._send_request )中,对于非幂等的 POST/PUT/DELETE 请求,可以简单增加 time.sleep(0.5) 。对于 GET 请求,如果服务端允许,可以稍快一些。但这会拖慢测试速度。
    2. 实现重试机制 :使用 tenacity urllib3 Retry 库,对 429 状态码进行指数退避重试。这是更优雅的方案。
    from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception
    import requests
    
    def is_rate_limit_error(exception):
        return isinstance(exception, requests.exceptions.HTTPError) and exception.response.status_code == 429
    
    class APIClient:
        # ... 其他代码 ...
        @retry(
            stop=stop_after_attempt(5), # 最多重试5次
            wait=wait_exponential(multiplier=1, min=2, max=10), # 指数退避,2s, 4s, 8s...
            retry=retry_if_exception(is_rate_limit_error)
        )
        def _send_request(self, method, endpoint, **kwargs):
            # ... 原有的请求发送逻辑 ...
            resp.raise_for_status() # 触发 HTTPError 供 tenacity 判断
            return resp
    
    1. 与开发沟通 :确认测试环境的限流策略,看是否可以临时调高阈值或为测试账号设置白名单。

问题3:测试报告不够直观。

  • 现象 :控制台输出杂乱,无法快速看清哪些用例通过/失败,失败原因是什么。
  • 解决
    1. 使用 pytest-html 插件生成 HTML 报告 :安装后,运行 pytest --html=report.html 。报告会包含用例列表、状态、耗时和失败时的 traceback。
    2. 使用 pytest-allure 生成 Allure 报告 :Allure 报告非常强大美观,可以展示用例层级、步骤、附件(如请求/响应日志、截图)。需要先安装 allure-pytest ,运行测试时添加 --alluredir=./allure-results ,然后用 allure serve ./allure-results 查看。
    3. 善用 -v -s 参数 -v 显示详细输出, -s 允许打印 print 语句和日志(默认被捕获)。调试时非常有用。

问题4:如何高效地只运行一部分测试?

  • 技巧
    1. 标记(Mark) :使用 @pytest.mark.smoke 标记冒烟测试用例,然后运行 pytest -m smoke
    2. 按名称运行 pytest -k “login” 会运行所有名称中包含 “login” 的测试类、函数。
    3. 按目录/文件运行 :直接指定路径,如 pytest test_cases/ pytest test_cases/test_login.py
    4. 上次运行失败 pytest --lf 只重新运行上次失败的用例。

问题5:环境配置管理混乱。

  • 现象 :测试脚本中硬编码了测试环境的 URL、账号密码,无法适配多环境(开发、测试、预生产)。
  • 解决 :使用 pytest 的插件如 pytest-base-url ,或者更通用的 python-dotenv + pytest 自定义选项。
    # conftest.py
    import os
    import pytest
    from dotenv import load_dotenv
    load_dotenv()  # 从 .env 文件加载环境变量
    
    def pytest_addoption(parser):
        parser.addoption("--env", action="store", default="test", help="选择测试环境: dev, test, staging")
    
    @pytest.fixture(scope="session")
    def base_url(pytestconfig):
        env = pytestconfig.getoption("--env")
        env_urls = {
            "dev": "https://dev-api.example.com",
            "test": "https://test-api.example.com",
            "staging": "https://staging-api.example.com",
        }
        url = env_urls.get(env)
        if not url:
            raise ValueError(f"未知的环境: {env}")
        return url
    
    @pytest.fixture(scope="session")
    def test_username():
        # 从环境变量读取,安全且可配置
        user = os.getenv("TEST_USERNAME")
        if not user:
            pytest.fail("请设置环境变量 TEST_USERNAME")
        return user
    
    运行命令: pytest --env=staging 。这样就能轻松切换测试环境。

更多推荐