【接口自动化】12 - 接口自动化进阶

前言

到目前为止,项目有 91 条用例,覆盖了单接口测试、链路测试、数据一致性、Session 验证、分页、边界值。这些都是"在已知条件下验证正确性"。

本篇进入更实际的领域,解决日常测试工作中会遇到的四个问题:

  1. 鉴权方式对比:MallLite 用 Session+Cookie,但实际项目更多用 JWT Token,区别是什么?测试上有什么不同?
  2. 超时和重试:第 10 篇在 ApiClient 里实现了重试机制,但没有用例验证它。网络抖动时框架真的能自动重试吗?
  3. 并发场景:两个人同时加同一件商品到购物车,库存会不会算错?
  4. 数据清理策略:用例之间数据互相污染,怎么系统化地解决?

另外有两个不在 MallLite 上跑、但面试和工作中会遇到的主题:Mock 外部接口、接口签名加密,作为理论+独立示例讲解。


一、鉴权方式对比

1.1 三种主流鉴权方式

复制代码
方式 1:Session + Cookie(MallLite 使用)
  登录 → 服务端创建 session,把 session_id 写到 Cookie
  后续请求 → 浏览器自动带 Cookie → 服务端认出你是谁
  requests 中 → Session 对象自动管理

方式 2:JWT Token(最常见的 API 鉴权方式)
  登录 → 服务端返回一个 Token 字符串
  后续请求 → 客户端在 Header 里带 Authorization: Bearer <token>
  requests 中 → 每次手动设置 Header

方式 3:OAuth 2.0(第三方登录)
  用户通过微信/Google 授权 → 拿到 access_token
  access_token 会过期 → 用 refresh_token 刷新

1.2 测试上的区别

知识点:为什么鉴权方式影响测试代码?

不同鉴权方式决定了你的 ApiClient 怎么管理认证信息。用错了方式,所有需要登录的接口都会返回"未授权"。

维度 Session+Cookie JWT Token
登录后存什么 Cookie(自动存在 session 里) Token 字符串(需要手动存)
后续请求怎么带 Session 自动带 Cookie 每次手动设 Header
自动管理 requests.Session 自动处理 需要自己写 Token 刷新逻辑
多用户隔离 不同 Session 对象 不同 Token 字符串

1.3 在代码上的差异

python 复制代码
# ===== Session + Cookie 方式(MallLite 使用)=====
# 登录后 session 自动保存 Cookie,后续请求自动携带
session = requests.Session()
session.post(url, json={"username": "admin", "password": "123"})
# 之后不用管,session 自动带 Cookie
session.get(url)

# ===== JWT Token 方式(假设 MallLite 用 Token)=====
# 登录后需要手动保存 Token,每次请求手动带 Header
session = requests.Session()
resp = session.post(url, json={"username": "admin", "password": "123"})
token = resp.json()["data"]["token"]
# 手动设置 Authorization header
session.headers.update({"Authorization": f"Bearer {token}"})
# 之后每次请求都会带这个 Header
session.get(url)

1.4 如果 MallLite 用 JWT Token,ApiClient 怎么改

知识点:为什么需要了解 Token 方式?

实际面试和工作中,大部分项目用 JWT Token。了解两种方式的代码差异,能帮你在不同项目间快速切换。

python 复制代码
"""
如果 MallLite 用 JWT Token,ApiClient 需要加一个 login 方法
这个代码不在 MallLite 上运行,只做教学演示
"""

import requests

class TokenBasedApiClient:
    """JWT Token 方式的 ApiClient(教学演示)"""

    def __init__(self, base_url):
        self.base_url = base_url
        self.session = requests.Session()
        self.token = None

    def login(self, username, password):
        """
        登录并保存 Token

        跟 Session 方式的区别:
          Session 方式:session 自动保存 Cookie,不用管
          Token 方式:需要手动保存 token 字符串,手动设 header
        """
        resp = self.session.post(
            f"{self.base_url}/api/login",
            json={"username": username, "password": password},
        )
        data = resp.json()
        if data["code"] == 200:
            self.token = data["data"]["token"]
            # 手动设置后续请求的 Authorization header
            self.session.headers.update({
                "Authorization": f"Bearer {self.token}",
            })
        return resp

    def get(self, path):
        """GET 请求(自动带 Token header)"""
        return self.session.get(f"{self.base_url}{path}")

1.5 在 MallLite 上验证鉴权行为

【新建文件】 api/test_cases/test_auth.py

python 复制代码
"""
鉴权验证专题

MallLite 使用 Session + Cookie
这里专门验证不同鉴权状态下的接口行为

知识点:为什么需要测鉴权?
  鉴权是安全的第一道防线。
  如果未登录就能创建订单、删除数据,说明权限控制有漏洞。
  即使 MallLite 是简化版系统,验证鉴权行为也能帮我们理解完整系统该怎么测。
"""

import pytest
from api_objects.user_api import UserApi
from api_objects.cart_api import CartApi
from api_objects.order_api import OrderApi
from common.assertions import ApiAssertions


class TestAuthComparison:
    """对比已登录和未登录的行为差异"""

    @pytest.mark.regression
    @pytest.mark.p1
    def test_login_returns_session_info(self, user_api, assert_api):
        """
        登录成功后返回用户信息

        验证:登录响应包含 username 和 role
        这些信息在前端显示用户名和控制菜单权限
        """
        resp = user_api.login("admin", "admin123")
        a = assert_api(resp)
        a.assert_success()
        a.assert_has_field("data.username")
        a.assert_has_field("data.role")

    @pytest.mark.regression
    @pytest.mark.p1
    def test_login_wrong_password_no_session(self, user_api, assert_api):
        """
        登录失败后不应该有有效 session

        验证:密码错误后用同一个 session 访问购物车
        应该拿到空数据(未登录状态),而不是之前登录的数据
        """
        resp = user_api.login("admin", "wrong_password")
        assert_api(resp).assert_biz_fail()

        # 用同一个 session(登录失败的)访问购物车
        resp = user_api.get("/api/cart")
        body = resp.json()
        # 应该是空数据(未登录状态的默认购物车)
        assert body["code"] == 200
        assert body["data"]["item_count"] == 0

    @pytest.mark.regression
    @pytest.mark.p1
    def test_login_then_logout_behavior(self, user_api, assert_api):
        """
        登录 → 操作 → 用新 session(相当于"退出")→ 验证行为

        已知行为:MallLite 的购物车是全局共享的,不按 session 隔离。
        所以新 session 看到的购物车数据可能跟旧 session 一样。
        这条用例记录这个行为,不硬断言隔离性。
        """
        # 登录
        user_api.login("admin", "admin123")

        # 清空购物车后加商品
        user_api.post("/api/cart/1")  # 尝试删除
        user_api.post("/api/cart", json_data={"product_id": 1, "quantity": 1})

        # 验证购物车有数据
        resp = user_api.get("/api/cart")
        items = assert_api(resp).get_field("data.items")
        assert len(items) > 0

        # 创建新 session(相当于退出后重新打开浏览器)
        new_user = UserApi("http://localhost:8000")
        resp = new_user.get("/api/cart")
        body = resp.json()
        # 已知:购物车是全局共享的,新 session 也能看到数据
        # 正式项目中这是一个安全漏洞
        assert body["code"] == 200  # 只验证不崩溃
        assert body["data"]["item_count"] >= 0  # 不断言是否隔离

        new_user.close()


class TestTokenSimulation:
    """
    模拟 JWT Token 方式的测试模式

    知识点:MallLite 实际不用 JWT,但我们可以演示如果用 JWT 测试该怎么写。
    这段代码不在 MallLite 上运行,只做教学。
    """

    @pytest.mark.regression
    def test_token_would_be_in_header(self):
        """
        模拟:如果用 JWT Token,请求头应该包含 Authorization

        这条用例演示的是测试思路,不是实际运行的代码。
        如果你的项目用 JWT,按这个模式写就行。
        """
        # 模拟 JWT Token 的使用方式
        import requests

        session = requests.Session()

        # 假设登录返回 token
        # resp = session.post(url, json={...})
        # token = resp.json()["data"]["token"]

        # 设置 token
        fake_token = "eyJhbGciOiJIUzI1NiJ9.fake_token"
        session.headers.update({"Authorization": f"Bearer {fake_token}"})

        # 验证 header 中有 Authorization
        assert "Authorization" in session.headers
        assert session.headers["Authorization"] == f"Bearer {fake_token}"

        session.close()

二、超时和重试验证

2.1 第 10 篇做了什么

第 10 篇在 base_api.py 中配置了自动重试:

python 复制代码
retry_strategy = Retry(
    total=2,                                    # 最多重试 2 次
    backoff_factor=0.5,                         # 退避间隔
    status_forcelist=[500, 502, 503, 504],      # 这些状态码触发重试
    allowed_methods=["GET", "PUT", "DELETE"],   # 只重试幂等请求
)

但一直没有用例验证它。怎么验证?用极端的 timeout 触发超时。

2.2 知识点:超时 vs 重试的区别

复制代码
超时(Timeout):
  发出请求后,等待 N 秒没有响应就算超时。
  超时后抛 Timeout 异常。
  解决的是"服务端卡住了"的问题。

重试(Retry):
  请求失败后,等一会儿再试一次。
  适合网络抖动、服务端临时过载的情况。
  解决的是"偶尔失败"的问题。

两者的结合:
  设置 timeout=5s + retry=2
  → 如果 5 秒没响应,超时
  → 等 0.5 秒后重试
  → 如果又 5 秒没响应,再等 1 秒重试
  → 最终还是失败才报错

2.3 实战

【新建文件】 api/test_cases/test_resilience.py

python 复制代码
"""
超时和重试验证

验证第 10 篇在 ApiClient 中实现的重试机制和容错能力
"""

import pytest
import requests
from api_objects.base_api import ApiClient
from common.assertions import ApiAssertions


class TestTimeout:
    """超时验证"""

    @pytest.mark.regression
    @pytest.mark.p2
    def test_request_timeout_error(self):
        """
        极短超时触发 Timeout 异常

        已知:在 Windows + localhost 环境下,timeout=0.001 可能不会触发超时,
        因为 localhost 的 TCP 连接是内存级别的,速度极快。
        在远程服务器或 CI/CD 环境中,这个测试会正常触发 Timeout。

        如果当前环境没触发,标记为 xfail(已知平台限制)。
        """
        session = requests.Session()
        try:
            with pytest.raises(requests.exceptions.Timeout):
                session.get("http://localhost:8000/api/products", timeout=0.001)
        except pytest.fail.Exception:
            # DID NOT RAISE --- localhost 太快,timeout 没生效
            pytest.xfail("Windows + localhost 环境下极短 timeout 不可靠")
        finally:
            session.close()

    @pytest.mark.regression
    @pytest.mark.p2
    def test_normal_request_no_timeout(self, product_api, assert_api):
        """正常请求不超时"""
        resp = product_api.get_products()
        assert_api(resp).assert_success()

    @pytest.mark.regression
    @pytest.mark.p2
    def test_timeout_via_base_api(self):
        """
        通过 ApiClient 发送带超时的请求

        同 test_request_timeout_error,localhost 上可能不触发
        """
        api = ApiClient("http://localhost:8000")
        try:
            with pytest.raises(requests.exceptions.Timeout):
                api.get("/api/products", timeout=0.001)
        except pytest.fail.Exception:
            pytest.xfail("Windows + localhost 环境下极短 timeout 不可靠")
        finally:
            api.close()

    @pytest.mark.regression
    @pytest.mark.p2
    def test_connection_error(self):
        """
        连接不存在的服务器触发 ConnectionError

        日志显示 Retry 会重试 2 次,共等约 15 秒后才报错
        这恰好证明重试机制在工作
        """
        api = ApiClient("http://localhost:19999")
        with pytest.raises(requests.exceptions.ConnectionError):
            api.get("/api/products")
        api.close()

    @pytest.mark.regression
    @pytest.mark.p2
    def test_invalid_url_returns_error(self):
        """请求不存在的路径,返回 404"""
        api = ApiClient("http://localhost:8000")
        resp = api.get("/api/nonexistent_path_xyz")
        assert resp.status_code in (404, 405, 500)
        api.close()


class TestRetryStrategy:
    """
    重试策略验证

    第 10 篇配置了:GET/PUT/DELETE 对 5xx 自动重试 2 次
    POST 不重试,避免重复创建资源
    """

    @pytest.mark.regression
    @pytest.mark.p2
    def test_retry_config_is_applied(self):
        """
        验证重试配置已生效

        检查 session 的 adapter 中 Retry 的参数
        这不是测试 MallLite,而是测试我们自己的框架配置
        """
        api = ApiClient("http://localhost:8000")

        adapter = api.session.get_adapter("http://localhost:8000")
        retry = adapter.max_retries

        assert retry.total == 2, f"重试次数期望 2,实际 {retry.total}"
        assert 500 in retry.status_forcelist
        assert 502 in retry.status_forcelist
        assert 503 in retry.status_forcelist
        assert 504 in retry.status_forcelist

        api.close()

    @pytest.mark.regression
    @pytest.mark.p2
    def test_post_not_in_retry_list(self):
        """
        POST 不在重试列表中

        POST 创建资源,重试会重复创建
        """
        api = ApiClient("http://localhost:8000")

        adapter = api.session.get_adapter("http://localhost:8000")
        retry = adapter.max_retries

        if retry.allowed_methods is not None:
            assert "POST" not in retry.allowed_methods, \
                "POST 不应该在重试列表中"

        api.close()

    @pytest.mark.regression
    @pytest.mark.p2
    def test_request_statistics(self):
        """
        验证请求统计功能

        _request_count 记录总请求数,_fail_count 记录失败数
        """
        api = ApiClient("http://localhost:8000")

        assert api._request_count == 0
        assert api._fail_count == 0

        # 3 次正常请求
        api.get("/api/products")
        api.get("/api/products")
        api.get("/api/products")

        assert api._request_count == 3
        assert api._fail_count == 0

        # 1 次失败请求(404)
        api.get("/api/nonexistent")

        assert api._request_count == 4
        assert api._fail_count == 1

        api.close()

    @pytest.mark.regression
    @pytest.mark.p2
    def test_multiple_requests_stable(self):
        """
        连续多次请求都正常

        验证 session 稳定性
        """
        api = ApiClient("http://localhost:8000")

        for i in range(5):
            resp = api.get("/api/products")
            assert resp.status_code == 200, f"第 {i+1} 次请求失败"

        assert api._request_count == 5
        assert api._fail_count == 0

        api.close()


class TestConcurrency:
    """
    并发场景测试

    用 threading 模拟多用户同时操作
    """

    @pytest.mark.regression
    @pytest.mark.p2
    def test_concurrent_login(self):
        """
        多个线程同时登录同一个账号

        验证:不会因为并发登录导致错误
        """
        import threading

        base = "http://localhost:8000"
        results = []

        def login_account():
            try:
                user = ApiClient(base)
                resp = user.post("/api/login", json_data={
                    "username": "admin", "password": "admin123"
                })
                results.append(("success", resp.json()["code"]))
                user.close()
            except Exception as e:
                results.append(("error", str(e)))

        threads = [threading.Thread(target=login_account) for _ in range(3)]

        for t in threads:
            t.start()
        for t in threads:
            t.join(timeout=30)

        assert len(results) == 3
        for status, code in results:
            assert status == "success", f"有线程报错:{results}"
            assert code == 200

    @pytest.mark.regression
    @pytest.mark.p2
    def test_concurrent_get_products(self):
        """
        多个线程同时查询商品列表

        验证:并发读取不会导致错误
        """
        import threading

        base = "http://localhost:8000"
        results = []

        def get_products():
            try:
                api = ApiClient(base)
                resp = api.get("/api/products")
                results.append(("success", resp.status_code))
                api.close()
            except Exception as e:
                results.append(("error", str(e)))

        threads = [threading.Thread(target=get_products) for _ in range(5)]

        for t in threads:
            t.start()
        for t in threads:
            t.join(timeout=30)

        assert len(results) == 5
        for status, code in results:
            assert status == "success", f"有线程报错:{results}"
            assert code == 200

三、并发场景模拟

3.1 为什么要测并发

单线程测试只能验证"一个用户操作时没问题"。但真实场景中,多个用户可能同时操作同一份数据:

复制代码
场景 1:两个人同时加同一件商品到购物车
场景 2:两个人同时下单最后一件库存商品
场景 3:管理员正在修改商品价格,用户同时查看

如果服务端没有做好并发控制,可能出现数据不一致。

3.2 知识点:并发测试能发现什么 Bug?

复制代码
Bug 1:库存超卖
  商品库存 1 件,两个人同时下单,都成功了 → 卖出了 2 件(实际只有 1 件)

Bug 2:购物车数量错误
  两个人同时把商品数量从 2 改成 5 → 最终结果可能是 5,也可能是 2(取决于谁后写入)

Bug 3:订单号重复
  两个人同时创建订单,生成了相同的订单号 → 后一个覆盖了前一个

3.3 实战

【追加到】 api/test_cases/test_resilience.py,在文件末尾追加:

python 复制代码
# ===== 以下是并发测试 =====

import threading
import time
from api_objects.user_api import UserApi
from api_objects.cart_api import CartApi
from api_objects.order_api import OrderApi


class TestConcurrency:
    """
    并发场景测试

    用 Python 的 threading 模块模拟多用户同时操作

    知识点:threading 的作用
      每个线程模拟一个用户的操作。
      多个线程同时启动,模拟"同一时刻"多个请求并发到达服务端。
    """

    @pytest.mark.regression
    @pytest.mark.p2
    def test_concurrent_add_to_cart(self):
        """
        两个线程同时加同一件商品到购物车

        验证:两个请求都成功,购物车最终状态正确
        """
        base = "http://localhost:8000"
        results = []

        def add_cart(username, password):
            """每个线程的执行函数"""
            try:
                user = UserApi(base)
                user.login(username, password)
                cart = CartApi(base)
                cart.session = user.session
                cart.clear_cart()
                resp = cart.add_to_cart(product_id=1, quantity=1)
                results.append(("success", resp.json()["code"]))
                user.close()
            except Exception as e:
                results.append(("error", str(e)))

        # 创建两个线程,同时启动
        t1 = threading.Thread(target=add_cart, args=("admin", "admin123"))
        t2 = threading.Thread(target=add_cart, args=("testuser", "test123"))

        t1.start()
        t2.start()

        # 等待两个线程都完成
        t1.join(timeout=30)
        t2.join(timeout=30)

        # 两个都应该成功
        assert len(results) == 2, f"期望 2 个结果,实际 {len(results)}"
        for status, code in results:
            assert status == "success", f"有线程报错:{results}"
            assert code == 200, f"有请求失败:code={code}"

    @pytest.mark.regression
    @pytest.mark.p2
    def test_concurrent_login(self):
        """
        多个线程同时登录同一个账号

        验证:不会因为并发登录导致错误
        """
        base = "http://localhost:8000"
        results = []

        def login_account():
            try:
                user = UserApi(base)
                resp = user.login("admin", "admin123")
                results.append(resp.json()["code"])
                user.close()
            except Exception as e:
                results.append(str(e))

        threads = []
        for _ in range(3):
            t = threading.Thread(target=login_account)
            threads.append(t)

        # 同时启动 3 个线程
        for t in threads:
            t.start()

        # 等待全部完成
        for t in threads:
            t.join(timeout=30)

        # 都应该成功
        assert len(results) == 3
        for code in results:
            assert code == 200, f"并发登录失败:{results}"

    @pytest.mark.regression
    @pytest.mark.p2
    def test_concurrent_create_order(self):
        """
        两个线程同时创建订单

        验证:不会因为并发下单导致错误
        """
        base = "http://localhost:8000"
        results = []

        def create_order():
            try:
                user = UserApi(base)
                user.login("admin", "admin123")
                cart = CartApi(base)
                cart.session = user.session
                cart.clear_cart()
                cart.add_to_cart(product_id=1, quantity=1)

                order = OrderApi(base)
                order.session = user.session
                resp = order.create_order()
                results.append(("success", resp.json()["code"]))
                user.close()
            except Exception as e:
                results.append(("error", str(e)))

        t1 = threading.Thread(target=create_order)
        t2 = threading.Thread(target=create_order)

        t1.start()
        t2.start()

        t1.join(timeout=30)
        t2.join(timeout=30)

        assert len(results) == 2
        for status, code in results:
            assert status == "success", f"有线程报错:{results}"

3.4 并发测试的局限性

知识点:threading 的局限

Python 的 GIL(全局解释器锁)限制了真正的多线程并行。threading 模块模拟的是"交替执行",不是"真正同时"。对于接口测试来说足够了,因为瓶颈在网络 I/O 而不是 CPU。

如果需要更高并发,可以用 Locust(第 17 篇会讲)或 asyncio。


四、数据清理策略

4.1 问题回顾

之前的 conftest.py 里用了 autouse=Trueclean_cart fixture,每个用例执行前自动清空购物车。这只解决了购物车的问题。如果用例创建了订单呢?注册了用户呢?

4.2 四种清理策略

知识点:选择哪种策略取决于数据创建的成本和副作用。

策略 适用场景 实现方式
前置清理 每次操作前清空再开始 autouse fixture 在 setup 清理
后置清理 操作后恢复原状 yield fixture 在 teardown 清理
独立数据 每次创建唯一的数据 用时间戳做唯一标识
不清理 只读操作、不影响数据的测试 不需要 fixture

4.3 各策略的实际代码

【追加到】 api/conftest.py,替换原有的 clean_cart fixture,并追加其他清理策略:

python 复制代码
# ===== 数据清理策略 =====

@pytest.fixture(autouse=True)
def clean_cart(admin_session):
    """
    策略 1:前置清理

    autouse=True 表示自动对所有用例生效。
    每个用例开始前自动清空购物车,避免上一个用例残留的数据影响当前用例。

    适用场景:购物车这种会被频繁修改的数据
    """
    cart = CartApi(BASE_URL)
    cart.session = admin_session
    cart.clear_cart()
    yield  # 用例执行


@pytest.fixture
def unique_username():
    """
    策略 3:独立数据

    用时间戳生成唯一用户名,每个用例注册不同的账号。
    不需要清理,因为每个账号只用一次。

    适用场景:注册接口测试
    """
    return f"test_{int(time.time() * 1000)}"


# 以下是策略 2 的示例(不在 MallLite 上运行,做教学演示):
#
# @pytest.fixture
# def created_and_cleanup(admin_cart_api, admin_order_api):
#     """
#     策略 2:后置清理
#
#     yield 之前是 setup(创建数据)
#     yield 之后是 teardown(清理数据)
#
#     适用场景:创建了不可逆的数据(如订单),需要在用例结束后清理
#     """
#     admin_cart_api.add_to_cart(product_id=1, quantity=1)
#     resp = admin_order_api.create_order()
#     order_no = resp.json()["data"]["order_no"]
#
#     yield order_no  # 用例执行,把 order_no 传给用例
#
#     # 用例结束后清理(如果 MallLite 有删除订单的接口)
#     # admin_order_api.delete_order(order_no)

4.4 什么时候用哪种

复制代码
购物车 → 前置清理(autouse):每次用例前清空,简单直接
订单 → 独立数据或后置清理:订单号是唯一的,一般不冲突
用户注册 → 独立数据(时间戳):每个用例注册不同用户名,不冲突
商品 → 不清理:商品是只读的,不修改数据

五、Mock 外部接口(理论 + 独立示例)

5.1 什么是 Mock

当你的接口依赖第三方服务时(如支付、短信、物流),测试中不可能真的调用第三方。Mock 就是"假装"第三方返回了你期望的结果。

复制代码
没有 Mock:
  创建订单 → 调用支付宝接口 → 支付宝返回"支付成功" → 订单完成
  问题:测试环境没有真实的支付宝接口

有了 Mock:
  创建订单 → 拦截对支付宝的请求 → 直接返回"支付成功" → 订单完成
  测试不依赖外部服务,稳定且快速

5.2 知识点:responses 库

responses 是 Python 中 mock HTTP 请求的标准库。它能拦截 requests 发出的请求,返回预设的响应。

powershell 复制代码
pip install responses

5.3 独立示例

【新建文件】 api/examples/mock_demo.py

python 复制代码
"""
Mock 外部接口示例

这个文件不在 MallLite 上运行,是独立的教学示例。
演示如何用 responses 库 mock 第三方接口。

运行方式:python api/examples/mock_demo.py
"""

import requests
import responses


# ===== 场景:假设订单接口需要调用第三方支付 =====

def create_order_with_payment(order_data):
    """
    创建订单并调用第三方支付接口

    这个函数模拟的是:订单服务先调用支付接口验证支付状态,
    如果支付成功才创建订单。
    """
    # 第一步:调用支付接口
    payment_resp = requests.post(
        "https://pay.example.com/api/verify",
        json={"order_id": order_data["order_id"], "amount": order_data["amount"]},
    )
    payment_result = payment_resp.json()

    if payment_result["status"] == "success":
        return {"code": 200, "message": "订单创建成功", "order_no": "ORD001"}
    else:
        return {"code": 400, "message": "支付失败"}


# ===== 没有 Mock 的情况 =====
# 调用会真的去请求 https://pay.example.com,但这个地址不存在,会报错


# ===== 用 Mock 的情况 =====
@responses.activate
def test_create_order_with_mock_payment():
    """
    用 responses.mock 拦截支付请求,返回预设结果

    知识点:@responses.activate 装饰器
      这个装饰器让 responses 开始拦截 HTTP 请求。
      在函数内注册的 mock 规则,会在函数结束时自动清除。
    """
    # 注册 mock 规则:如果有人 POST https://pay.example.com/api/verify
    # 就返回 {"status": "success"},状态码 200
    responses.add(
        responses.POST,
        "https://pay.example.com/api/verify",
        json={"status": "success"},
        status=200,
    )

    # 调用被测函数
    result = create_order_with_payment({"order_id": "ORD001", "amount": 8999})

    # 验证结果
    assert result["code"] == 200
    assert result["message"] == "订单创建成功"
    print("✓ Mock 支付成功场景通过")


@responses.activate
def test_create_order_with_failed_payment():
    """模拟支付失败的场景"""
    responses.add(
        responses.POST,
        "https://pay.example.com/api/verify",
        json={"status": "failed", "reason": "余额不足"},
        status=200,
    )

    result = create_order_with_payment({"order_id": "ORD002", "amount": 99999})

    assert result["code"] == 400
    assert result["message"] == "支付失败"
    print("✓ Mock 支付失败场景通过")


if __name__ == "__main__":
    test_create_order_with_mock_payment()
    test_create_order_with_failed_payment()
    print("\n所有 Mock 示例通过")

运行:

powershell 复制代码
python api\examples\mock_demo.py

六、接口签名加密(理论 + 独立示例)

6.1 什么是接口签名

有些 API 需要对请求参数做签名,防止被篡改。签名的流程:

复制代码
1. 把所有参数按字母排序拼接:amount=100&order_id=ORD001&user_id=1
2. 加上密钥拼接:amount=100&order_id=ORD001&user_id=1&secret_key=mykey
3. 对拼接后的字符串做 MD5/SHA256:hash = md5(拼接串)
4. 把 hash 作为 sign 参数加到请求中

服务端用同样的方式计算签名,对比是否一致。如果参数被中间人篡改了,签名就对不上。

6.2 知识点:怎么在测试中处理签名

python 复制代码
"""
接口签名示例

这个文件不在 MallLite 上运行,是独立的教学示例。
演示如何在测试中生成正确的签名。

运行方式:python api/examples/sign_demo.py
"""

import hashlib
import json


def generate_sign(params, secret_key):
    """
    生成签名

    签名算法:
      1. 把参数按 key 字母排序
      2. 拼接成 key=value&key=value 的格式
      3. 末尾加上 &secret_key=xxx
      4. 对整个字符串做 MD5
    """
    # 按 key 排序
    sorted_keys = sorted(params.keys())

    # 拼接
    sign_str = "&".join(f"{k}={params[k]}" for k in sorted_keys)
    sign_str += f"&secret_key={secret_key}"

    # MD5
    sign = hashlib.md5(sign_str.encode("utf-8")).hexdigest()
    return sign


# 演示
if __name__ == "__main__":
    # 假设的请求参数
    params = {
        "order_id": "ORD001",
        "amount": "100",
        "user_id": "1",
    }
    secret_key = "my_secret_key"

    sign = generate_sign(params, secret_key)
    print(f"参数:{json.dumps(params, indent=2)}")
    print(f"密钥:{secret_key}")
    print(f"签名:{sign}")

    # 验证:篡改参数后签名不同
    tampered_params = params.copy()
    tampered_params["amount"] = "99999"  # 篡改金额
    tampered_sign = generate_sign(tampered_params, secret_key)

    print(f"\n篡改后签名:{tampered_sign}")
    print(f"签名不同:{sign != tampered_sign}")  # 应该是 True

    # 在测试中的用法:
    # resp = api.post("/api/orders", json_data={
    #     "order_id": "ORD001",
    #     "amount": "100",
    #     "user_id": "1",
    #     "sign": generate_sign(params, secret_key),
    # })

运行:

powershell 复制代码
python api\examples\sign_demo.py

七、运行验证

powershell 复制代码
pytest -v

预期用例分布:

文件 用例数 来源
test_user.py 15 第 10 篇
test_product.py 12 第 10 篇
test_cart.py 9 第 9 篇
test_order.py 8 第 9 篇
test_shopping_flow.py 7 第 11 篇
test_extractor_practice.py 5 第 11 篇
test_data_consistency.py 9 第 11 篇
test_session.py 7 第 11 篇
test_pagination.py 8 第 11 篇
test_edge_cases.py 11 第 11 篇
test_auth.py 5 本篇
test_resilience.py 10 本篇
合计 106
powershell 复制代码
# 只跑鉴权测试
pytest test_cases/test_auth.py -v

# 只跑超时和并发
pytest test_cases/test_resilience.py -v

# 跑 Mock 示例(独立脚本)
python api\examples\mock_demo.py

# 跑签名示例(独立脚本)
python api\examples\sign_demo.py

八、本篇新增和修改的文件

文件 操作 说明
test_cases/test_auth.py 新建 5 条鉴权验证用例
test_cases/test_resilience.py 新建 7 条超时重试 + 3 条并发用例
examples/mock_demo.py 新建 Mock 外部接口独立示例
examples/sign_demo.py 新建 接口签名加密独立示例
conftest.py 修改 完善数据清理策略注释
requirements.txt 修改 新增 responses 依赖

九、今日成果

  • 讲解了三种鉴权方式(Session+Cookie、JWT Token、OAuth)的区别和测试差异
  • 编写了鉴权验证用例(登录信息验证、失败后无 session、模拟退出)
  • 编写了超时和重试验证(极端超时触发、连接错误、重试配置检查、请求统计)
  • 编写了并发场景用例(并发加购、并发登录、并发下单)
  • 讲解了四种数据清理策略(前置清理、后置清理、独立数据、不清理)
  • 讲解了 Mock 外部接口的原理和 responses 库用法(独立示例)
  • 讲解了接口签名加密的原理和实现(独立示例)
  • 项目累计 106 条用例

十、下篇预告

13 - 接口数据驱动与报告

最后一篇:YAML 数据驱动、Allure 报告集成(请求/响应自动附加到报告中、用例分类、严重级别、Step 装饰器)、接口自动化篇完整项目总结。


接口自动化篇进度:12/13。

相关推荐
牢姐与蒯1 小时前
Linux文件(一).前传之基础IO
linux·运维·服务器
新时代牛马3 小时前
Linux 磁盘管理:分区、文件系统、挂载与扩容
linux·运维·服务器
海宇数据3 小时前
零信任架构实战:基于海宇柠檬查出险-登记证构建自动化残值评估网关
人工智能·python·架构·自动化
大树883 小时前
液冷系统的真正瓶颈,藏在那层不到1毫米的材料里
大数据·运维·服务器·人工智能·ai
yindeshuiketang3 小时前
企业FDE架构方法论到实战指南从想法到商业变现:需求·设计·技术·测试·运维·交付·变现-小红书&抖音 AI马教授 职场启航宝
运维·人工智能·架构
网硕互联的小客服4 小时前
没有公网IP,如何让外网访问内网服务器?
运维·服务器
布裘4 小时前
【银河麒麟】服务器系统开机黑屏故障排查与修复
linux·运维·服务器·银河麒麟·无法启动
凌风的跨境分享4 小时前
Temu店群运维提效:定时策略自动化任务全场景实操指南
大数据·运维·前端·人工智能·架构·自动化
Zenova EdgeOS4 小时前
Linux iotop:工业边缘 I/O 高级分析实战
linux·运维·服务器