1. 项目概述:从API测试到Python核心技能

如果你已经跟着这个系列走到了第三篇,说明你对API接口自动化测试这件事是动真格的了。前两篇我们搭好了环境,跑通了第一个测试脚本,你可能觉得“自动化测试不过如此”。但我要告诉你,真正的分水岭就在这里。很多朋友在入门后,脚本写得磕磕绊绊,遇到复杂一点的接口就束手无策,根本原因在于对支撑自动化测试的Python基础知识掌握得不够扎实。这篇教程,我们不赶进度,不追求新框架,就扎扎实实地回头啃一啃那些在API测试中天天用、但你可能一知半解的Python核心知识。我会结合真实的接口测试场景,把 requests 库的进阶用法、数据处理利器 json re 模块、以及如何优雅地组织你的测试代码讲透。目标是让你写的每一个测试脚本,都清晰、健壮、易于维护,而不仅仅是“能跑通”。

2. 核心Python库在API测试中的深度应用

在API自动化测试中,我们打交道最多的无非是“发送请求”和“处理响应”。这背后,几个Python标准库和第三方库扮演着核心角色。理解它们,就是理解自动化测试的筋骨。

2.1 requests库:远不止get和post

requests 库是Python玩转HTTP的瑞士军刀,但很多人只用了它最基本的功能。在自动化测试中,我们需要更精细的控制。

会话(Session)保持:模拟用户状态 对于需要登录态的API(比如查询用户信息、提交订单),每次请求都手动添加 Cookie Authorization 头是低效且容易出错的。 requests.Session() 才是正解。

import requests

# 创建一个会话对象,它会自动保存cookies,并在后续请求中携带
session = requests.Session()

# 1. 登录接口,获取身份凭证
login_url = "https://api.example.com/v1/login"
login_data = {"username": "testuser", "password": "testpass"}
login_resp = session.post(login_url, json=login_data)

# 此时,服务器返回的cookies(如session_id)会自动保存在session对象中
print(f"登录响应Cookies: {session.cookies.get_dict()}")

# 2. 访问需要登录的接口,无需手动处理cookies
profile_url = "https://api.example.com/v1/user/profile"
profile_resp = session.get(profile_url) # session自动携带了登录后的cookies
print(profile_resp.json())

注意 Session 对象不仅管理cookies,还能用于设置请求的默认参数,如 headers auth 等,非常适合用来封装一个特定业务场景的API客户端。

超时与重试机制:让测试更稳定 网络是不稳定的,接口偶尔超时是常态。一个健壮的测试脚本必须能处理这种情况。

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

# 定义重试策略
retry_strategy = Retry(
    total=3, # 最大重试次数
    backoff_factor=1, # 重试等待时间增长因子 (等待时间 = backoff_factor * (2^(重试次数-1)) 秒)
    status_forcelist=[429, 500, 502, 503, 504], # 遇到这些HTTP状态码会重试
    allowed_methods=["GET", "POST"] # 只对GET和POST方法重试
)

# 创建适配器并挂载重试策略
adapter = HTTPAdapter(max_retries=retry_strategy)

# 创建会话并挂载适配器到http和https
session = requests.Session()
session.mount("http://", adapter)
session.mount("https://", adapter)

try:
    # 设置单个请求的超时时间(连接超时,读取超时)
    response = session.get("https://api.example.com/slow-endpoint", timeout=(3.05, 10))
    print(response.status_code)
except requests.exceptions.Timeout:
    print("请求超时,已按策略重试数次,仍失败。")
except requests.exceptions.RequestException as e:
    print(f"请求发生异常: {e}")

这里的关键是 timeout 参数,它接收一个元组 (connect_timeout, read_timeout) connect_timeout 是客户端与服务器建立连接的超时时间, read_timeout 是客户端等待服务器发送数据的超时时间。根据网络状况和接口特性合理设置这两个值,可以避免测试脚本长时间挂起。

2.2 json模块:不仅仅是dumps和loads

API测试中,请求体和响应体几乎都是JSON格式。 json 模块大家都会用,但细节决定成败。

处理非标准JSON与编码问题 有些API返回的JSON字符串可能包含 \uXXXX 形式的Unicode转义字符,或者日期等特殊类型。直接 json.loads() 可能会遇到问题。

import json
from datetime import datetime

# 示例:服务器返回的JSON中包含日期字符串
response_text = '{"orderId": 12345, "createTime": "2023-10-27T10:30:00Z", "note": "test\\u4e2d\\u6587"}'

# 自定义JSON解码器处理日期
def json_date_hook(obj):
    if isinstance(obj, dict):
        for key, value in obj.items():
            # 尝试将符合ISO 8601格式的字符串转换为datetime对象
            if isinstance(value, str):
                try:
                    # 这里只是示例,实际可根据接口约定调整格式
                    obj[key] = datetime.fromisoformat(value.replace('Z', '+00:00'))
                except (ValueError, AttributeError):
                    pass
    return obj

# 使用object_hook参数
parsed_data = json.loads(response_text, object_hook=json_date_hook)
print(parsed_data)
# 输出: {'orderId': 12345, 'createTime': datetime.datetime(2023, 10, 27, 10, 30, tzinfo=datetime.timezone.utc), 'note': 'test中文'}
print(parsed_data['createTime'].year) # 现在可以像datetime对象一样操作了

格式化输出,方便调试 当断言失败时,对比两个复杂的JSON对象是噩梦。让它们以清晰格式打印出来。

import json

complex_response = {
    "code": 0,
    "data": {
        "user": {"id": 1, "name": "Alice", "roles": ["admin", "editor"]},
        "items": [{"id": 1, "name": "item1"}, {"id": 2, "name": "item2"}]
    },
    "message": "success"
}

# 不友好的打印
print(complex_response)

# 友好的打印,indent指定缩进,ensure_ascii确保中文正常显示
print(json.dumps(complex_response, indent=2, ensure_ascii=False))

在写断言时,我习惯将预期和实际的JSON都这样格式化后输出到日志,一眼就能看出差异在哪一行、哪个字段。

2.3 re模块:从响应中精准提取数据

正则表达式是处理非结构化文本数据的利器。在API测试中,虽然响应通常是结构化的JSON,但你仍然会遇到需要从 Header Cookie 或是某些特定文本字段(如错误信息)中提取数据的情况。

场景:从Set-Cookie头中提取session值 有些老式或特殊的认证方式,token可能藏在 Set-Cookie 头里。

import re

headers = {
    'Set-Cookie': 'session_id=s%3Aabc123def456.xyz789; Path=/; HttpOnly; Expires=Wed, 30 Oct 2024 05:00:00 GMT',
    'Content-Type': 'application/json'
}

# 使用正则表达式提取session_id的值
pattern = r'session_id=([^;]+)'
match = re.search(pattern, headers.get('Set-Cookie', ''))
if match:
    extracted_session = match.group(1)
    print(f"提取到的session: {extracted_session}")
    # 输出: 提取到的session: s%3Aabc123def456.xyz789
else:
    print("未找到session_id")

场景:验证响应消息的格式 断言错误信息是否符合预期的格式,而不仅仅是内容。

import re

# 假设接口返回的错误信息格式应为:“错误代码 [CODE]: 具体描述”
error_message = "错误代码 [AUTH_1001]: 用户令牌已过期"

expected_pattern = r"^错误代码 \[([A-Z_]+)\d+\]: (.+)$"
match = re.match(expected_pattern, error_message)

if match:
    error_code_prefix = match.group(1) # AUTH_
    error_desc = match.group(2) # 用户令牌已过期
    print(f"错误码前缀: {error_code_prefix}, 描述: {error_desc}")
    # 可以进一步断言 error_code_prefix 是否为 'AUTH_'
else:
    print("错误信息格式不符合预期")

实操心得 :正则表达式虽然强大,但可读性差,容易写错。在API测试中,能通过JSON路径(如 jsonpath 库)或直接键值访问获取的数据,就尽量不要用正则。正则更适合处理那些确实没有固定结构的文本片段。写好正则后,务必用在线工具(如regex101)测试各种边界情况。

3. 测试代码的结构化与数据驱动

当测试用例多起来后,把所有代码、数据、配置都堆在一个文件里会是维护的灾难。良好的结构是可持续自动化测试的基石。

3.1 模块化设计:分离关注点

一个典型的、结构清晰的API自动化测试项目目录可能如下所示:

api_auto_test_project/
├── config/           # 配置文件
│   ├── __init__.py
│   └── settings.py   # 存放环境URL、数据库配置等
├── common/           # 公共模块
│   ├── __init__.py
│   ├── api_client.py # 封装的requests会话类
│   └── logger.py     # 日志配置
├── test_data/        # 测试数据
│   ├── __init__.py
│   └── user_data.py  # 用户相关测试数据
├── test_cases/       # 测试用例
│   ├── __init__.py
│   ├── test_user.py  # 用户模块测试用例
│   └── test_order.py # 订单模块测试用例
├── utils/            # 工具函数
│   ├── __init__.py
│   └── assert_utils.py # 自定义断言方法
└── run_tests.py      # 测试执行入口

核心模块解析:api_client.py 这个文件封装了所有与HTTP请求相关的底层操作,是测试用例与 requests 库之间的桥梁。

# common/api_client.py
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
import logging

class ApiClient:
    """封装HTTP请求的客户端,提供重试、日志、统一错误处理"""
    
    def __init__(self, base_url):
        self.base_url = base_url.rstrip('/')
        self.session = requests.Session()
        self.logger = logging.getLogger(__name__)
        
        # 配置重试机制
        retries = Retry(total=2, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504])
        self.session.mount('http://', HTTPAdapter(max_retries=retries))
        self.session.mount('https://', HTTPAdapter(max_retries=retries))
        
        # 设置默认请求头
        self.session.headers.update({
            'Content-Type': 'application/json',
            'User-Agent': 'ApiAutoTest/1.0'
        })
    
    def _full_url(self, endpoint):
        """拼接完整的请求URL"""
        return f"{self.base_url}/{endpoint.lstrip('/')}"
    
    def request(self, method, endpoint, **kwargs):
        """发送请求的核心方法,统一添加日志和异常处理"""
        url = self._full_url(endpoint)
        self.logger.info(f"发送请求: {method.upper()} {url}")
        if kwargs.get('json'):
            self.logger.debug(f"请求体: {kwargs['json']}")
        if kwargs.get('params'):
            self.logger.debug(f"查询参数: {kwargs['params']}")
        
        try:
            resp = self.session.request(method, url, **kwargs)
            resp.raise_for_status() # 如果状态码不是2xx,抛出HTTPError异常
            self.logger.info(f"请求成功: {resp.status_code}")
            self.logger.debug(f"响应体: {resp.text[:500]}...") # 日志只记录前500字符
            return resp
        except requests.exceptions.HTTPError as e:
            self.logger.error(f"HTTP请求错误: {e}, 响应内容: {e.response.text if e.response else '无'}")
            raise
        except requests.exceptions.RequestException as e:
            self.logger.error(f"网络请求异常: {e}")
            raise
    
    # 提供便捷方法
    def get(self, endpoint, **kwargs):
        return self.request('GET', endpoint, **kwargs)
    
    def post(self, endpoint, **kwargs):
        return self.request('POST', endpoint, **kwargs)
    
    def put(self, endpoint, **kwargs):
        return self.request('PUT', endpoint, **kwargs)
    
    def delete(self, endpoint, **kwargs):
        return self.request('DELETE', endpoint, **kwargs)

在测试用例中,你就可以这样清晰、安全地调用:

# test_cases/test_user.py
from common.api_client import ApiClient
from config.settings import BASE_URL

client = ApiClient(BASE_URL)

def test_get_user_info():
    """测试获取用户信息接口"""
    resp = client.get('/api/v1/users/1')
    assert resp.status_code == 200
    user_data = resp.json()
    assert user_data['id'] == 1
    assert 'name' in user_data

3.2 数据驱动测试:将测试数据与逻辑分离

数据驱动测试(DDT)的核心思想是将测试用例的数据(输入、预期输出)从测试脚本中抽离出来,存放在单独的文件或数据结构中。这样,同一套测试逻辑可以轻松地用多组数据来执行。

使用Python内置数据结构驱动 最简单的方式是使用列表或元组。

# test_data/user_data.py
LOGIN_TEST_DATA = [
    # (username, password, expected_status_code, expected_message_keyword)
    ("correct_user", "correct_pass", 200, "success"),
    ("wrong_user", "correct_pass", 401, "invalid credentials"),
    ("correct_user", "", 400, "password required"),
    ("", "correct_pass", 400, "username required"),
]

# test_cases/test_login.py
import pytest
from common.api_client import ApiClient
from test_data.user_data import LOGIN_TEST_DATA

client = ApiClient("https://api.example.com")

@pytest.mark.parametrize("username, password, exp_code, exp_msg", LOGIN_TEST_DATA)
def test_login_with_different_data(username, password, exp_code, exp_msg):
    """使用参数化进行数据驱动登录测试"""
    payload = {"username": username, "password": password}
    resp = client.post('/login', json=payload)
    
    assert resp.status_code == exp_code
    resp_json = resp.json()
    # 断言返回的消息中包含预期的关键词
    assert exp_msg in resp_json.get('message', '').lower()

使用外部文件驱动(JSON/YAML) 当测试数据非常复杂或需要非技术人员维护时,外部文件是更好的选择。

# test_data/login_cases.yaml
- case_name: "正常登录"
  request:
    username: "test_user"
    password: "P@ssw0rd123"
  expect:
    status_code: 200
    response_contains:
      - "token"
      - "user_id"
- case_name: "密码错误"
  request:
    username: "test_user"
    password: "wrong"
  expect:
    status_code: 401
    response_contains:
      - "认证失败"
# test_cases/test_login_with_yaml.py
import yaml
import pytest
from common.api_client import ApiClient

client = ApiClient("https://api.example.com")

def load_test_cases():
    with open('test_data/login_cases.yaml', 'r', encoding='utf-8') as f:
        cases = yaml.safe_load(f)
    return cases

@pytest.mark.parametrize('case', load_test_cases())
def test_login_from_yaml(case):
    """从YAML文件加载数据驱动测试"""
    resp = client.post('/login', json=case['request'])
    
    assert resp.status_code == case['expect']['status_code']
    resp_text = resp.text
    for expected_text in case['expect']['response_contains']:
        assert expected_text in resp_text, f"响应中未找到预期文本: {expected_text}"

注意事项 :使用外部文件时,要处理好文件路径问题(建议使用 os.path 构造绝对路径),并注意文件的编码(统一使用UTF-8)。YAML文件对格式(缩进)非常敏感,编辑时要小心。

4. 断言的艺术:超越简单的相等判断

断言是测试的灵魂,但 assert response.status_code == 200 assert response.json()['code'] == 0 只是开始。在复杂的业务场景下,我们需要更强大、更灵活的断言方式。

4.1 针对JSON响应的结构化断言

对于复杂的JSON响应,逐字段进行 == 判断既冗长又脆弱(比如服务器可能返回一些无关的动态字段)。我们需要能进行“部分匹配”和“结构验证”的断言。

使用 jsonschema 进行模式验证 jsonschema 库允许你定义一个JSON模式(Schema),然后验证响应是否符合这个模式。这非常适合用来确保API返回的数据结构是稳定的。

import jsonschema
from jsonschema import validate

# 1. 定义你期望的响应JSON模式
user_schema = {
    "type": "object",
    "properties": {
        "id": {"type": "number"},
        "username": {"type": "string", "minLength": 1},
        "email": {"type": "string", "format": "email"}, # 使用format校验邮箱格式
        "roles": {
            "type": "array",
            "items": {"type": "string"},
            "minItems": 1
        },
        "createdAt": {"type": "string", "format": "date-time"}, # 校验ISO日期时间格式
        "profile": {"type": ["object", "null"]} # profile可以是对象或null
    },
    "required": ["id", "username", "email"], # 必须存在的字段
    "additionalProperties": True # 允许存在其他未定义的字段
}

# 2. 在测试中验证
def test_get_user_schema():
    resp = client.get('/api/v1/users/1')
    user_data = resp.json()['data']
    
    try:
        validate(instance=user_data, schema=user_schema)
        print("响应数据符合模式定义")
    except jsonschema.exceptions.ValidationError as e:
        pytest.fail(f"响应数据模式验证失败: {e.message} at path: {e.path}")

这种方式比一堆 assert 语句更清晰,也更能捕获结构性的错误。当API字段有增减或类型变化时,只需更新 schema 定义即可。

使用 deepdiff 进行智能对比 有时你需要对比两个复杂的JSON对象(比如,更新用户信息后的响应和更新前的响应),并找出所有差异。 deepdiff 库能完美胜任。

from deepdiff import DeepDiff

# 假设这是更新前的用户数据
before_update = {
    "id": 1,
    "name": "Alice",
    "age": 25,
    "tags": ["engineer", "reader"]
}

# 这是调用更新接口后的响应数据
after_update = {
    "id": 1,
    "name": "Alice Smith", # 名字变了
    "age": 26, # 年龄变了
    "tags": ["engineer", "reader", "traveler"], # 标签增加了
    "updatedAt": "2023-10-27T11:00:00Z" # 新增了字段
}

diff = DeepDiff(before_update, after_update, ignore_order=True)
print(diff.pretty())

# 输出会清晰地显示:
# 值发生变化的项:
#   root['age'] 从 25 变为 26
#   root['name'] 从 'Alice' 变为 'Alice Smith'
# 新增的项:
#   root['updatedAt']
# 列表新增项:
#   root['tags'][2] 新增了 'traveler'

在测试中,你可以用 DeepDiff 来断言只有你期望的字段发生了变化,而其他字段保持不变。

4.2 自定义断言函数,提高可读性

将常用的、复杂的断言逻辑封装成函数,能让测试用例读起来像自然语言。

# utils/assert_utils.py
import json
from typing import Any, Dict, List

def assert_response_status(resp, expected_status: int):
    """断言响应状态码,并附带响应体信息在失败时输出"""
    assert resp.status_code == expected_status, \
        f"状态码断言失败。预期: {expected_status}, 实际: {resp.status_code}。响应体: {resp.text[:200]}"

def assert_json_contains(resp, expected_key_values: Dict[str, Any]):
    """断言JSON响应中包含特定的键值对"""
    resp_json = resp.json()
    for key, expected_value in expected_key_values.items():
        actual_value = resp_json.get(key)
        assert actual_value == expected_value, \
            f"键 '{key}' 的值断言失败。预期: {expected_value}, 实际: {actual_value}"

def assert_response_time_less_than(resp, threshold_ms: int):
    """断言接口响应时间小于阈值"""
    elapsed_ms = resp.elapsed.total_seconds() * 1000
    assert elapsed_ms < threshold_ms, \
        f"接口响应时间 {elapsed_ms:.2f}ms 超过阈值 {threshold_ms}ms"

# 在测试用例中使用
from utils.assert_utils import assert_response_status, assert_json_contains

def test_create_order():
    payload = {"productId": 1001, "quantity": 2}
    resp = client.post('/orders', json=payload)
    
    assert_response_status(resp, 201)
    assert_json_contains(resp, {"status": "PENDING"})
    assert_response_time_less_than(resp, 500) # 响应时间应小于500毫秒

5. 测试报告与日志:让问题无处可藏

测试脚本跑完了,是绿是红很重要,但更重要的是,当它变红时,你能多快定位到问题。清晰、详尽的日志和测试报告是关键。

5.1 结构化日志记录

Python内置的 logging 模块足够强大。我们需要为自动化测试配置一个合理的日志格式和级别。

# common/logger.py
import logging
import sys
from pathlib import Path

def setup_logger(name='api_auto_test', log_level=logging.INFO, log_file=None):
    """配置并返回一个日志器"""
    logger = logging.getLogger(name)
    logger.setLevel(log_level)
    
    # 避免重复添加handler
    if logger.handlers:
        return logger
    
    # 定义日志格式
    formatter = logging.Formatter(
        '%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s'
    )
    
    # 控制台处理器
    console_handler = logging.StreamHandler(sys.stdout)
    console_handler.setLevel(log_level)
    console_handler.setFormatter(formatter)
    logger.addHandler(console_handler)
    
    # 文件处理器(可选)
    if log_file:
        # 确保日志目录存在
        log_path = Path(log_file)
        log_path.parent.mkdir(parents=True, exist_ok=True)
        
        file_handler = logging.FileHandler(log_file, encoding='utf-8')
        file_handler.setLevel(log_level)
        file_handler.setFormatter(formatter)
        logger.addHandler(file_handler)
    
    return logger

# 在项目入口或配置中初始化
# test_logger = setup_logger(log_file='./logs/test_run.log')

ApiClient 或其他模块中引入这个日志器:

# common/api_client.py (续前)
from .logger import setup_logger

class ApiClient:
    def __init__(self, base_url):
        # ... 其他初始化 ...
        self.logger = setup_logger('api_client') # 使用独立的logger名称

5.2 生成美观的测试报告

对于CI/CD集成或团队分享,一个可视化的HTML报告比控制台输出友好得多。 pytest-html 插件是首选。

首先安装: pip install pytest-html

然后,在运行测试时指定生成报告:

# 运行测试并生成HTML报告
pytest test_cases/ -v --html=reports/test_report.html --self-contained-html

--self-contained-html 参数会将CSS样式内嵌到HTML中,生成一个独立的文件,方便传播。

你还可以在 conftest.py 文件中进行更详细的配置,比如添加环境信息到报告中:

# 项目根目录下的 conftest.py
import pytest
from datetime import datetime

def pytest_configure(config):
    """Pytest配置钩子,用于添加元数据到报告"""
    config._metadata = {
        "测试项目": "API接口自动化测试",
        "测试环境": "Staging",
        "执行时间": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "Python版本": "3.9",
    }

@pytest.hookimpl(optionalhook=True)
def pytest_html_results_summary(prefix, summary, postfix):
    """修改HTML报告的摘要部分"""
    prefix.extend([f"<p><strong>本次测试重点:</strong> 用户模块与订单模块核心流程</p>"])

生成的报告会包含每个测试用例的状态、执行时间、错误详情(包括截图,如果配合 pytest-selenium 等)以及你自定义的环境信息,非常利于问题回溯和结果展示。

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

在实际编写和执行API自动化测试的过程中,你会遇到各种各样意想不到的问题。这里记录了一些高频问题和我的解决思路。

6.1 SSL证书验证错误

在测试内部或使用自签名证书的环境时,经常会遇到 requests.exceptions.SSLError

问题 SSLError: HTTPSConnectionPool(host='...', port=443): Max retries exceeded with url: ... (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self signed certificate (_ssl.c:1129)'))

解决

  1. 临时禁用验证(仅用于测试环境) :在 requests 请求中设置 verify=False 务必注意,生产环境绝对不要这样做,这会带来中间人攻击风险。
    resp = requests.get('https://internal-api.example.com', verify=False)
    
  2. 指定自定义CA证书包 :如果你有内部CA的证书文件(.pem或.crt),可以将其路径传给 verify 参数。
    resp = requests.get('https://internal-api.example.com', verify='/path/to/custom/ca-bundle.crt')
    
  3. ApiClient 中统一处理 :为了安全,建议在封装的客户端中根据环境配置决定是否验证。
    class ApiClient:
        def __init__(self, base_url, verify_ssl=True):
            # ...
            self.session.verify = verify_ssl # 可以传入False或证书路径
    

6.2 接口响应慢导致测试超时

问题 :某个查询接口在数据量大时响应很慢,导致测试用例因超时而失败,但这并不是接口功能错误。

解决

  1. 区分功能断言与性能断言 :将响应时间的检查从核心功能断言中分离。首先断言接口返回了正确的业务数据(状态码、关键字段),然后再断言其性能是否符合要求。
    def test_slow_query():
        resp = client.get('/api/big-data-report', timeout=30) # 单独为这个慢查询设置更长的超时
        assert resp.status_code == 200 # 1. 断言功能正确
        data = resp.json()
        assert data['reportId'] is not None
        
        # 2. 断言性能,但失败时不标记为功能错误
        if resp.elapsed.total_seconds() > 10:
            pytest.xfail(f"接口响应时间({resp.elapsed.total_seconds():.1f}s)超过10秒,需优化。") # 标记为预期失败
    
  2. 使用 pytest.mark 标记 :给这类已知的慢测试打上标记,在常规快速回归中跳过它们。
    import pytest
    @pytest.mark.slow
    def test_big_data_export():
        # ... 测试代码 ...
    
    # 命令行运行:pytest -m "not slow"  # 跳过标记为slow的测试
    

6.3 测试数据污染与清理

问题 :测试“创建订单”接口成功后,数据库中多了一条测试订单。如果下次运行前不清理,可能导致“订单已存在”等错误,或者影响其他测试(如查询订单列表的断言)。

解决

  1. 测试固件(Fixture)清理 :在Pytest中,使用 yield 的fixture可以在测试完成后执行清理代码。
    import pytest
    
    @pytest.fixture
    def clean_test_order(client):
        """创建一个测试订单,并在测试完成后删除它"""
        order_data = {"productId": 999, "quantity": 1}
        create_resp = client.post('/orders', json=order_data)
        order_id = create_resp.json()['id']
        
        yield order_id # 将order_id提供给测试用例使用
        
        # 测试用例执行完毕后,执行清理
        client.delete(f'/orders/{order_id}')
        print(f"已清理测试订单: {order_id}")
    
    def test_order_operations(clean_test_order):
        order_id = clean_test_order
        # 使用这个order_id进行查询、更新等测试
        resp = client.get(f'/orders/{order_id}')
        assert resp.status_code == 200
    
  2. 使用测试专用数据 :为自动化测试准备一套隔离的数据,比如使用特定的用户前缀( test_ )、或在测试环境中使用独立的数据库。并在测试套件开始前和结束后,通过调用专门的初始化/清理接口来重置数据状态。

6.4 如何处理动态数据(如token、时间戳)

问题 :很多接口需要携带一个有过期时间的token,或者在请求体中需要当前时间戳。这些数据每次运行都会变,不能写死在测试脚本里。

解决

  1. 使用fixture动态获取 :对于token,写一个fixture来负责登录并返回有效的token。
    import pytest
    import time
    
    @pytest.fixture(scope='session') # session级别,所有测试用例只登录一次
    def auth_token(client):
        """获取认证token"""
        login_resp = client.post('/auth/login', json={'user': 'test', 'pass': 'test'})
        token = login_resp.json()['access_token']
        # 可以在这里简单校验token有效性(如是否包含必要字段),但通常直接使用
        return token
    
    @pytest.fixture
    def authenticated_client(client, auth_token):
        """返回一个已设置认证头的客户端"""
        client.session.headers.update({'Authorization': f'Bearer {auth_token}'})
        return client
    
    def test_secure_endpoint(authenticated_client):
        # 这个client已经自带token了
        resp = authenticated_client.get('/secure/data')
        assert resp.status_code == 200
    
  2. 在请求前实时生成 :对于时间戳,在发起请求的瞬间生成。
    import time
    
    def test_create_with_timestamp(client):
        current_timestamp = int(time.time() * 1000) # 毫秒时间戳
        payload = {
            "event": "page_view",
            "timestamp": current_timestamp,
            "data": {...}
        }
        resp = client.post('/events', json=payload)
        assert resp.status_code == 201
    
    如果服务器对时间戳的验证非常严格(如要求是当前时间前后几分钟内),这种方法是最可靠的。

掌握这些Python基础知识,并按照清晰的结构来组织你的API自动化测试代码,你会发现编写和维护测试用例的效率大大提升,脚本的稳定性和可读性也今非昔比。自动化测试不是一蹴而就的,它是在不断遇到问题、解决问题、优化代码的过程中逐渐成熟起来的。当你下次再面对一个复杂的接口测试需求时,希望这些工具和思路能帮你从容拆解,写出既稳健又优雅的测试代码。

更多推荐