Python自动化测试中Mock外部接口

在自动化测试中,一个核心挑战是隔离被测代码,使其不依赖于不稳定的外部系统,如第三方API、数据库或微服务。Mock(模拟)技术是解决此问题的关键。本文将以一个简单的接口请求场景为例,探讨主流Mock方法及其使用场景和利弊。

场景示例:待测试函数

假设我们有一个测试方法,其功能是从一个外部API获取数据并验证返回内容。

import requests
import json

def get_data():
    url = 'http://baidu.com/api/products'  # 外部依赖接口
    response = requests.get(url)
    data = json.loads(response.text)
    print(data)
    assert 'data' in data
    return data

测试目标:在不实际调用http://baidu.com/api/products的前提下,测试get_data()函数逻辑(特别是assert 'data' in data)。我们需要模拟该接口,使其固定返回{'data': [{'api': 'mock1'}, {'api': 'mock22'}]}


主流的Mock方法及分析

方法一:使用unittest.mock库 (最常用、最灵活)

Python标准库unittest.mock(或第三方pytest-mock)提供了patch装饰器/上下文管理器,允许在测试运行时动态替换对象。

示例代码

import unittest.mock as mock
import your_module  # 假设get_data函数定义在your_module.py中

def test_get_data_with_unittest_mock():
    # 1. 定义模拟的返回值
    mock_response = mock.MagicMock()
    mock_response.text = '{"data": [{"api": "mock1"}, {"api": "mock22"}]}'

    # 2. 使用patch替换requests.get方法
    with mock.patch('your_module.requests.get') as mock_get:
        # 让被替换的mock_get函数返回我们准备好的mock_response
        mock_get.return_value = mock_response

        # 3. 调用被测函数
        result = your_module.get_data()

        # 4. 进行断言
        assert result == {'data': [{'api': 'mock1'}, {'api': 'mock22'}]}
        # 验证requests.get是否被以正确的参数调用了一次
        mock_get.assert_called_once_with('http://baidu.com/api/products')
  • 使用场景

    • 单元测试:需要精确控制被模拟函数的行为和返回值。

    • 模拟任何可导入的对象(函数、类、属性等)。

    • 需要验证模拟函数是否被正确调用(调用次数、参数)。

  • 优点

    • 功能强大:不仅可以模拟返回值,还能模拟副作用、检查调用历史。

    • 无需修改源码:在测试层面完成替换,对原代码无侵入。

    • 与测试框架深度集成:是unittestpytest的首选方案。

  • 缺点

    • 补丁路径(patch target):需要正确指定对象的导入路径(your_module.requests.get而非requests.get),新手容易出错。

    • 可能过度模拟:如果模拟范围过大,会降低测试的真实性。

方法二:使用responses库 (针对HTTP请求的专用工具)

responses库专门用于模拟requests库发起的HTTP请求,通过注册预定义的响应来拦截实际网络调用。

示例代码

import responses
import your_module

@responses.activate  # 激活响应模拟
def test_get_data_with_responses_lib():
    # 注册一个模拟响应:当向指定URL发送GET请求时,返回我们预设的JSON和状态码
    mock_json = {'data': [{'api': 'mock1'}, {'api': 'mock22'}]}
    responses.add(responses.GET,
                  'http://baidu.com/api/products',
                  json=mock_json,
                  status=200)

    # 调用被测函数,此时requests.get会被库拦截,不会发生真实网络请求
    result = your_module.get_data()

    # 断言返回值
    assert result == mock_json
    # 也可以断言请求历史
    assert len(responses.calls) == 1
    assert responses.calls[0].request.url == 'http://baidu.com/api/products'
  • 使用场景

    • 专门针对requests库的测试。代码清晰,意图明确。

    • 需要模拟复杂的HTTP交互,如不同的状态码、响应头、响应体序列。

    • 集成测试中,希望更真实地模拟HTTP层的行为。

  • 优点

    • 声明式API:设置模拟响应的代码非常直观,类似定义路由。

    • 更真实的模拟:能模拟网络超时、特定HTTP状态码等requests库可能遇到的情况。

    • 避免补丁路径问题:在库层面拦截,不关心被测代码中requests的导入路径。

  • 缺点

    • 专用性:仅适用于requests库。如果代码使用其他HTTP客户端(如aiohttphttpx),则需要其他工具。

    • 可能遗漏模拟:如果未对某个URL注册响应,测试时会发起真实请求,可能导致测试失败或意外行为。

方法三:搭建临时的Mock Server (重量级但最真实)

使用如pytest-httpserverWireMock等工具,在测试运行时启动一个真实的、临时的HTTP服务器,该服务器按预定规则返回响应。

示例代码 (使用pytest-httpserver):

import pytest
import your_module

def test_get_data_with_mock_server(pytest_httpserver):
    # 1. 在Mock Server上为指定路径设置响应
    mock_json = {'data': [{'api': 'mock1'}, {'api': 'mock22'}]}
    pytest_httpserver.expect_request("/api/products").respond_with_json(mock_json)

    # 2. 获取Mock Server的地址(如 http://localhost:1234)
    mock_url = pytest_httpserver.url_for("/api/products")

    # 3. 临时替换被测函数中的URL,指向Mock Server
    # 注意:这里需要能修改被测函数的url,在实际中可能需要通过依赖注入(如环境变量、参数化)实现
    original_url = your_module.TARGET_URL
    your_module.TARGET_URL = mock_url
    try:
        result = your_module.get_data()
        assert result == mock_json
    finally:
        your_module.TARGET_URL = original_url  # 恢复原URL
  • 使用场景

    • 端到端(E2E)或集成测试:需要测试整个HTTP协议栈的真实交互。

    • 被测代码使用的HTTP客户端库非常用或无法用上述方法轻松模拟时。

    • 需要模拟一个完整的、行为复杂的API服务。

  • 优点

    • 最高真实性:最接近生产环境,能暴露网络、序列化/反序列化层面的问题。

    • 客户端无关:对被测代码使用的HTTP客户端没有要求。

    • 可复用:Mock Server的定义可以被多个测试用例甚至多个项目共享。

  • 缺点

    • 复杂度高,速度慢:需要启动和管理服务器进程,测试运行最慢。

    • 配置繁琐:需要管理服务器生命周期和响应规则。

    • 可能引入额外依赖:需要确保测试环境能运行该服务器。

总结与选择建议

方法

核心思想

适用测试层级

优点

缺点

unittest.mock

运行时对象替换

单元测试

灵活、强大、标准库

补丁路径需小心,可能过度模拟

responses

拦截特定HTTP库的请求

单元测试、集成测试

声明式、针对requests、模拟真实HTTP场景

仅限requests,未注册的请求会发出去

Mock Server

启动真实HTTP服务

集成测试、E2E测试

真实性最高、客户端无关

速度慢、配置复杂、重量级

通用建议

  1. 优先使用unittest.mock/pytest-mock进行纯单元测试,它提供了最精细的控制。

  2. 如果测试大量围绕requests库,responses库能让测试代码更清晰、意图更明确

  3. 只有当需要测试网络交互的完整链条,或者单元/集成测试的Mock无法满足需求时,才考虑搭建Mock Server

在您的示例中,为了测试get_data()函数中assert 'data' in data的逻辑,方法一(unittest.mock)通常是最高效、最直接的选择

mock数据通过yaml存储读取

# 接口配置文件
apis:
  # 产品接口
  products:
    url: http://baidu.com/api/products
    method: GET
    request_params:
      category: 
      page: 1
      limit: 10
    response_data:
      data:
        - id: 1
          category:
        - id: 2
          category:
        - id: 3
          category:
    # 参数映射规则:请求参数 -> 响应字段路径
    param_mappings:
      category: data[*].category
    status: 200
  
  # 用户接口
  users:
    url: http://example.com/api/users
    method: GET
    request_params:
      department: engineering
    response_data:
      data:
        - id: 1
          name: John
        - id: 2
          name: Alice
    status: 200
  
  # 订单接口
  orders:
    url: http://example.com/api/orders
    method: GET
    response_data:
      data:
        - id: 101
          amount: 100
        - id: 102
          amount: 200
    status: 200

test

import json
import requests
import responses
import yaml

# 读取 yaml 配置文件
def load_api_config(config_file):
    import os
    # 获取当前文件的目录路径
    current_dir = os.path.dirname(os.path.abspath(__file__))
    
    # 确保路径是相对于当前文件的,而不是相对于执行目录
    if not os.path.isabs(config_file):
        # 检查 config_file 是否已经包含当前目录的部分路径
        if not config_file.startswith('tests/'):
            config_file = os.path.join(current_dir, config_file)
        else:
            # 如果 config_file 已经以 tests/ 开头,直接使用当前目录作为基础
            config_file = os.path.join(current_dir, config_file.replace('tests/', ''))
    
    with open(config_file, 'r', encoding='utf-8') as f:
        return yaml.safe_load(f)

# 设置 mock 响应的函数
def setup_mock_response(rsps, url, method=responses.GET, request_params=None, response_data=None, status=200, param_mappings=None):
    # 处理请求参数,动态生成响应数据
    def callback(request):
        # 从请求中获取参数
        from urllib.parse import urlparse, parse_qs
        parsed_url = urlparse(request.url)
        params = parse_qs(parsed_url.query)
        
        # 复制响应数据
        import copy
        dynamic_response = copy.deepcopy(response_data)
        
        # 根据参数映射规则更新响应数据
        if param_mappings:
            for param_name, response_path in param_mappings.items():
                if param_name in params:
                    param_value = params[param_name][0]
                    # 处理响应路径,支持通配符 *
                    path_parts = response_path.split('.')
                    current = dynamic_response
                    
                    # 遍历路径部分
                    i = 0
                    while i < len(path_parts):
                        part = path_parts[i]
                        # 处理数组通配符
                        if part.endswith('[]') or part.endswith('[*]'):
                            array_name = part.split('[')[0]
                            if array_name in current:
                                # 遍历数组中的每个元素
                                for item in current[array_name]:
                                    # 处理数组元素的剩余路径
                                    if i < len(path_parts) - 1:
                                        sub_path = path_parts[i+1:]
                                        update_nested_value(item, sub_path, param_value)
                            # 数组通配符处理完毕,跳出循环
                            break
                        else:
                            # 处理普通字段
                            if i == len(path_parts) - 1:
                                # 最后一个路径部分,设置值
                                current[part] = param_value
                            else:
                                # 中间路径部分,继续深入
                                if part in current:
                                    current = current[part]
                                else:
                                    break
                        i += 1
        
        return (status, {}, json.dumps(dynamic_response))
    
    # 辅助函数:更新嵌套值
    def update_nested_value(obj, path_parts, value):
        current = obj
        for i, part in enumerate(path_parts):
            if i == len(path_parts) - 1:
                # 最后一个路径部分,设置值
                current[part] = value
            else:
                # 中间路径部分,继续深入
                if part in current:
                    current = current[part]
                else:
                    break
    
    # 添加 mock 响应
    rsps.add_callback(
        method,
        url,
        callback=callback,
        content_type='application/json',
    )

# 接口测试方法
def atest_api_products(use_mock=False, api_name=None, config_file='tests/api_config.yaml', url='http://baidu.com/api/products', request_params=None, expected_response=None, status=200):
    param_mappings = None
    # 从配置文件读取接口配置
    if api_name:
        config = load_api_config(config_file)
        api_config = config['apis'][api_name]
        url = api_config.get('url', url)
        # 只有当传入的 request_params 为 None 时,才使用配置文件中的值
        if request_params is None:
            request_params = api_config.get('request_params')
        expected_response = api_config.get('response_data', expected_response)
        status = api_config.get('status', status)
        param_mappings = api_config.get('param_mappings')
    
    # 默认响应数据
    if expected_response is None:
        expected_response = {'data': [{'id': 1}]}
    
    if use_mock:
        with responses.RequestsMock() as rsps:
            # 设置 mock 响应
            setup_mock_response(rsps, url, responses.GET, request_params, expected_response, status, param_mappings)

            # 发送请求
            if request_params:
                re = requests.get(url, params=request_params)
            else:
                re = requests.get(url)
            
            print(re.text)
            assert 'data' in json.loads(re.text)
    else:
        # 发送真实请求
        if request_params:
            re = requests.get(url, params=request_params)
        else:
            re = requests.get(url)
        
        print(re.text)
        assert 'data' in json.loads(re.text)


# 调用测试方法 - 使用默认参数
atest_api_products(use_mock=True)

# 调用测试方法 - 从配置文件读取产品接口配置
atest_api_products(
    use_mock=True,
    api_name='products'
)

# 调用测试方法 - 测试 category 参数
atest_api_products(
    use_mock=True,
    api_name='products',
    request_params={'category': 'electronics', 'page': 1, 'limit': 10}
)

# 调用测试方法 - 不使用 mock
# test_api_products(use_mock=False)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值