22.pytest的接口自动化封装实战

Pytest 的接口自动化封装实战

1. 什么是接口自动化封装

1.1 封装的概念

封装(Encapsulation) 是面向对象编程的核心概念之一,在接口自动化测试中,封装指的是将重复的代码逻辑、通用的功能模块进行抽象和封装,形成可复用的组件。

简单来说,封装就是把复杂的操作简化成简单的调用,把重复的代码提取成公共的方法

1.2 为什么需要封装

在接口自动化测试中,如果不进行封装,我们可能会写出这样的代码:

# 不封装的代码示例 - 问题很多
def test_login():
    import requests
    url = "https://api.example.com/login"
    headers = {
        "Content-Type": "application/json",
        "User-Agent": "Mozilla/5.0"
    }
    data = {
        "username": "admin",
        "password": "123456"
    }
    response = requests.post(url, json=data, headers=headers)
    assert response.status_code == 200
    result = response.json()
    assert result["code"] == 0
    assert result["message"] == "登录成功"
    token = result["data"]["token"]

    # 下一个接口测试又需要重复写类似的代码
    url2 = "https://api.example.com/user/info"
    headers2 = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {token}"
    }
    response2 = requests.get(url2, headers=headers2)
    assert response2.status_code == 200
    # ... 更多重复代码

这种写法的问题:

  1. 代码重复:每个测试用例都要写请求代码、断言代码
  2. 难以维护:如果接口地址改变,需要修改很多地方
  3. 错误处理不统一:每个地方都要单独处理异常
  4. 日志记录不统一:每个地方都要单独记录日志
  5. 可读性差:测试用例中混杂了大量技术细节

封装后的代码:

# 封装后的代码示例 - 简洁清晰
def test_login(api_client):
    """测试登录接口"""
    # 发送登录请求
    response = api_client.login(username="admin", password="123456")

    # 验证响应
    response.assert_status_code(200)
    response.assert_json_path("code", 0)
    response.assert_json_path("message", "登录成功")

    # 提取 token
    token = response.extract("data.token")

    # 使用 token 获取用户信息
    user_info = api_client.get_user_info(token=token)
    user_info.assert_status_code(200)

封装的优点:

  1. 代码复用:一次封装,多处使用
  2. 易于维护:修改一处,全局生效
  3. 统一管理:错误处理、日志记录统一管理
  4. 提高可读性:测试用例更清晰,专注于业务逻辑
  5. 降低学习成本:新手只需要调用封装好的方法

1.3 封装的核心原则

在进行接口自动化封装时,需要遵循以下原则:

1.3.1 单一职责原则

每个封装类或方法只负责一个功能,不要在一个方法中做太多事情。

# ❌ 不好的封装 - 职责过多
class ApiClient:
    def login_and_get_user_info(self, username, password):
        # 登录
        login_response = requests.post(...)
        token = login_response.json()["data"]["token"]
        # 获取用户信息
        user_response = requests.get(..., headers={"Authorization": token})
        return user_response.json()
        # 问题:这个方法做了两件事,不够灵活

# ✅ 好的封装 - 职责单一
class ApiClient:
    def login(self, username, password):
        """只负责登录"""
        response = requests.post(...)
        return response

    def get_user_info(self, token):
        """只负责获取用户信息"""
        response = requests.get(..., headers={"Authorization": token})
        return response

1.3.2 DRY 原则(Don’t Repeat Yourself)

不要重复代码,将重复的逻辑提取成公共方法。

# ❌ 不好的封装 - 代码重复
def test_login():
    headers = {"Content-Type": "application/json"}
    response = requests.post(url, json=data, headers=headers)

def test_register():
    headers = {"Content-Type": "application/json"}
    response = requests.post(url2, json=data2, headers=headers)

# ✅ 好的封装 - 消除重复
class ApiClient:
    def _build_headers(self):
        """构建公共请求头"""
        return {"Content-Type": "application/json"}

    def post(self, url, data):
        """统一的 POST 请求方法"""
        headers = self._build_headers()
        return requests.post(url, json=data, headers=headers)

1.3.3 开闭原则

对扩展开放,对修改关闭。封装应该易于扩展,而不需要修改原有代码。

# ✅ 好的封装 - 易于扩展
class ApiClient:
    def __init__(self, base_url):
        self.base_url = base_url
        self.session = requests.Session()

    def _request(self, method, endpoint, **kwargs):
        """统一的请求方法,易于扩展"""
        url = f"{self.base_url}{endpoint}"
        return self.session.request(method, url, **kwargs)

    # 可以轻松添加新的请求方法,不需要修改 _request
    def get(self, endpoint, **kwargs):
        return self._request("GET", endpoint, **kwargs)

    def post(self, endpoint, **kwargs):
        return self._request("POST", endpoint, **kwargs)

1.3.4 配置与代码分离

将配置信息(如 URL、超时时间等)与代码分离,便于不同环境使用。

# ✅ 好的封装 - 配置分离
# config.yaml
base_url: https://api.example.com
timeout: 30

# api_client.py
class ApiClient:
    def __init__(self, config):
        self.base_url = config["base_url"]
        self.timeout = config["timeout"]

2. 接口自动化封装的层次结构

2.1 封装层次概览

接口自动化封装通常分为以下几个层次:

┌─────────────────────────────────────┐
│   测试用例层(Test Cases)          │  ← 最上层,编写测试用例
├─────────────────────────────────────┤
│   业务接口层(Business API)        │  ← 封装业务接口(登录、注册等)
├─────────────────────────────────────┤
│   HTTP 请求层(HTTP Client)        │  ← 封装 HTTP 请求(GET、POST等)
├─────────────────────────────────────┤
│   工具层(Utils)                   │  ← 封装工具方法(断言、日志等)
├─────────────────────────────────────┤
│   配置层(Config)                  │  ← 最底层,管理配置信息
└─────────────────────────────────────┘

2.2 各层次详细说明

2.2.1 配置层(Config Layer)

作用:管理所有配置信息,包括环境配置、数据库配置、接口地址等。

示例:

# config/config.py
import os
import yaml
from pathlib import Path

class Config:
    """配置管理类"""

    def __init__(self, env="test"):
        """
        初始化配置
        :param env: 环境名称(test、dev、prod)
        """
        self.env = env
        self.base_dir = Path(__file__).parent.parent
        self.config_file = self.base_dir / "config" / f"{env}_config.yaml"
        self._load_config()

    def _load_config(self):
        """加载配置文件"""
        with open(self.config_file, "r", encoding="utf-8") as f:
            self.config = yaml.safe_load(f)

    @property
    def base_url(self):
        """获取基础 URL"""
        return self.config["api"]["base_url"]

    @property
    def timeout(self):
        """获取超时时间"""
        return self.config["api"]["timeout"]

    @property
    def db_config(self):
        """获取数据库配置"""
        return self.config["database"]

配置文件示例:

# config/test_config.yaml
api:
  base_url: https://api.test.example.com
  timeout: 30

database:
  host: localhost
  port: 3306
  username: test_user
  password: test_pass
  database: test_db

logging:
  level: DEBUG
  file: logs/test.log

2.2.2 工具层(Utils Layer)

作用:封装通用的工具方法,如断言、日志、数据解析等。

示例:

# utils/assertion.py
import json
import jsonpath

class Assertion:
    """断言工具类"""

    @staticmethod
    def assert_status_code(response, expected_code):
        """
        断言状态码
        :param response: 响应对象
        :param expected_code: 期望的状态码
        """
        actual_code = response.status_code
        assert actual_code == expected_code, 
            f"状态码断言失败:期望 {expected_code},实际 {actual_code}"

    @staticmethod
    def assert_json_path(response, json_path, expected_value):
        """
        断言 JSON 路径的值
        :param response: 响应对象
        :param json_path: JSON 路径表达式
        :param expected_value: 期望的值
        """
        try:
            data = response.json()
            actual_value = jsonpath.jsonpath(data, json_path)
            if not actual_value:
                raise AssertionError(f"JSON 路径 {json_path} 不存在")
            actual_value = actual_value[0]
            assert actual_value == expected_value, 
                f"JSON 路径断言失败:路径 {json_path},期望 {expected_value},实际 {actual_value}"
        except json.JSONDecodeError:
            raise AssertionError(f"响应不是有效的 JSON 格式:{response.text}")

    @staticmethod
    def assert_contains(response, text):
        """
        断言响应包含指定文本
        :param response: 响应对象
        :param text: 要查找的文本
        """
        assert text in response.text, 
            f"响应中不包含文本 '{text}':{response.text}"

# utils/logger.py
import logging
import os
from pathlib import Path

class Logger:
    """日志工具类"""

    def __init__(self, name="test", level=logging.DEBUG):
        """
        初始化日志器
        :param name: 日志器名称
        :param level: 日志级别
        """
        self.logger = logging.getLogger(name)
        self.logger.setLevel(level)

        # 避免重复添加处理器
        if not self.logger.handlers:
            # 控制台处理器
            console_handler = logging.StreamHandler()
            console_handler.setLevel(level)

            # 文件处理器
            log_dir = Path(__file__).parent.parent / "logs"
            log_dir.mkdir(exist_ok=True)
            file_handler = logging.FileHandler(
                log_dir / f"{name}.log",
                encoding="utf-8"
            )
            file_handler.setLevel(level)

            # 格式化器
            formatter = logging.Formatter(
                '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
            )
            console_handler.setFormatter(formatter)
            file_handler.setFormatter(formatter)

            self.logger.addHandler(console_handler)
            self.logger.addHandler(file_handler)

    def debug(self, message):
        """记录 DEBUG 级别日志"""
        self.logger.debug(message)

    def info(self, message):
        """记录 INFO 级别日志"""
        self.logger.info(message)

    def warning(self, message):
        """记录 WARNING 级别日志"""
        self.logger.warning(message)

    def error(self, message):
        """记录 ERROR 级别日志"""
        self.logger.error(message)

2.2.3 HTTP 请求层(HTTP Client Layer)

作用:封装底层的 HTTP 请求,提供统一的请求接口。

示例:

# clients/http_client.py
import requests
import json
from typing import Dict, Optional, Any
from utils.logger import Logger

class HttpClient:
    """HTTP 客户端封装类"""

    def __init__(self, base_url: str, timeout: int = 30):
        """
        初始化 HTTP 客户端
        :param base_url: 基础 URL
        :param timeout: 超时时间(秒)
        """
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.session = requests.Session()
        self.logger = Logger("http_client")

        # 设置默认请求头
        self.session.headers.update({
            "Content-Type": "application/json",
            "User-Agent": "Python-Automation-Test/1.0"
        })

    def _build_url(self, endpoint: str) -> str:
        """
        构建完整 URL
        :param endpoint: 接口路径
        :return: 完整 URL
        """
        endpoint = endpoint.lstrip("/")
        return f"{self.base_url}/{endpoint}"

    def _log_request(self, method: str, url: str, **kwargs):
        """记录请求日志"""
        self.logger.info(f"[请求] {method} {url}")
        if "json" in kwargs:
            self.logger.debug(f"[请求体] {json.dumps(kwargs['json'], ensure_ascii=False, indent=2)}")
        if "params" in kwargs:
            self.logger.debug(f"[请求参数] {kwargs['params']}")
        if "headers" in kwargs:
            self.logger.debug(f"[请求头] {kwargs['headers']}")

    def _log_response(self, response: requests.Response):
        """记录响应日志"""
        self.logger.info(f"[响应] 状态码: {response.status_code}")
        try:
            self.logger.debug(f"[响应体] {json.dumps(response.json(), ensure_ascii=False, indent=2)}")
        except:
            self.logger.debug(f"[响应体] {response.text[:500]}")  # 只记录前500个字符

    def 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:
        """
        发送 HTTP 请求
        :param method: 请求方法(GET、POST、PUT、DELETE等)
        :param endpoint: 接口路径
        :param params: URL 参数
        :param json_data: JSON 格式的请求体
        :param data: 表单格式的请求体
        :param headers: 请求头
        :param kwargs: 其他 requests 参数
        :return: 响应对象
        """
        url = self._build_url(endpoint)

        # 合并请求头
        request_headers = self.session.headers.copy()
        if headers:
            request_headers.update(headers)

        # 记录请求日志
        self._log_request(method, url, params=params, json=json_data, headers=request_headers)

        try:
            # 发送请求
            response = self.session.request(
                method=method,
                url=url,
                params=params,
                json=json_data,
                data=data,
                headers=request_headers,
                timeout=self.timeout,
                **kwargs
            )

            # 记录响应日志
            self._log_response(response)

            return response

        except requests.exceptions.Timeout:
            self.logger.error(f"请求超时:{method} {url}")
            raise
        except requests.exceptions.ConnectionError:
            self.logger.error(f"连接错误:{method} {url}")
            raise
        except Exception as e:
            self.logger.error(f"请求异常:{method} {url},错误:{str(e)}")
            raise

    def get(self, endpoint: str, params: Optional[Dict] = None, **kwargs) -> requests.Response:
        """发送 GET 请求"""
        return self.request("GET", endpoint, params=params, **kwargs)

    def post(self, endpoint: str, json_data: Optional[Dict] = None, **kwargs) -> requests.Response:
        """发送 POST 请求"""
        return self.request("POST", endpoint, json_data=json_data, **kwargs)

    def put(self, endpoint: str, json_data: Optional[Dict] = None, **kwargs) -> requests.Response:
        """发送 PUT 请求"""
        return self.request("PUT", endpoint, json_data=json_data, **kwargs)

    def delete(self, endpoint: str, **kwargs) -> requests.Response:
        """发送 DELETE 请求"""
        return self.request("DELETE", endpoint, **kwargs)

    def set_header(self, key: str, value: str):
        """设置请求头"""
        self.session.headers[key] = value

    def remove_header(self, key: str):
        """移除请求头"""
        if key in self.session.headers:
            del self.session.headers[key]

2.2.4 业务接口层(Business API Layer)

作用:封装具体的业务接口,如登录、注册、查询用户等。

示例:

# api/user_api.py
from clients.http_client import HttpClient
from utils.response_handler import ResponseHandler

class UserAPI:
    """用户相关接口"""

    def __init__(self, http_client: HttpClient):
        """
        初始化用户 API
        :param http_client: HTTP 客户端实例
        """
        self.client = http_client

    def login(self, username: str, password: str) -> ResponseHandler:
        """
        用户登录
        :param username: 用户名
        :param password: 密码
        :return: 响应处理器
        """
        endpoint = "/api/user/login"
        data = {
            "username": username,
            "password": password
        }
        response = self.client.post(endpoint, json_data=data)
        return ResponseHandler(response)

    def register(self, username: str, password: str, email: str) -> ResponseHandler:
        """
        用户注册
        :param username: 用户名
        :param password: 密码
        :param email: 邮箱
        :return: 响应处理器
        """
        endpoint = "/api/user/register"
        data = {
            "username": username,
            "password": password,
            "email": email
        }
        response = self.client.post(endpoint, json_data=data)
        return ResponseHandler(response)

    def get_user_info(self, token: str) -> ResponseHandler:
        """
        获取用户信息
        :param token: 认证 token
        :return: 响应处理器
        """
        endpoint = "/api/user/info"
        headers = {
            "Authorization": f"Bearer {token}"
        }
        response = self.client.get(endpoint, headers=headers)
        return ResponseHandler(response)

    def update_user_info(self, token: str, **kwargs) -> ResponseHandler:
        """
        更新用户信息
        :param token: 认证 token
        :param kwargs: 要更新的用户信息字段
        :return: 响应处理器
        """
        endpoint = "/api/user/info"
        headers = {
            "Authorization": f"Bearer {token}"
        }
        response = self.client.put(endpoint, json_data=kwargs, headers=headers)
        return ResponseHandler(response)

2.2.5 响应处理器(Response Handler)

作用:封装响应对象的常用操作,如断言、数据提取等。

示例:

# utils/response_handler.py
import requests
import json
import jsonpath
from typing import Any, Optional

class ResponseHandler:
    """响应处理器"""

    def __init__(self, response: requests.Response):
        """
        初始化响应处理器
        :param response: requests 响应对象
        """
        self.response = response
        self._json_data = None

    @property
    def status_code(self) -> int:
        """获取状态码"""
        return self.response.status_code

    @property
    def text(self) -> str:
        """获取响应文本"""
        return self.response.text

    @property
    def json(self) -> dict:
        """获取 JSON 数据(缓存)"""
        if self._json_data is None:
            try:
                self._json_data = self.response.json()
            except json.JSONDecodeError:
                raise ValueError(f"响应不是有效的 JSON 格式:{self.response.text}")
        return self._json_data

    def assert_status_code(self, expected_code: int):
        """
        断言状态码
        :param expected_code: 期望的状态码
        """
        actual_code = self.status_code
        assert actual_code == expected_code, 
            f"状态码断言失败:期望 {expected_code},实际 {actual_code}"
        return self

    def assert_json_path(self, json_path: str, expected_value: Any):
        """
        断言 JSON 路径的值
        :param json_path: JSON 路径表达式
        :param expected_value: 期望的值
        """
        actual_value = self.extract(json_path)
        assert actual_value == expected_value, 
            f"JSON 路径断言失败:路径 {json_path},期望 {expected_value},实际 {actual_value}"
        return self

    def assert_contains(self, text: str):
        """
        断言响应包含指定文本
        :param text: 要查找的文本
        """
        assert text in self.text, 
            f"响应中不包含文本 '{text}':{self.text[:200]}"
        return self

    def extract(self, json_path: str) -> Any:
        """
        提取 JSON 路径的值
        :param json_path: JSON 路径表达式
        :return: 提取的值
        """
        try:
            data = self.json
            result = jsonpath.jsonpath(data, json_path)
            if not result:
                raise ValueError(f"JSON 路径 {json_path} 不存在")
            return result[0]
        except Exception as e:
            raise ValueError(f"提取 JSON 路径失败:{json_path},错误:{str(e)}")

    def extract_all(self, json_path: str) -> list:
        """
        提取 JSON 路径的所有匹配值
        :param json_path: JSON 路径表达式
        :return: 提取的值列表
        """
        try:
            data = self.json
            result = jsonpath.jsonpath(data, json_path)
            return result if result else []
        except Exception as e:
            raise ValueError(f"提取 JSON 路径失败:{json_path},错误:{str(e)}")

2.2.6 测试用例层(Test Cases Layer)

作用:编写具体的测试用例,调用封装好的接口。

示例:

# tests/test_user_api.py
import pytest
from config.config import Config
from clients.http_client import HttpClient
from api.user_api import UserAPI

@pytest.fixture(scope="module")
def config():
    """配置 fixture"""
    return Config(env="test")

@pytest.fixture(scope="module")
def http_client(config):
    """HTTP 客户端 fixture"""
    return HttpClient(
        base_url=config.base_url,
        timeout=config.timeout
    )

@pytest.fixture(scope="module")
def user_api(http_client):
    """用户 API fixture"""
    return UserAPI(http_client)

class TestUserAPI:
    """用户接口测试类"""

    def test_login_success(self, user_api):
        """测试登录成功"""
        # 发送登录请求
        response = user_api.login(username="admin", password="123456")

        # 断言
        response.assert_status_code(200)
        response.assert_json_path("code", 0)
        response.assert_json_path("message", "登录成功")

        # 提取 token
        token = response.extract("data.token")
        assert token is not None

    def test_login_failed(self, user_api):
        """测试登录失败"""
        response = user_api.login(username="wrong", password="wrong")
        response.assert_status_code(200)
        response.assert_json_path("code", 1001)
        response.assert_json_path("message", "用户名或密码错误")

    def test_get_user_info(self, user_api):
        """测试获取用户信息"""
        # 先登录获取 token
        login_response = user_api.login(username="admin", password="123456")
        token = login_response.extract("data.token")

        # 获取用户信息
        user_info_response = user_api.get_user_info(token=token)
        user_info_response.assert_status_code(200)
        user_info_response.assert_json_path("code", 0)

        # 验证用户信息
        username = user_info_response.extract("data.username")
        assert username == "admin"

3. 完整的项目结构

3.1 项目目录结构

一个完整的接口自动化测试项目应该有以下目录结构:

project/
├── config/                 # 配置目录
│   ├── __init__.py
│   ├── config.py          # 配置管理类
│   ├── test_config.yaml   # 测试环境配置
│   ├── dev_config.yaml    # 开发环境配置
│   └── prod_config.yaml   # 生产环境配置
│
├── clients/               # HTTP 客户端目录
│   ├── __init__.py
│   └── http_client.py     # HTTP 客户端封装
│
├── api/                   # 业务接口目录
│   ├── __init__.py
│   ├── user_api.py        # 用户相关接口
│   ├── order_api.py       # 订单相关接口
│   └── product_api.py     # 商品相关接口
│
├── utils/                 # 工具类目录
│   ├── __init__.py
│   ├── assertion.py       # 断言工具
│   ├── logger.py          # 日志工具
│   ├── response_handler.py # 响应处理器
│   └── data_helper.py     # 数据辅助工具
│
├── tests/                 # 测试用例目录
│   ├── __init__.py
│   ├── conftest.py        # pytest 配置文件
│   ├── test_user_api.py   # 用户接口测试
│   ├── test_order_api.py  # 订单接口测试
│   └── test_product_api.py # 商品接口测试
│
├── data/                  # 测试数据目录
│   ├── test_data.yaml     # 测试数据
│   └── test_cases.yaml    # 测试用例数据
│
├── logs/                  # 日志目录(自动生成)
│   └── test.log
│
├── reports/               # 测试报告目录(自动生成)
│   └── report.html
│
├── requirements.txt       # 依赖文件
└── README.md             # 项目说明

3.2 各文件详细说明

3.2.1 requirements.txt

# 测试框架
pytest==7.4.3
pytest-html==4.1.1
pytest-xdist==3.5.0
pytest-rerunfailures==12.0
pytest-timeout==2.2.0

# HTTP 请求
requests==2.31.0

# 数据处理
pyyaml==6.0.1
jsonpath==0.82

# 日志
loguru==0.7.2

# 其他工具
python-dotenv==1.0.0

3.2.2 conftest.py

# tests/conftest.py
import pytest
from config.config import Config
from clients.http_client import HttpClient
from api.user_api import UserAPI
from api.order_api import OrderAPI

@pytest.fixture(scope="session")
def config():
    """全局配置 fixture"""
    env = pytest.config.getoption("--env", default="test")
    return Config(env=env)

@pytest.fixture(scope="session")
def http_client(config):
    """全局 HTTP 客户端 fixture"""
    return HttpClient(
        base_url=config.base_url,
        timeout=config.timeout
    )

@pytest.fixture(scope="session")
def user_api(http_client):
    """用户 API fixture"""
    return UserAPI(http_client)

@pytest.fixture(scope="session")
def order_api(http_client):
    """订单 API fixture"""
    return OrderAPI(http_client)

def pytest_addoption(parser):
    """添加命令行参数"""
    parser.addoption(
        "--env",
        action="store",
        default="test",
        help="测试环境:test、dev、prod"
    )

4. 实战案例:完整的登录流程测试

4.1 需求分析

假设我们要测试一个用户登录系统,需要测试以下场景:

  1. 正常登录:用户名和密码正确
  2. 密码错误:用户名正确,密码错误
  3. 用户名不存在:用户名不存在
  4. 登录后获取用户信息:登录成功后,使用 token 获取用户信息
  5. 登录后修改用户信息:登录成功后,修改用户信息

4.2 实现步骤

步骤 1:创建配置文件

# config/test_config.yaml
api:
  base_url: https://api.test.example.com
  timeout: 30

database:
  host: localhost
  port: 3306
  username: test_user
  password: test_pass
  database: test_db

logging:
  level: DEBUG
  file: logs/test.log

test_data:
  valid_user:
    username: admin
    password: 123456
  invalid_user:
    username: wrong_user
    password: wrong_pass

步骤 2:实现配置管理类

# config/config.py
import yaml
from pathlib import Path
from typing import Dict, Any

class Config:
    """配置管理类"""

    def __init__(self, env: str = "test"):
        """
        初始化配置
        :param env: 环境名称
        """
        self.env = env
        self.base_dir = Path(__file__).parent.parent
        self.config_file = self.base_dir / "config" / f"{env}_config.yaml"
        self._config = self._load_config()

    def _load_config(self) -> Dict[str, Any]:
        """加载配置文件"""
        with open(self.config_file, "r", encoding="utf-8") as f:
            return yaml.safe_load(f)

    @property
    def base_url(self) -> str:
        """获取基础 URL"""
        return self._config["api"]["base_url"]

    @property
    def timeout(self) -> int:
        """获取超时时间"""
        return self._config["api"]["timeout"]

    @property
    def test_data(self) -> Dict[str, Any]:
        """获取测试数据"""
        return self._config.get("test_data", {})

步骤 3:实现 HTTP 客户端

# clients/http_client.py
import requests
import json
from typing import Dict, Optional
from utils.logger import Logger

class HttpClient:
    """HTTP 客户端"""

    def __init__(self, base_url: str, timeout: int = 30):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.session = requests.Session()
        self.logger = Logger("http_client")

        # 设置默认请求头
        self.session.headers.update({
            "Content-Type": "application/json"
        })

    def _build_url(self, endpoint: str) -> str:
        """构建完整 URL"""
        endpoint = endpoint.lstrip("/")
        return f"{self.base_url}/{endpoint}"

    def _log_request(self, method: str, url: str, **kwargs):
        """记录请求日志"""
        self.logger.info(f"[请求] {method} {url}")
        if "json_data" in kwargs:
            self.logger.debug(f"[请求体] {json.dumps(kwargs['json_data'], ensure_ascii=False, indent=2)}")

    def _log_response(self, response: requests.Response):
        """记录响应日志"""
        self.logger.info(f"[响应] 状态码: {response.status_code}")
        try:
            self.logger.debug(f"[响应体] {json.dumps(response.json(), ensure_ascii=False, indent=2)}")
        except:
            self.logger.debug(f"[响应体] {response.text[:500]}")

    def request(
        self,
        method: str,
        endpoint: str,
        json_data: Optional[Dict] = None,
        headers: Optional[Dict] = None,
        **kwargs
    ) -> requests.Response:
        """发送 HTTP 请求"""
        url = self._build_url(endpoint)

        request_headers = self.session.headers.copy()
        if headers:
            request_headers.update(headers)

        self._log_request(method, url, json_data=json_data)

        try:
            response = self.session.request(
                method=method,
                url=url,
                json=json_data,
                headers=request_headers,
                timeout=self.timeout,
                **kwargs
            )
            self._log_response(response)
            return response
        except Exception as e:
            self.logger.error(f"请求异常:{method} {url},错误:{str(e)}")
            raise

    def post(self, endpoint: str, json_data: Optional[Dict] = None, **kwargs) -> requests.Response:
        """POST 请求"""
        return self.request("POST", endpoint, json_data=json_data, **kwargs)

    def get(self, endpoint: str, **kwargs) -> requests.Response:
        """GET 请求"""
        return self.request("GET", endpoint, **kwargs)

    def put(self, endpoint: str, json_data: Optional[Dict] = None, **kwargs) -> requests.Response:
        """PUT 请求"""
        return self.request("PUT", endpoint, json_data=json_data, **kwargs)

步骤 4:实现响应处理器

# utils/response_handler.py
import requests
import json
import jsonpath
from typing import Any

class ResponseHandler:
    """响应处理器"""

    def __init__(self, response: requests.Response):
        self.response = response
        self._json_data = None

    @property
    def status_code(self) -> int:
        return self.response.status_code

    @property
    def json(self) -> dict:
        if self._json_data is None:
            try:
                self._json_data = self.response.json()
            except json.JSONDecodeError:
                raise ValueError(f"响应不是有效的 JSON:{self.response.text}")
        return self._json_data

    def assert_status_code(self, expected_code: int):
        """断言状态码"""
        assert self.status_code == expected_code, 
            f"状态码断言失败:期望 {expected_code},实际 {self.status_code}"
        return self

    def assert_json_path(self, json_path: str, expected_value: Any):
        """断言 JSON 路径"""
        actual_value = self.extract(json_path)
        assert actual_value == expected_value, 
            f"JSON 路径断言失败:{json_path},期望 {expected_value},实际 {actual_value}"
        return self

    def extract(self, json_path: str) -> Any:
        """提取 JSON 路径的值"""
        data = self.json
        result = jsonpath.jsonpath(data, json_path)
        if not result:
            raise ValueError(f"JSON 路径不存在:{json_path}")
        return result[0]

步骤 5:实现用户 API

# api/user_api.py
from clients.http_client import HttpClient
from utils.response_handler import ResponseHandler

class UserAPI:
    """用户相关接口"""

    def __init__(self, http_client: HttpClient):
        self.client = http_client

    def login(self, username: str, password: str) -> ResponseHandler:
        """用户登录"""
        endpoint = "/api/user/login"
        data = {
            "username": username,
            "password": password
        }
        response = self.client.post(endpoint, json_data=data)
        return ResponseHandler(response)

    def get_user_info(self, token: str) -> ResponseHandler:
        """获取用户信息"""
        endpoint = "/api/user/info"
        headers = {"Authorization": f"Bearer {token}"}
        response = self.client.get(endpoint, headers=headers)
        return ResponseHandler(response)

    def update_user_info(self, token: str, **kwargs) -> ResponseHandler:
        """更新用户信息"""
        endpoint = "/api/user/info"
        headers = {"Authorization": f"Bearer {token}"}
        response = self.client.put(endpoint, json_data=kwargs, headers=headers)
        return ResponseHandler(response)

步骤 6:编写测试用例

# tests/test_user_login.py
import pytest
from config.config import Config
from clients.http_client import HttpClient
from api.user_api import UserAPI

@pytest.fixture(scope="module")
def config():
    """配置 fixture"""
    return Config(env="test")

@pytest.fixture(scope="module")
def http_client(config):
    """HTTP 客户端 fixture"""
    return HttpClient(
        base_url=config.base_url,
        timeout=config.timeout
    )

@pytest.fixture(scope="module")
def user_api(http_client):
    """用户 API fixture"""
    return UserAPI(http_client)

@pytest.fixture(scope="module")
def test_data(config):
    """测试数据 fixture"""
    return config.test_data

class TestUserLogin:
    """用户登录测试类"""

    def test_login_success(self, user_api, test_data):
        """测试登录成功"""
        # 获取测试数据
        valid_user = test_data["valid_user"]

        # 发送登录请求
        response = user_api.login(
            username=valid_user["username"],
            password=valid_user["password"]
        )

        # 断言响应
        response.assert_status_code(200)
        response.assert_json_path("code", 0)
        response.assert_json_path("message", "登录成功")

        # 提取 token
        token = response.extract("data.token")
        assert token is not None
        assert len(token) > 0

    def test_login_password_error(self, user_api, test_data):
        """测试密码错误"""
        valid_user = test_data["valid_user"]

        response = user_api.login(
            username=valid_user["username"],
            password="wrong_password"
        )

        response.assert_status_code(200)
        response.assert_json_path("code", 1001)
        response.assert_json_path("message", "密码错误")

    def test_login_username_not_exist(self, user_api):
        """测试用户名不存在"""
        response = user_api.login(
            username="not_exist_user",
            password="123456"
        )

        response.assert_status_code(200)
        response.assert_json_path("code", 1002)
        response.assert_json_path("message", "用户名不存在")

    def test_login_and_get_user_info(self, user_api, test_data):
        """测试登录后获取用户信息"""
        # 先登录
        valid_user = test_data["valid_user"]
        login_response = user_api.login(
            username=valid_user["username"],
            password=valid_user["password"]
        )
        token = login_response.extract("data.token")

        # 获取用户信息
        user_info_response = user_api.get_user_info(token=token)
        user_info_response.assert_status_code(200)
        user_info_response.assert_json_path("code", 0)

        # 验证用户信息
        username = user_info_response.extract("data.username")
        assert username == valid_user["username"]

    def test_login_and_update_user_info(self, user_api, test_data):
        """测试登录后更新用户信息"""
        # 先登录
        valid_user = test_data["valid_user"]
        login_response = user_api.login(
            username=valid_user["username"],
            password=valid_user["password"]
        )
        token = login_response.extract("data.token")

        # 更新用户信息
        update_data = {
            "nickname": "新昵称",
            "email": "newemail@example.com"
        }
        update_response = user_api.update_user_info(token=token, **update_data)
        update_response.assert_status_code(200)
        update_response.assert_json_path("code", 0)
        update_response.assert_json_path("message", "更新成功")

        # 验证更新后的信息
        user_info_response = user_api.get_user_info(token=token)
        assert user_info_response.extract("data.nickname") == update_data["nickname"]
        assert user_info_response.extract("data.email") == update_data["email"]

5. 高级封装技巧

5.1 会话管理(Session Management)

在接口测试中,很多接口需要先登录获取 token,然后在后续请求中使用。我们可以封装一个会话管理器来统一管理。

# utils/session_manager.py
from typing import Optional
from clients.http_client import HttpClient
from api.user_api import UserAPI

class SessionManager:
    """会话管理器"""

    def __init__(self, http_client: HttpClient, user_api: UserAPI):
        """
        初始化会话管理器
        :param http_client: HTTP 客户端
        :param user_api: 用户 API
        """
        self.http_client = http_client
        self.user_api = user_api
        self._token: Optional[str] = None
        self._user_info: Optional[dict] = None

    def login(self, username: str, password: str):
        """
        登录并保存 token
        :param username: 用户名
        :param password: 密码
        """
        response = self.user_api.login(username=username, password=password)
        response.assert_status_code(200)
        response.assert_json_path("code", 0)

        self._token = response.extract("data.token")
        # 自动设置 Authorization 头
        self.http_client.set_header("Authorization", f"Bearer {self._token}")

        # 获取并保存用户信息
        user_info_response = self.user_api.get_user_info(token=self._token)
        self._user_info = user_info_response.extract("data")

        return response

    @property
    def token(self) -> Optional[str]:
        """获取当前 token"""
        return self._token

    @property
    def user_info(self) -> Optional[dict]:
        """获取当前用户信息"""
        return self._user_info

    def logout(self):
        """登出,清除 token"""
        if self._token:
            self.http_client.remove_header("Authorization")
            self._token = None
            self._user_info = None

    def is_logged_in(self) -> bool:
        """检查是否已登录"""
        return self._token is not None

使用示例:

# tests/test_with_session.py
import pytest
from utils.session_manager import SessionManager

@pytest.fixture(scope="function")
def session(user_api, http_client):
    """会话 fixture"""
    session = SessionManager(http_client, user_api)
    yield session
    session.logout()  # 测试结束后清理

def test_with_session(session):
    """使用会话管理器的测试"""
    # 登录
    session.login(username="admin", password="123456")

    # 后续请求自动携带 token
    # 不需要手动传递 token
    assert session.is_logged_in()
    assert session.token is not None
    assert session.user_info is not None

5.2 数据驱动测试封装

将测试数据与测试逻辑分离,使用 YAML 或 JSON 文件存储测试数据。

# utils/data_driver.py
import yaml
from pathlib import Path
from typing import List, Dict, Any

class DataDriver:
    """数据驱动工具"""

    @staticmethod
    def load_yaml(file_path: str) -> List[Dict[str, Any]]:
        """
        从 YAML 文件加载测试数据
        :param file_path: YAML 文件路径
        :return: 测试数据列表
        """
        file_path = Path(file_path)
        if not file_path.exists():
            raise FileNotFoundError(f"测试数据文件不存在:{file_path}")

        with open(file_path, "r", encoding="utf-8") as f:
            data = yaml.safe_load(f)

        # 如果数据是字典,转换为列表
        if isinstance(data, dict):
            return [data]
        return data if isinstance(data, list) else []

    @staticmethod
    def load_test_cases(file_path: str) -> List[Dict[str, Any]]:
        """
        加载测试用例数据
        :param file_path: 测试用例文件路径
        :return: 测试用例列表
        """
        data = DataDriver.load_yaml(file_path)
        return data

测试数据文件:

# data/test_login_cases.yaml
- name: 登录成功
  username: admin
  password: 123456
  expected:
    status_code: 200
    code: 0
    message: 登录成功

- name: 密码错误
  username: admin
  password: wrong_password
  expected:
    status_code: 200
    code: 1001
    message: 密码错误

- name: 用户名不存在
  username: not_exist_user
  password: 123456
  expected:
    status_code: 200
    code: 1002
    message: 用户名不存在

使用数据驱动的测试用例:

# tests/test_login_data_driven.py
import pytest
from utils.data_driver import DataDriver
from pathlib import Path

@pytest.fixture(scope="module")
def test_cases():
    """加载测试用例数据"""
    data_file = Path(__file__).parent.parent / "data" / "test_login_cases.yaml"
    return DataDriver.load_test_cases(str(data_file))

@pytest.mark.parametrize("case", [
    pytest.param(case, id=case["name"])
    for case in DataDriver.load_test_cases(
        str(Path(__file__).parent.parent / "data" / "test_login_cases.yaml")
    )
])
def test_login_data_driven(user_api, case):
    """数据驱动的登录测试"""
    # 发送请求
    response = user_api.login(
        username=case["username"],
        password=case["password"]
    )

    # 断言
    expected = case["expected"]
    response.assert_status_code(expected["status_code"])
    response.assert_json_path("code", expected["code"])
    response.assert_json_path("message", expected["message"])

5.3 重试机制封装

对于不稳定的接口,可以封装重试机制。

# utils/retry.py
import time
from functools import wraps
from typing import Callable, Any
from utils.logger import Logger

class Retry:
    """重试装饰器"""

    def __init__(self, max_attempts: int = 3, delay: float = 1.0, backoff: float = 2.0):
        """
        初始化重试配置
        :param max_attempts: 最大重试次数
        :param delay: 初始延迟时间(秒)
        :param backoff: 延迟时间倍数
        """
        self.max_attempts = max_attempts
        self.delay = delay
        self.backoff = backoff
        self.logger = Logger("retry")

    def __call__(self, func: Callable) -> Callable:
        """装饰器实现"""
        @wraps(func)
        def wrapper(*args, **kwargs) -> Any:
            attempt = 0
            current_delay = self.delay

            while attempt < self.max_attempts:
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    attempt += 1
                    if attempt >= self.max_attempts:
                        self.logger.error(f"重试 {self.max_attempts} 次后仍然失败:{str(e)}")
                        raise

                    self.logger.warning(
                        f"第 {attempt} 次尝试失败:{str(e)},"
                        f"{current_delay} 秒后重试..."
                    )
                    time.sleep(current_delay)
                    current_delay *= self.backoff

            return None

        return wrapper

# 使用示例
class UserAPI:
    @Retry(max_attempts=3, delay=1.0)
    def login(self, username: str, password: str) -> ResponseHandler:
        """登录接口(带重试)"""
        # ... 实现代码
        pass

5.4 接口依赖管理

在测试中,很多接口之间有依赖关系,我们可以封装一个依赖管理器。

# utils/dependency_manager.py
from typing import Dict, Any, Optional
from utils.logger import Logger

class DependencyManager:
    """依赖管理器"""

    def __init__(self):
        """初始化依赖管理器"""
        self._dependencies: Dict[str, Any] = {}
        self.logger = Logger("dependency")

    def set(self, key: str, value: Any):
        """
        设置依赖值
        :param key: 依赖键
        :param value: 依赖值
        """
        self._dependencies[key] = value
        self.logger.debug(f"设置依赖:{key} = {value}")

    def get(self, key: str, default: Any = None) -> Any:
        """
        获取依赖值
        :param key: 依赖键
        :param default: 默认值
        :return: 依赖值
        """
        value = self._dependencies.get(key, default)
        if value is None and default is None:
            self.logger.warning(f"依赖不存在:{key}")
        return value

    def has(self, key: str) -> bool:
        """
        检查依赖是否存在
        :param key: 依赖键
        :return: 是否存在
        """
        return key in self._dependencies

    def clear(self):
        """清除所有依赖"""
        self._dependencies.clear()
        self.logger.debug("清除所有依赖")

# 使用示例
@pytest.fixture(scope="function")
def dependency_manager():
    """依赖管理器 fixture"""
    manager = DependencyManager()
    yield manager
    manager.clear()

def test_with_dependency(user_api, dependency_manager):
    """使用依赖管理器的测试"""
    # 登录并保存 token
    response = user_api.login(username="admin", password="123456")
    token = response.extract("data.token")
    dependency_manager.set("token", token)

    # 后续测试可以使用保存的 token
    saved_token = dependency_manager.get("token")
    user_info = user_api.get_user_info(token=saved_token)
    # ...

5.5 接口 Mock 封装

在测试中,有时候需要 Mock 某些接口的响应,我们可以封装一个 Mock 工具。

# utils/mock_helper.py
from unittest.mock import Mock, patch
from typing import Dict, Any, Optional
import json

class MockHelper:
    """Mock 辅助工具"""

    @staticmethod
    def mock_response(
        status_code: int = 200,
        json_data: Optional[Dict[str, Any]] = None,
        text: Optional[str] = None,
        headers: Optional[Dict[str, str]] = None
    ) -> Mock:
        """
        创建 Mock 响应对象
        :param status_code: 状态码
        :param json_data: JSON 数据
        :param text: 文本数据
        :param headers: 响应头
        :return: Mock 响应对象
        """
        mock_response = Mock()
        mock_response.status_code = status_code
        mock_response.headers = headers or {}

        if json_data:
            mock_response.json.return_value = json_data
            mock_response.text = json.dumps(json_data, ensure_ascii=False)
        elif text:
            mock_response.text = text
        else:
            mock_response.text = ""
            mock_response.json.return_value = {}

        return mock_response

    @staticmethod
    def patch_request(return_value: Mock):
        """
        创建请求 Mock 装饰器
        :param return_value: Mock 响应对象
        :return: 装饰器
        """
        def decorator(func):
            def wrapper(*args, **kwargs):
                with patch('requests.Session.request', return_value=return_value):
                    return func(*args, **kwargs)
            return wrapper
        return decorator

# 使用示例
def test_with_mock(user_api):
    """使用 Mock 的测试"""
    # 创建 Mock 响应
    mock_response = MockHelper.mock_response(
        status_code=200,
        json_data={
            "code": 0,
            "message": "登录成功",
            "data": {
                "token": "mock_token_12345"
            }
        }
    )

    # Mock 请求
    with patch('requests.Session.request', return_value=mock_response):
        response = user_api.login(username="admin", password="123456")
        assert response.extract("data.token") == "mock_token_12345"

6. 最佳实践和注意事项

6.1 命名规范

6.1.1 类命名

  • 配置类:使用 Config 后缀,如 ConfigDatabaseConfig
  • 客户端类:使用 Client 后缀,如 HttpClientDatabaseClient
  • API 类:使用 API 后缀,如 UserAPIOrderAPI
  • 工具类:使用描述性名称,如 LoggerAssertionDataDriver

6.1.2 方法命名

  • 请求方法:使用动词,如 getpostputdelete
  • 业务方法:使用业务动词,如 loginregisterget_user_info
  • 断言方法:使用 assert_ 前缀,如 assert_status_codeassert_json_path
  • 提取方法:使用 extractget 等动词,如 extractget_token

6.1.3 变量命名

  • 配置变量:使用小写字母和下划线,如 base_urltimeout
  • 响应变量:使用描述性名称,如 login_responseuser_info_response
  • 测试数据:使用 test_datacase

6.2 错误处理

6.2.1 统一异常处理

# utils/exceptions.py
class APIException(Exception):
    """API 异常基类"""
    pass

class HTTPException(APIException):
    """HTTP 异常"""
    def __init__(self, status_code: int, message: str):
        self.status_code = status_code
        self.message = message
        super().__init__(f"HTTP {status_code}: {message}")

class ValidationException(APIException):
    """验证异常"""
    pass

# 在 HTTP 客户端中使用
class HttpClient:
    def request(self, method: str, endpoint: str, **kwargs):
        try:
            response = self.session.request(...)
            response.raise_for_status()  # 自动抛出 HTTP 错误
            return response
        except requests.exceptions.HTTPError as e:
            raise HTTPException(e.response.status_code, str(e))
        except requests.exceptions.RequestException as e:
            raise APIException(f"请求失败:{str(e)}")

6.2.2 优雅的错误处理

# 在测试用例中使用 try-except
def test_login_with_error_handling(user_api):
    """带错误处理的登录测试"""
    try:
        response = user_api.login(username="admin", password="123456")
        response.assert_status_code(200)
    except HTTPException as e:
        pytest.fail(f"HTTP 错误:{e.status_code} - {e.message}")
    except ValidationException as e:
        pytest.fail(f"验证错误:{str(e)}")
    except Exception as e:
        pytest.fail(f"未知错误:{str(e)}")

6.3 日志记录最佳实践

6.3.1 日志级别使用

# DEBUG:详细的调试信息
logger.debug(f"请求参数:{params}")

# INFO:重要的业务流程信息
logger.info(f"开始执行登录测试")

# WARNING:警告信息,不影响测试
logger.warning(f"响应时间较长:{response_time}ms")

# ERROR:错误信息
logger.error(f"请求失败:{str(e)}")

6.3.2 日志格式

# 统一的日志格式
formatter = logging.Formatter(
    '%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s'
)

# 输出示例:
# 2024-01-27 10:30:45 - http_client - INFO - [http_client.py:45] - [请求] POST /api/user/login

6.4 测试数据管理

6.4.1 测试数据分离

# ✅ 好的做法:测试数据在配置文件中
# config/test_config.yaml
test_data:
  valid_user:
    username: admin
    password: 123456

# ❌ 不好的做法:测试数据硬编码在代码中
def test_login():
    response = user_api.login(username="admin", password="123456")  # 硬编码

6.4.2 测试数据清理

@pytest.fixture(scope="function", autouse=True)
def cleanup_test_data():
    """自动清理测试数据"""
    yield
    # 测试结束后清理
    # 例如:删除测试创建的用户、订单等
    pass

6.5 性能优化

6.5.1 使用 Session

# ✅ 好的做法:使用 Session 复用连接
class HttpClient:
    def __init__(self):
        self.session = requests.Session()  # 复用连接

# ❌ 不好的做法:每次请求都创建新连接
def request(self):
    response = requests.get(url)  # 每次都创建新连接

6.5.2 合理设置超时

# 根据接口特点设置合理的超时时间
class HttpClient:
    def __init__(self, timeout=30):  # 默认 30 秒
        self.timeout = timeout

    def quick_request(self, endpoint):
        # 快速接口使用短超时
        return self.request(endpoint, timeout=5)

    def slow_request(self, endpoint):
        # 慢速接口使用长超时
        return self.request(endpoint, timeout=60)

6.6 可维护性

6.6.1 代码注释

class UserAPI:
    def login(self, username: str, password: str) -> ResponseHandler:
        """
        用户登录接口

        :param username: 用户名
        :param password: 密码
        :return: 响应处理器对象
        :raises HTTPException: 当 HTTP 请求失败时
        :raises ValidationException: 当响应验证失败时

        示例:
            >>> api = UserAPI(http_client)
            >>> response = api.login("admin", "123456")
            >>> response.assert_status_code(200)
        """
        # 实现代码
        pass

6.6.2 类型提示

from typing import Dict, Optional, List

def get_user_info(
    self,
    token: str,
    fields: Optional[List[str]] = None
) -> ResponseHandler:
    """
    获取用户信息

    :param token: 认证 token
    :param fields: 要获取的字段列表,None 表示获取所有字段
    :return: 响应处理器
    """
    pass

7. 完整实战项目示例

7.1 项目结构

api_test_framework/
├── config/
│   ├── __init__.py
│   ├── config.py
│   └── test_config.yaml
│
├── clients/
│   ├── __init__.py
│   └── http_client.py
│
├── api/
│   ├── __init__.py
│   ├── base_api.py
│   ├── user_api.py
│   └── order_api.py
│
├── utils/
│   ├── __init__.py
│   ├── logger.py
│   ├── assertion.py
│   ├── response_handler.py
│   ├── session_manager.py
│   └── data_driver.py
│
├── tests/
│   ├── __init__.py
│   ├── conftest.py
│   ├── test_user_api.py
│   └── test_order_api.py
│
├── data/
│   ├── test_users.yaml
│   └── test_orders.yaml
│
├── logs/
│   └── .gitkeep
│
├── requirements.txt
└── README.md

7.2 核心代码实现

7.2.1 基础 API 类

# api/base_api.py
from clients.http_client import HttpClient
from utils.response_handler import ResponseHandler

class BaseAPI:
    """API 基类"""

    def __init__(self, http_client: HttpClient):
        """
        初始化 API
        :param http_client: HTTP 客户端实例
        """
        self.client = http_client

    def _get(self, endpoint: str, **kwargs) -> ResponseHandler:
        """GET 请求"""
        response = self.client.get(endpoint, **kwargs)
        return ResponseHandler(response)

    def _post(self, endpoint: str, json_data: dict = None, **kwargs) -> ResponseHandler:
        """POST 请求"""
        response = self.client.post(endpoint, json_data=json_data, **kwargs)
        return ResponseHandler(response)

    def _put(self, endpoint: str, json_data: dict = None, **kwargs) -> ResponseHandler:
        """PUT 请求"""
        response = self.client.put(endpoint, json_data=json_data, **kwargs)
        return ResponseHandler(response)

    def _delete(self, endpoint: str, **kwargs) -> ResponseHandler:
        """DELETE 请求"""
        response = self.client.delete(endpoint, **kwargs)
        return ResponseHandler(response)

7.2.2 用户 API 实现

# api/user_api.py
from api.base_api import BaseAPI
from utils.response_handler import ResponseHandler

class UserAPI(BaseAPI):
    """用户相关接口"""

    def login(self, username: str, password: str) -> ResponseHandler:
        """用户登录"""
        endpoint = "/api/user/login"
        data = {
            "username": username,
            "password": password
        }
        return self._post(endpoint, json_data=data)

    def register(self, username: str, password: str, email: str) -> ResponseHandler:
        """用户注册"""
        endpoint = "/api/user/register"
        data = {
            "username": username,
            "password": password,
            "email": email
        }
        return self._post(endpoint, json_data=data)

    def get_user_info(self, token: str) -> ResponseHandler:
        """获取用户信息"""
        endpoint = "/api/user/info"
        headers = {"Authorization": f"Bearer {token}"}
        return self._get(endpoint, headers=headers)

    def update_user_info(self, token: str, **kwargs) -> ResponseHandler:
        """更新用户信息"""
        endpoint = "/api/user/info"
        headers = {"Authorization": f"Bearer {token}"}
        return self._put(endpoint, json_data=kwargs, headers=headers)

    def logout(self, token: str) -> ResponseHandler:
        """用户登出"""
        endpoint = "/api/user/logout"
        headers = {"Authorization": f"Bearer {token}"}
        return self._post(endpoint, headers=headers)

7.2.3 订单 API 实现

# api/order_api.py
from api.base_api import BaseAPI
from utils.response_handler import ResponseHandler

class OrderAPI(BaseAPI):
    """订单相关接口"""

    def create_order(self, token: str, product_id: int, quantity: int) -> ResponseHandler:
        """创建订单"""
        endpoint = "/api/order/create"
        headers = {"Authorization": f"Bearer {token}"}
        data = {
            "product_id": product_id,
            "quantity": quantity
        }
        return self._post(endpoint, json_data=data, headers=headers)

    def get_order_list(self, token: str, page: int = 1, page_size: int = 10) -> ResponseHandler:
        """获取订单列表"""
        endpoint = "/api/order/list"
        headers = {"Authorization": f"Bearer {token}"}
        params = {
            "page": page,
            "page_size": page_size
        }
        return self._get(endpoint, params=params, headers=headers)

    def get_order_detail(self, token: str, order_id: int) -> ResponseHandler:
        """获取订单详情"""
        endpoint = f"/api/order/{order_id}"
        headers = {"Authorization": f"Bearer {token}"}
        return self._get(endpoint, headers=headers)

    def cancel_order(self, token: str, order_id: int) -> ResponseHandler:
        """取消订单"""
        endpoint = f"/api/order/{order_id}/cancel"
        headers = {"Authorization": f"Bearer {token}"}
        return self._post(endpoint, headers=headers)

7.2.4 conftest.py 配置

# tests/conftest.py
import pytest
from config.config import Config
from clients.http_client import HttpClient
from api.user_api import UserAPI
from api.order_api import OrderAPI

def pytest_addoption(parser):
    """添加命令行参数"""
    parser.addoption(
        "--env",
        action="store",
        default="test",
        help="测试环境:test、dev、prod"
    )

@pytest.fixture(scope="session")
def config(request):
    """全局配置 fixture"""
    env = request.config.getoption("--env")
    return Config(env=env)

@pytest.fixture(scope="session")
def http_client(config):
    """全局 HTTP 客户端 fixture"""
    return HttpClient(
        base_url=config.base_url,
        timeout=config.timeout
    )

@pytest.fixture(scope="session")
def user_api(http_client):
    """用户 API fixture"""
    return UserAPI(http_client)

@pytest.fixture(scope="session")
def order_api(http_client):
    """订单 API fixture"""
    return OrderAPI(http_client)

@pytest.fixture(scope="function")
def login_token(user_api, config):
    """登录并返回 token fixture"""
    test_data = config.test_data
    valid_user = test_data["valid_user"]
    response = user_api.login(
        username=valid_user["username"],
        password=valid_user["password"]
    )
    response.assert_status_code(200)
    token = response.extract("data.token")
    return token

7.2.5 测试用例示例

# tests/test_user_api.py
import pytest
from api.user_api import UserAPI

class TestUserAPI:
    """用户接口测试"""

    def test_login_success(self, user_api, config):
        """测试登录成功"""
        test_data = config.test_data
        valid_user = test_data["valid_user"]

        response = user_api.login(
            username=valid_user["username"],
            password=valid_user["password"]
        )

        response.assert_status_code(200)
        response.assert_json_path("code", 0)
        response.assert_json_path("message", "登录成功")

        token = response.extract("data.token")
        assert token is not None

    def test_get_user_info(self, user_api, login_token):
        """测试获取用户信息"""
        response = user_api.get_user_info(token=login_token)

        response.assert_status_code(200)
        response.assert_json_path("code", 0)

        username = response.extract("data.username")
        assert username is not None

# tests/test_order_api.py
import pytest
from api.order_api import OrderAPI

class TestOrderAPI:
    """订单接口测试"""

    def test_create_order(self, order_api, login_token):
        """测试创建订单"""
        response = order_api.create_order(
            token=login_token,
            product_id=1,
            quantity=2
        )

        response.assert_status_code(200)
        response.assert_json_path("code", 0)

        order_id = response.extract("data.order_id")
        assert order_id is not None

    def test_get_order_list(self, order_api, login_token):
        """测试获取订单列表"""
        response = order_api.get_order_list(
            token=login_token,
            page=1,
            page_size=10
        )

        response.assert_status_code(200)
        response.assert_json_path("code", 0)

        orders = response.extract("data.orders")
        assert isinstance(orders, list)

7.3 运行测试

7.3.1 基本运行

# 运行所有测试
pytest

# 运行指定测试文件
pytest tests/test_user_api.py

# 运行指定测试类
pytest tests/test_user_api.py::TestUserAPI

# 运行指定测试方法
pytest tests/test_user_api.py::TestUserAPI::test_login_success

7.3.2 带参数运行

# 指定测试环境
pytest --env=test

# 生成 HTML 报告
pytest --html=reports/report.html

# 并行运行测试
pytest -n auto

# 显示详细输出
pytest -v -s

8. 常见问题和解决方案

8.1 问题:如何处理接口依赖?

问题描述:测试订单接口需要先登录获取 token,如何处理这种依赖关系?

解决方案 1:使用 fixture

@pytest.fixture(scope="function")
def login_token(user_api, config):
    """登录并返回 token"""
    test_data = config.test_data
    response = user_api.login(
        username=test_data["valid_user"]["username"],
        password=test_data["valid_user"]["password"]
    )
    return response.extract("data.token")

def test_order(order_api, login_token):
    """使用 login_token fixture"""
    response = order_api.create_order(token=login_token, ...)

解决方案 2:使用会话管理器

@pytest.fixture(scope="function")
def session(user_api, http_client):
    """会话管理器"""
    session = SessionManager(http_client, user_api)
    session.login(username="admin", password="123456")
    yield session
    session.logout()

def test_order(order_api, session):
    """使用会话管理器"""
    # session.token 自动可用
    response = order_api.create_order(token=session.token, ...)

8.2 问题:如何管理不同环境的配置?

问题描述:测试环境、开发环境、生产环境的配置不同,如何管理?

解决方案:使用配置文件 + 环境变量

# config/config.py
import os
import yaml

class Config:
    def __init__(self, env=None):
        # 优先使用环境变量,其次使用参数
        self.env = env or os.getenv("TEST_ENV", "test")
        self._load_config()

    def _load_config(self):
        config_file = f"config/{self.env}_config.yaml"
        with open(config_file, "r") as f:
            self.config = yaml.safe_load(f)

        # 环境变量可以覆盖配置文件
        self.config["api"]["base_url"] = os.getenv(
            "API_BASE_URL",
            self.config["api"]["base_url"]
        )

使用方式:

# 方式 1:命令行参数
pytest --env=test

# 方式 2:环境变量
export TEST_ENV=test
pytest

# 方式 3:环境变量覆盖配置
export API_BASE_URL=https://api.custom.com
pytest --env=test

8.3 问题:如何处理接口返回的数据格式不一致?

问题描述:有些接口返回 {"code": 0, "data": {...}},有些返回 {"status": "success", "result": {...}},如何处理?

解决方案:使用适配器模式

# utils/response_adapter.py
class ResponseAdapter:
    """响应适配器"""

    @staticmethod
    def normalize(response_data: dict) -> dict:
        """
        标准化响应数据格式
        :param response_data: 原始响应数据
        :return: 标准化后的数据
        """
        # 统一转换为标准格式
        normalized = {
            "code": response_data.get("code") or response_data.get("status_code", 0),
            "message": response_data.get("message") or response_data.get("msg", ""),
            "data": response_data.get("data") or response_data.get("result", {})
        }
        return normalized

# 在 ResponseHandler 中使用
class ResponseHandler:
    def __init__(self, response):
        self.response = response
        self._normalized_data = None

    @property
    def normalized_json(self):
        """获取标准化后的 JSON"""
        if self._normalized_data is None:
            raw_data = self.json
            self._normalized_data = ResponseAdapter.normalize(raw_data)
        return self._normalized_data

    def assert_code(self, expected_code):
        """断言 code(适配不同格式)"""
        normalized = self.normalized_json
        assert normalized["code"] == expected_code
        return self

8.4 问题:如何实现接口的批量测试?

问题描述:需要对多个接口进行批量测试,如何实现?

解决方案:使用数据驱动 + 参数化

# data/batch_test_cases.yaml
- name: 用户登录
  api: user_api
  method: login
  params:
    username: admin
    password: 123456
  expected:
    status_code: 200
    code: 0

- name: 创建订单
  api: order_api
  method: create_order
  params:
    product_id: 1
    quantity: 2
  expected:
    status_code: 200
    code: 0

# tests/test_batch.py
import pytest
from utils.data_driver import DataDriver

@pytest.fixture
def api_instances(user_api, order_api):
    """API 实例字典"""
    return {
        "user_api": user_api,
        "order_api": order_api
    }

@pytest.mark.parametrize("case", DataDriver.load_test_cases("data/batch_test_cases.yaml"))
def test_batch_api(api_instances, case, login_token):
    """批量测试接口"""
    # 获取 API 实例
    api = api_instances[case["api"]]

    # 获取方法
    method = getattr(api, case["method"])

    # 准备参数
    params = case["params"].copy()
    if "token" in str(case.get("params", {})):
        params["token"] = login_token

    # 调用方法
    response = method(**params)

    # 断言
    expected = case["expected"]
    response.assert_status_code(expected["status_code"])
    response.assert_json_path("code", expected["code"])

8.5 问题:如何处理接口的异步调用?

问题描述:某些接口是异步的,需要等待一段时间才能获取结果,如何处理?

解决方案:使用轮询机制

# utils/polling.py
import time
from typing import Callable, Optional
from utils.logger import Logger

class Polling:
    """轮询工具"""

    def __init__(self, timeout: int = 30, interval: float = 1.0):
        """
        初始化轮询配置
        :param timeout: 超时时间(秒)
        :param interval: 轮询间隔(秒)
        """
        self.timeout = timeout
        self.interval = interval
        self.logger = Logger("polling")

    def wait_for(
        self,
        condition: Callable[[], bool],
        message: str = "等待条件满足"
    ) -> bool:
        """
        等待条件满足
        :param condition: 条件函数,返回 True 表示条件满足
        :param message: 等待消息
        :return: 是否满足条件
        """
        start_time = time.time()

        while time.time() - start_time < self.timeout:
            if condition():
                self.logger.info(f"{message} - 条件已满足")
                return True

            self.logger.debug(f"{message} - 继续等待...")
            time.sleep(self.interval)

        self.logger.error(f"{message} - 超时")
        return False

# 使用示例
def test_async_task(user_api, login_token):
    """测试异步任务"""
    # 提交异步任务
    response = user_api.submit_task(token=login_token, task_type="export")
    task_id = response.extract("data.task_id")

    # 轮询任务状态
    polling = Polling(timeout=60, interval=2)
    is_completed = polling.wait_for(
        condition=lambda: user_api.get_task_status(token=login_token, task_id=task_id)
                            .extract("data.status") == "completed",
        message=f"等待任务 {task_id} 完成"
    )

    assert is_completed, "任务未在指定时间内完成"

9. 总结

9.1 封装的核心价值

通过本文的学习,你应该理解接口自动化封装的核心价值:

  1. 提高代码复用性:一次封装,多处使用
  2. 降低维护成本:修改一处,全局生效
  3. 提高测试效率:简化测试用例编写
  4. 增强可读性:测试用例更清晰,专注于业务逻辑
  5. 便于团队协作:统一的封装规范,便于团队协作

9.2 封装的关键点

  1. 分层设计:配置层 → 工具层 → HTTP 层 → 业务层 → 测试层
  2. 单一职责:每个类和方法只负责一个功能
  3. DRY 原则:消除重复代码
  4. 配置分离:配置与代码分离
  5. 错误处理:统一的错误处理机制
  6. 日志记录:完善的日志记录

9.3 学习路径建议

对于新手,建议按以下顺序学习:

  1. 第一步:理解封装的概念和必要性
  2. 第二步:学习基础的 HTTP 客户端封装
  3. 第三步:学习响应处理和断言封装
  4. 第四步:学习业务接口封装
  5. 第五步:学习高级技巧(会话管理、数据驱动等)
  6. 第六步:实践完整项目

9.4 持续改进

封装不是一次性的工作,需要根据实际使用情况持续改进:

  1. 收集反馈:从使用者的反馈中发现问题
  2. 优化重构:定期优化和重构代码
  3. 文档更新:及时更新文档和注释
  4. 最佳实践:总结和分享最佳实践

9.5 扩展阅读


附录 A:完整代码清单

A.1 项目依赖文件

# requirements.txt
pytest==7.4.3
pytest-html==4.1.1
pytest-xdist==3.5.0
requests==2.31.0
pyyaml==6.0.1
jsonpath==0.82

A.2 配置文件示例

# config/test_config.yaml
api:
  base_url: https://api.test.example.com
  timeout: 30

test_data:
  valid_user:
    username: admin
    password: 123456
  invalid_user:
    username: wrong_user
    password: wrong_pass

A.3 README.md 示例

# 接口自动化测试框架

## 项目简介

这是一个基于 pytest 的接口自动化测试框架,提供了完整的封装和工具支持。

## 快速开始

### 安装依赖

```bash
pip install -r requirements.txt

运行测试

# 运行所有测试
pytest

# 运行指定测试
pytest tests/test_user_api.py

# 指定环境运行
pytest --env=test

项目结构

  • config/: 配置文件
  • clients/: HTTP 客户端
  • api/: 业务接口封装
  • utils/: 工具类
  • tests/: 测试用例
  • data/: 测试数据

发表评论