在自动化测试中,一个核心挑战是隔离被测代码,使其不依赖于不稳定的外部系统,如第三方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')
-
使用场景:
-
单元测试:需要精确控制被模拟函数的行为和返回值。
-
模拟任何可导入的对象(函数、类、属性等)。
-
需要验证模拟函数是否被正确调用(调用次数、参数)。
-
-
优点:
-
功能强大:不仅可以模拟返回值,还能模拟副作用、检查调用历史。
-
无需修改源码:在测试层面完成替换,对原代码无侵入。
-
与测试框架深度集成:是
unittest和pytest的首选方案。
-
-
缺点:
-
补丁路径(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客户端(如aiohttp,httpx),则需要其他工具。 -
可能遗漏模拟:如果未对某个URL注册响应,测试时会发起真实请求,可能导致测试失败或意外行为。
-
方法三:搭建临时的Mock Server (重量级但最真实)
使用如pytest-httpserver、WireMock等工具,在测试运行时启动一个真实的、临时的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的定义可以被多个测试用例甚至多个项目共享。
-
-
缺点:
-
复杂度高,速度慢:需要启动和管理服务器进程,测试运行最慢。
-
配置繁琐:需要管理服务器生命周期和响应规则。
-
可能引入额外依赖:需要确保测试环境能运行该服务器。
-
总结与选择建议
|
方法 |
核心思想 |
适用测试层级 |
优点 |
缺点 |
|---|---|---|---|---|
|
|
运行时对象替换 |
单元测试 |
灵活、强大、标准库 |
补丁路径需小心,可能过度模拟 |
|
|
拦截特定HTTP库的请求 |
单元测试、集成测试 |
声明式、针对 |
仅限 |
|
Mock Server |
启动真实HTTP服务 |
集成测试、E2E测试 |
真实性最高、客户端无关 |
速度慢、配置复杂、重量级 |
通用建议:
-
优先使用
unittest.mock/pytest-mock进行纯单元测试,它提供了最精细的控制。 -
如果测试大量围绕
requests库,responses库能让测试代码更清晰、意图更明确。 -
只有当需要测试网络交互的完整链条,或者单元/集成测试的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)

2138

被折叠的 条评论
为什么被折叠?



