【Pytest框架学习】0-1搭建API测试框架-Version01跑通test case

23 阅读5分钟

本文同步平台:blog.csdn.net/qq_58221901…

 前言:


为什么选Pytest

自动化框架有以下几种:

1.pytest(需要安装, 高扩展性,简单)

2.unittest(不需要安装,标准库,需要写类,格式规范)

3.Robot Framework


1. 创造编程环境(AI编程):

下载Cursor,一个类似Vscode的代码编译器(自带AI),新账号有免费额度:cursor.com/cn/download

(或者选择下载trae,也有免费额度 TRAE - Collaborate with Intelligence

(或者在Vscode 安装 Condex插件)

1.安装

2.注册账号

3.登录

4.换成高亮;(个人习惯)

5.有编码模式/ai agent 模式,选择编码模式

6.新建项目


2. 创造python环境/cursor终端环境

(1)cursor终端环境:

安装python插件 :

打开python terminal :

cursor选择终端:

cursor选择环境:

(2)python环境

(创建虚拟环境隔离项目、避免不同项目使用不同版本的依赖导致的环境冲突的问题)

这里创建conda环境(可以直接在cmd中创建和激活,然后到cursor终端选择环境) :

创建环境:conda create -n cursor_pytest_autotest python==3.11

激活环境:conda activate cursor_pytest_autotest

(3)配置python环境,安装部分依赖

pip install pytest

pip install https

pip install requests

pip install python-dotenv

3. 手动创建目录,创建文件夹/文件的指令:

midir -p .../file_path                    // 创建文件夹 -p 父目录
touch .../file_path.txt                   // 创建文件
cat file_path1.txt file_path2.txt         // 查看文件1、 文件2
cat file_path1.txt > file_path2.txt       // 1内容输出到2
cat file_path1.txt >> file_path2.txt      // 1内容追加到2

4. pytest框架知识点:

pytest相关文件的命名规范:

文件名必须以test_开头或者_test结尾;

测试类必须以test开头,并且不能有__init__方法;

测试方法必须以test开头


pytest 的装饰器(decorator)

@pytest.fixture

fixture的scope级别:

#准备测试需要的东西
@pytest.fixture(scope="session") #fixture 的初始化/生命周期是 session 级别
def logged_in_client(api_client: ApiClient):
    ...
    
'''
把 logged_in_client() 定义成一个 fixture,并且这个 fixture 在整个 pytest 测试会话期间只创建/执行一次。
'''

@pytest.mark.parametrize

#让同一个测试用不同数据跑多次
@pytest.mark.parametrize(
    "username,password",
    [
        ("user1", "123456"),
        ("user2", "123456"),
        ("user3", "123456"),
    ]
)
def test_login(username, password):
    print(username, password)
    
'''
pytest 会自动把它变成 3 次测试:
test_login[user1-123456]
test_login[user2-123456]
test_login[user3-123456]

只写了一个函数,但是pytest执行了三次测试
'''

5. 核心代码示例


api_client.py


import httpx

class ApiClient:
    """
    API 客户端封装类。
    用于统一管理 API 请求,包括:
    - HTTP GET/POST 请求
    - 请求 Header
    - Bearer Token(如果需要token登录的话)
    - Cookie(如果是Cookie登录的话)
    - HTTP Client 的生命周期
    """
    def __init__(self, base_url: str, timeout: float = 30.0):
        self._client = httpx.Client(
            base_url=base_url,
            timeout=timeout,
        )

    def set_header(self, name: str, value: str) -> None:
        self._client.headers[name] = value

    def set_cookie(
        self,
        name: str,
        value: str,
        domain: str | None = None,
        path: str = "/",
    ) -> None:
        self._client.cookies.set(
            name,
            value,
            domain=domain,
            path=path,
        )

    def get(self, path: str, params: dict | None = None) -> httpx.Response:
        return self._client.get(path, params=params)

    def post(
        self,
        path: str,
        json: dict | None = None,
        data: dict | None = None,
    ) -> httpx.Response:
        return self._client.post(path, json=json, data=data)

    def close(self):
        self._client.close()

conftest.py

# base_url配置到环境变量里或从配置文件获取
@pytest.fixture(scope="session")
def api_client():
    base_url = os.environ.get("API_BASE_URL")
    if not base_url:
        raise RuntimeError("Missing env var: API_BASE_URL")
    client = ApiClient(base_url=base_url)
    yield client
    client.close()

# 直接用从网站上获取的cookie : f12 Application / Storage / cookie / url: ... ...
# 配置到环境变量里,从环境变量里面获取 
@pytest.fixture(scope="session")
def logged_in_client(api_client: ApiClient):
    login_username = os.environ.get("LOGINUSERNAME")
    token_pass = os.environ.get("TOKENPASS")
    jsessionid = os.environ.get("JSESSIONID")
    domain = os.environ.get("DOMAIN")
    if not all([
        login_username,
        token_pass,
        jsessionid,
        domain,
    ]):
        pytest.skip(
            "Cookie configuration not found in .env"
        )
    api_client.set_cookie(
        "loginUserName",
        login_username,
        domain=domain,
        path="/",
    )
    api_client.set_cookie(
        "token_pass",
        token_pass,
        domain=domain,
        path="/",
    )
    api_client.set_cookie(
        "JSESSIONID",
        jsessionid,
        domain=domain,
        path="/",
    )
    return api_client

assertions.py


from typing import Any

def key_exists_anywhere(obj: Any, target_key: str) -> bool:
    '''
    递归查找对象(dict/list 混合)中是否存在指定 key。
    '''
    if isinstance(obj, dict):
        if target_key in obj:
            return True
        return any(key_exists_anywhere(v, target_key) for v in obj.values())
    if isinstance(obj, list):
        return any(key_exists_anywhere(x, target_key) for x in obj)
    return False

def get_by_path(obj: Any, path: str) -> Any:
    """
    按点分路径取值,支持数组索引。例如:data.datas.0.title,缺失安全返回 `None`。
    """
    cur = obj
    for part in path.split("."):
        if cur is None:
            return None
        if isinstance(cur, dict):
            cur = cur.get(part)
        elif isinstance(cur, list) and part.isdigit():
            idx = int(part)
            cur = cur[idx] if 0 <= idx < len(cur) else None
        else:
            return None
    return cur

def assert_wan_response(
    resp,
    expected_status: int = 200,
    expected_error_code: int | None = 0,
    data_keys: list[str] | None = None,
    path_equals: dict[str, Any] | None = None,
) -> dict:
    """
    针对 wanandroid 统一响应结构做断言:
    { "data": ..., "errorCode": 0, "errorMsg": "" }
    1. HTTP 状态码 = 200
    2. body 必含 `errorCode`、`errorMsg`
    3. `errorCode` == 0(可设 `None` 关闭)
    4. `data_keys` 中每个 key 都能在响应任意层级找到(递归)
    5. `path_equals` 按点分路径做等值断言(支持数组索引,如 `data.datas.0.title`)
    """

    assert resp.status_code == expected_status, (
        f"HTTP status mismatch: expected {expected_status}, got {resp.status_code}. "
        f"body: {resp.text[:500]}"
    )
    body = resp.json()
    assert "errorCode" in body, f"Missing errorCode in response: {body}"
    assert "errorMsg" in body, f"Missing errorMsg in response: {body}"
    if expected_error_code is not None:
        assert body["errorCode"] == expected_error_code, (
            f"errorCode mismatch: expected {expected_error_code}, "
            f"got {body['errorCode']}. errorMsg: {body.get('errorMsg')}"
        )
    if data_keys:
        for k in data_keys:
            assert key_exists_anywhere(body, k), (
                f"Expected key '{k}' not found anywhere in response body"
            )
    if path_equals:
        for path, expected in path_equals.items():
            actual = get_by_path(body, path)
            assert actual == expected, (
                f"path '{path}' mismatch: expected {expected!r}, got {actual!r}"
            )
    return body

utils.py

如果是json文件数据驱动的话,以下代码用于加载案例

import json
from pathlib import Path
from typing import Any

def load_cases(json_filename: str) -> list[dict[str, Any]]:
    """
    从 tests/data/<json_filename> 读取用例列表
    """
    project_root = Path(__file__).resolve().parents[3] # 根据实际情况调整根目录获取方式
    path = project_root / "tests" / "data" / json_filename
    return json.loads(path.read_text(encoding="utf-8"))

test.py 示例

class TestAuth:
    def test_login_without_credentials_returns_error(self, api_client: ApiClient):
        resp = api_client.post(
            "/user/login",
            data={"username": "", "password": ""},
        )
        body = resp.json()
        assert resp.status_code == 200
        assert body["errorCode"] != 0
        assert "errorMsg" in body

运行代码指令

pytest