文章目录
简介
库 | Stars | 备注 |
---|---|---|
responses | 3.8 k | 需要Python 3.7+ |
requests-mock | 355 | |
HTTPretty | 2 k | |
httmock | 452 |
统计截止 2023.03.23
responses
一款用于模拟响应的 Python 库,非常类似于 requests
安装
pip install responses
初试
main.py
from flask import Flask
app = Flask(__name__)
@app.route('/')
def index():
return 'Hello World!'
@app.route('/user/<name>')
def user(name):
return f'Hello {name}!'
if __name__ == '__main__':
app.run()
访问
模拟网络请求
import requests
import responses
# 真实请求
print(requests.get('http://127.0.0.1:5000/').text) # Hello World!
print(requests.get('http://127.0.0.1:5000/user/a').text) # Hello a!
@responses.activate
def test_index():
responses.get('http://127.0.0.1:5000/', body='Hello Fake World!') # 模拟网络请求
response = requests.get('http://127.0.0.1:5000/')
assert response.text == 'Hello Fake World!'
@responses.activate
def test_user():
responses.get('http://127.0.0.1:5000/user/a', body='Hello Fake a!') # 模拟网络请求
response = requests.get('http://127.0.0.1:5000/user/a')
assert response.text == 'Hello Fake a!'
if __name__ == '__main__':
test_index()
test_user()
基础
核心概念为注册模拟响应,并使用 responses.activate
装饰器覆盖测试函数
import requests
import responses
@responses.activate
def test_simple():
rsp1 = responses.Response(method='PUT', url='http://example.com')
responses.add(rsp1)
responses.add(responses.GET, 'http://twitter.com/api/1/foobar', json={'error': 'not found'}, status=404)
resp = requests.get('http://twitter.com/api/1/foobar')
resp2 = requests.put('http://example.com')
assert resp.json() == {'error': 'not found'}
assert resp.status_code == 404
assert resp2.status_code == 200
assert resp2.request.method == 'PUT'
if __name__ == '__main__':
test_simple()
不匹配将报错 ConnectionError
import pytest
import requests
import responses
from requests.exceptions import ConnectionError
@responses.activate
def test_simple():
with pytest.raises(ConnectionError):
requests.get('http://twitter.com/api/1/foobar')
if __name__ == '__main__':
test_simple()
简写
支持多种 HTTP 方法:
delete()
get()
head()
options()
patch()
post()
put()
相当于 responses.add()
+ responses.Response
import requests
import responses
@responses.activate
def test_simple():
responses.get('http://twitter.com/api/1/foobar', json={'type': 'get'})
responses.post('http://twitter.com/api/1/foobar', json={'type': 'post'})
responses.patch('http://twitter.com/api/1/foobar', json={'type': 'patch'})
resp_get = requests.get('http://twitter.com/api/1/foobar')
resp_post = requests.post('http://twitter.com/api/1/foobar')
resp_patch = requests.patch('http://twitter.com/api/1/foobar')
assert resp_get.json() == {'type': 'get'}
assert resp_post.json() == {'type': 'post'}
assert resp_patch.json() == {'type': 'patch'}
上下文管理器
import requests
import responses
def test_my_api():
with responses.RequestsMock() as rsps:
rsps.get('http://twitter.com/api/1/foobar', body='{}', status=200, content_type='application/json')
resp = requests.get('http://twitter.com/api/1/foobar')
assert resp.status_code == 200
Response参数
method(str)
:HTTP 请求方法url(str or compiled regular expression)
:完整请求 URLquery_param_matcher
:请求匹配参数query_string_matcher
:请求匹配字符串body(str or BufferedReader or Exception)
:响应体json
:JSON 响应体,将自动配置 Content-Typestatus(int)
:状态码content_type(content_type)
:默认为 text/plainheaders(dict)
:响应头auto_calculate_content_length(bool)
:默认禁用,自动计算字符串或 JSON 长度match(tuple)
:一系列匹配规则- ``:
- ``:
- ``:
响应体异常
import pytest
import requests
import responses
@responses.activate
def test_simple():
responses.get('http://twitter.com/api/1/foobar', body=Exception('...'))
with pytest.raises(Exception):
requests.get('http://twitter.com/api/1/foobar')
匹配请求
匹配data参数
import requests
import responses
from responses import matchers
@responses.activate
def test_calc_api():
responses.post(
url='http://calc.com/sum',
body='4',
match=[matchers.urlencoded_params_matcher({'left': '1', 'right': '3'})],
)
response = requests.post('http://calc.com/sum', data={'left': 1, 'right': 3})
assert response.text == '4'
匹配json参数
import requests
import responses
from responses import matchers
@responses.activate
def test_calc_api():
responses.post(
url='http://example.com/',
body='one',
match=[matchers.json_params_matcher({'page': {'name': 'first', 'type': 'json'}})]
)
response = requests.post(
url='http://example.com/',
json={'page': {'name': 'first', 'type': 'json'}}
)
assert response.text == 'one'
匹配query参数
默认匹配所有参数,如果只验证出现过的参数,设置 strict_match=False
import requests
import responses
from responses import matchers
@responses.activate
def test_calc_api():
url = 'http://example.com/test'
params = {'hello': 'world', 'I am': 'a big test'}
responses.get(
url=url,
body='test',
match=[matchers.query_param_matcher(params)],
)
response = requests.get(url, params=params)
constructed_url = r'http://example.com/test?hello=world&I+am=a+big+test'
assert response.url == constructed_url
assert response.request.url == constructed_url
assert response.request.params == params
query 字符串
import requests
import responses
from responses import matchers
@responses.activate
def test_query():
responses.get(
url='https://httpbin.org/get',
body='test',
match=[matchers.query_string_matcher('didi=pro&test=1')],
)
response = requests.get(url='https://httpbin.org/get', params={'test': 1, 'didi': 'pro'})
assert response.text == 'test'
匹配关键字参数
支持 timeout
, verify
, proxies
, stream
, cert
import requests
import responses
from responses import matchers
def test_kwargs():
with responses.RequestsMock(assert_all_requests_are_fired=False) as response:
req_kwargs = {'stream': True, 'verify': False}
response.get(url='http://111.com', body='test', match=[matchers.request_kwargs_matcher(req_kwargs)])
response = requests.get(url='http://111.com', stream=True, verify=False)
assert response.text == 'test'
匹配multipart/form-data参数
import requests
import responses
from responses.matchers import multipart_matcher
@responses.activate
def test_multipart():
files = {'file_name': b'Old World!'}
data = {'some': 'other', 'data': 'fields'}
responses.post(
url='http://httpbin.org/post',
body='test',
match=[multipart_matcher(files=files, data=data)],
)
response = requests.post('http://httpbin.org/post', data=data, files=files)
assert response.text == 'test'
匹配片段标识符#
import requests
import responses
from responses.matchers import fragment_identifier_matcher
@responses.activate
def test_fragment():
responses.get(
url='http://example.com?ab=xy&zed=qwe#test=1&foo=bar',
body='test',
match=[fragment_identifier_matcher('test=1&foo=bar')],
)
response = requests.get('http://example.com?ab=xy&zed=qwe#test=1&foo=bar')
assert response.text == 'test'
response = requests.get('http://example.com?zed=qwe&ab=xy#foo=bar&test=1')
assert response.text == 'test'
匹配请求头Headers
import requests
import responses
from responses import matchers
@responses.activate
def test_content_type():
responses.get(
url='http://example.com/',
body='hello world',
match=[matchers.header_matcher({'Accept': 'text/plain'})],
)
responses.get(
url='http://example.com/',
json={'content': 'hello world'},
match=[matchers.header_matcher({'Accept': 'application/json'})],
)
response = requests.get('http://example.com/', headers={'Accept': 'application/json'})
assert response.json() == {'content': 'hello world'}
response = requests.get('http://example.com/', headers={'Accept': 'text/plain'})
assert response.text == 'hello world'
默认除了传递给匹配器的 Headers 外的请求 Headers 会被忽略,设置 strict_match=True
可以确保只校验期望的请求头
import pytest
import requests
import responses
from responses import matchers
@responses.activate
def test_content_type():
responses.get(
url='http://example.com/',
body='hello world',
match=[matchers.header_matcher({'Accept': 'text/plain'}, strict_match=True)],
)
with pytest.raises(ConnectionError):
requests.get('http://example.com/', headers={'Accept': 'text/plain'})
session = requests.Session()
prepped = session.prepare_request(
requests.Request(
method='GET',
url='http://example.com/',
)
)
prepped.headers = {'Accept': 'text/plain'}
response = session.send(prepped)
assert response.text == 'hello world'
不匹配将报错
注册响应
按顺序注册响应
import requests
import responses
from responses.registries import OrderedRegistry
@responses.activate(registry=OrderedRegistry)
def test_invocation_index():
responses.get(
'http://twitter.com/api/1/foobar',
json={'msg': 'not found'},
status=404,
)
responses.get(
'http://twitter.com/api/1/foobar',
json={'msg': 'OK'},
status=200,
)
responses.get(
'http://twitter.com/api/1/foobar',
json={'msg': 'OK'},
status=200,
)
responses.get(
'http://twitter.com/api/1/foobar',
json={'msg': 'not found'},
status=404,
)
response = requests.get('http://twitter.com/api/1/foobar')
assert response.status_code == 404
response = requests.get('http://twitter.com/api/1/foobar')
assert response.status_code == 200
response = requests.get('http://twitter.com/api/1/foobar')
assert response.status_code == 200
response = requests.get('http://twitter.com/api/1/foobar')
assert response.status_code == 404
自定义注册响应
import responses
from responses import registries
class CustomRegistry(registries.FirstMatchRegistry):
pass
print("测试前:", responses.mock.get_registry()) # <responses.registries.FirstMatchRegistry object>
@responses.activate(registry=CustomRegistry)
def run():
print("测试中:", responses.mock.get_registry()) # <__main__.CustomRegistry object>
run()
print("测试后:", responses.mock.get_registry()) # <responses.registries.FirstMatchRegistry object>
with responses.RequestsMock(registry=CustomRegistry) as rsps:
print("上下文管理器:", rsps.get_registry()) # <__main__.CustomRegistry object>
print("退出上下文管理器:", responses.mock.get_registry()) # <responses.registries.FirstMatchRegistry object>
动态响应
通过回调实现动态响应
import json
import requests
import responses
@responses.activate
def test_calc_api():
def callback(request):
payload = json.loads(request.body)
resp_body = {'value': sum(payload['numbers'])}
headers = {'request-id': '728d329e-0e86-11e4-a748-0c84dc037c13'}
return (200, headers, json.dumps(resp_body))
responses.add_callback(
responses.POST,
url='http://calc.com/sum',
callback=callback,
content_type='application/json',
)
resp = requests.post(
url='http://calc.com/sum',
data=json.dumps({'numbers': [1, 2, 3]}),
headers={'content-type': 'application/json'},
)
assert resp.json() == {'value': 6}
assert len(responses.calls) == 1
assert responses.calls[0].request.url == 'http://calc.com/sum'
assert responses.calls[0].response.text == '{"value": 6}'
assert responses.calls[0].response.headers['request-id'] == '728d329e-0e86-11e4-a748-0c84dc037c13'
可使用正则表达式
import re
import json
from functools import reduce
import requests
import responses
operators = {
'sum': lambda x, y: x + y,
'prod': lambda x, y: x * y,
'pow': lambda x, y: x ** y,
}
@responses.activate
def test_regex_url():
def request_callback(request):
payload = json.loads(request.body)
operator_name = request.path_url[1:]
operator = operators[operator_name]
resp_body = {'value': reduce(operator, payload['numbers'])}
headers = {'request-id': '728d329e-0e86-11e4-a748-0c84dc037c13'}
return (200, headers, json.dumps(resp_body))
responses.add_callback(
responses.POST,
re.compile('http://calc.com/(sum|prod|pow|unsupported)'),
callback=request_callback,
content_type='application/json',
)
resp = requests.post(
url='http://calc.com/prod',
data=json.dumps({'numbers': [2, 3, 4]}),
headers={'content-type': 'application/json'},
)
assert resp.json() == {'value': 24}
偏函数
import json
from functools import partial
import requests
import responses
@responses.activate
def test_calc_api():
def request_callback(request, id=None):
payload = json.loads(request.body)
resp_body = {'value': sum(payload['numbers'])}
headers = {'request-id': id}
return (200, headers, json.dumps(resp_body))
responses.add_callback(
responses.POST,
'http://calc.com/sum',
callback=partial(request_callback, id='728d329e-0e86-11e4-a748-0c84dc037c13'),
content_type='application/json',
)
resp = requests.post(
url='http://calc.com/sum',
data=json.dumps({'numbers': [1, 2, 3]}),
headers={'content-type': 'application/json'},
)
assert resp.json() == {'value': 6}
集成pytest
import pytest
import requests
import responses
@pytest.fixture
def mocked_responses():
with responses.RequestsMock() as rsps:
yield rsps
def test_api(mocked_responses):
mocked_responses.get(
url='http://twitter.com/api/1/foobar',
body='{}',
status=200,
content_type='application/json',
)
resp = requests.get('http://twitter.com/api/1/foobar')
assert resp.status_code == 200
为每个测试用例添加默认响应
import unittest
import requests
import responses
from responses import matchers
class TestMyApi(unittest.TestCase):
def setUp(self):
responses.get('https://example.com', body='within setup')
@responses.activate
def test_my_func(self):
responses.get(
url='https://httpbin.org/get',
match=[matchers.query_param_matcher({'test': '1', 'didi': 'pro'})],
body='within test',
)
resp = requests.get('https://example.com')
resp2 = requests.get('https://httpbin.org/get', params={'test': '1', 'didi': 'pro'})
assert resp.text == 'within setup'
assert resp2.text == 'within test'
请求模拟方法
responses
提供了非常类似于 unittest.mock.patch 的方法:start
, stop
, reset
import requests
import responses
class TestUnitTestPatchSetup:
def setup(self):
self.r_mock = responses.RequestsMock(assert_all_requests_are_fired=True)
self.r_mock.start()
self.r_mock.get('https://example.com', status=505)
self.r_mock.put('https://example.com', status=506)
def teardown(self):
self.r_mock.stop()
self.r_mock.reset()
def test_function(self):
resp = requests.get('https://example.com')
assert resp.status_code == 505
resp = requests.put('https://example.com')
assert resp.status_code == 506
响应的断言
当用作上下文管理器时,默认情况下,注册但未被访问,将引发断言错误
可以通过 assert_all_requests_are_fired=False
来禁用
import responses
def test_my_api():
with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
rsps.get(url='http://twitter.com/api/1/foobar', body='test')
断言请求次数
import requests
import responses
from responses import matchers
@responses.activate
def test_call_count_with_matcher():
rsp = responses.get(
'http://www.example.com',
match=(matchers.query_param_matcher({}),),
)
rsp2 = responses.get(
'http://www.example.com',
match=(matchers.query_param_matcher({'hello': 'world'}),),
status=777,
)
requests.get('http://www.example.com')
resp1 = requests.get('http://www.example.com')
requests.get('http://www.example.com?hello=world')
resp2 = requests.get('http://www.example.com?hello=world')
assert resp1.status_code == 200
assert resp2.status_code == 777
assert rsp.call_count == 2
assert rsp2.call_count == 2
根据 URL 判断
import pytest
import requests
import responses
@responses.activate
def test_assert_call_count():
responses.get('http://example.com')
requests.get('http://example.com')
assert responses.assert_call_count('http://example.com', 1) is True
requests.get('http://example.com')
with pytest.raises(AssertionError) as excinfo:
responses.assert_call_count('http://example.com', 1)
assert "Expected URL 'http://example.com' to be called 1 times. Called 2 times." in str(excinfo.value)
@responses.activate
def test_assert_call_count_always_match_qs():
responses.get('http://www.example.com')
requests.get('http://www.example.com')
requests.get('http://www.example.com?hello=world')
assert responses.assert_call_count('http://www.example.com', 1) is True
assert responses.assert_call_count('http://www.example.com?hello=world', 1) is True
多重响应
import requests
import responses
@responses.activate
def test_my_api():
responses.get('http://twitter.com/api/1/foobar', status=500)
responses.get(
url='http://twitter.com/api/1/foobar',
body='{}',
status=200,
content_type='application/json',
)
resp = requests.get('http://twitter.com/api/1/foobar')
assert resp.status_code == 500
resp = requests.get('http://twitter.com/api/1/foobar')
assert resp.status_code == 200
重定向
import pytest
import requests
import responses
@responses.activate
def test_redirect():
rsp1 = responses.get('http://example.com/1', status=301, headers={'Location': 'http://example.com/2'})
rsp2 = responses.get('http://example.com/2', status=301, headers={'Location': 'http://example.com/3'})
rsp3 = responses.get('http://example.com/3', status=200)
rsp = requests.get('http://example.com/1')
responses.calls.reset()
my_error = requests.ConnectionError('custom error')
my_error.response = rsp
rsp3.body = my_error
with pytest.raises(requests.ConnectionError) as exc_info:
requests.get('http://example.com/1')
assert exc_info.value.args[0] == 'custom error'
assert rsp1.url in exc_info.value.response.history[0].url
assert rsp2.url in exc_info.value.response.history[1].url
验证重试机制
import requests
import responses
from urllib3 import Retry
from responses import registries
@responses.activate(registry=registries.OrderedRegistry)
def test_max_retries():
url = 'https://example.com'
rsp1 = responses.get(url, body='Error', status=500)
rsp2 = responses.get(url, body='Error', status=500)
rsp3 = responses.get(url, body='Error', status=500)
rsp4 = responses.get(url, body='OK', status=200)
session = requests.Session()
adapter = requests.adapters.HTTPAdapter(
max_retries=Retry(
total=4,
backoff_factor=0.1,
status_forcelist=[500],
method_whitelist=['GET', 'POST', 'PATCH'],
)
)
session.mount('https://', adapter)
resp = session.get(url)
assert resp.status_code == 200
assert rsp1.call_count == 1
assert rsp2.call_count == 1
assert rsp3.call_count == 1
assert rsp4.call_count == 1
使用回调修改响应
import requests
import responses
def response_callback(resp):
resp.callback_processed = True
return resp
def test_callback_modify_response():
with responses.RequestsMock(response_callback=response_callback) as m:
m.add(responses.GET, 'http://example.com', body='test')
resp = requests.get('http://example.com')
assert resp.text == 'test'
assert hasattr(resp, 'callback_processed')
assert resp.callback_processed is True
真实传递
add_passthru()
支持正则表达式
import re
import responses
@responses.activate
def test_my_api():
responses.add_passthru('https://percy.io')
responses.add_passthru(re.compile('https://percy.io/\\w+'))
responses.get('http://example.com', body='not used', passthrough=True)
查看或修改已注册的响应
import requests
import responses
@responses.activate
def test_replace():
responses.get('http://example.org', json={'data': 1})
print(responses.registered())
responses.replace(responses.GET, 'http://example.org', json={'data': 2})
resp = requests.get('http://example.org')
assert resp.json() == {'data': 2}
协程和多线程
import requests
import responses
async def test_async_calls():
@responses.activate
async def run():
responses.get(
'http://twitter.com/api/1/foobar',
json={'error': 'not found'},
status=404,
)
resp = requests.get('http://twitter.com/api/1/foobar')
assert resp.json() == {'error': 'not found'}
assert responses.calls[0].request.url == 'http://twitter.com/api/1/foobar'
await run()
requests-mock
安装
pip install requests-mock
初试
main.py
from flask import Flask
app = Flask(__name__)
@app.route('/')
def index():
return 'Hello World!'
@app.route('/user/<name>')
def user(name):
return f'Hello {name}!'
if __name__ == '__main__':
app.run()
访问
模拟网络请求
import requests
import requests_mock
# 真实请求
print(requests.get('http://127.0.0.1:5000/').text) # Hello World!
print(requests.get('http://127.0.0.1:5000/user/a').text) # Hello a!
# 模拟网络请求
with requests_mock.Mocker() as m:
m.get('http://127.0.0.1:5000/', text='Hello Fake World!')
m.get('http://127.0.0.1:5000/user/a', text='Hello Fake a!')
print(requests.get('http://127.0.0.1:5000/').text) # Hello Fake World!
print(requests.get('http://127.0.0.1:5000/user/a').text) # Hello Fake a!
激活
使用 Mocker
拦截请求调用,并提供接近实际请求的接口,参数有:
real_http (bool)
:如果为True
将真实转发,默认为False
json_encoder (json.JSONEncoder)
:JSON 编码器session (requests.Session)
:指定 session
有两种方案加载适配器:
- 上下文管理器
import requests
import requests_mock
with requests_mock.Mocker() as m:
m.get('http://test.com', text='resp')
print(requests.get('http://test.com').text) # resp
- 装饰器
import requests
import requests_mock
@requests_mock.Mocker(kw='mock')
def test_kw_function(**kwargs):
kwargs['mock'].get('http://test.com', text='resp')
return requests.get('http://test.com').text
print(test_kw_function()) # resp
类装饰器
Mocker 可以装饰整个类
import requests
import requests_mock
requests_mock.Mocker.TEST_PREFIX = 'foo'
@requests_mock.Mocker()
class Thing(object):
def foo_one(self, m):
m.register_uri('GET', 'http://test.com', text='resp')
return requests.get('http://test.com').text
def foo_two(self, m):
m.register_uri('GET', 'http://test.com', text='resp')
return requests.get('http://test.com').text
print(Thing().foo_one()) # resp
print(Thing().foo_two()) # resp
方法
支持多种 HTTP 方法:
get()
post()
delete()
head()
options()
patch()
put()
真实HTTP请求
如果设置 real_http=True
,请求将转发到真实服务器
import requests
import requests_mock
with requests_mock.Mocker(real_http=True) as m:
m.register_uri('GET', 'http://test.com', text='resp')
print(requests.get('http://test.com').text) # resp
print(requests.get('http://www.google.com').status_code) # 200
with requests_mock.Mocker() as m:
m.register_uri('GET', 'http://test.com', text='resp')
m.register_uri('GET', 'http://www.google.com', real_http=True)
print(requests.get('http://test.com').text) # resp
print(requests.get('http://www.google.com').status_code) # 报错