Python模拟网络请求库responses和requests-mock

简介

Stars备注
responses3.8 k需要Python 3.7+
requests-mock355
HTTPretty2 k
httmock452

统计截止 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):完整请求 URL
  • query_param_matcher:请求匹配参数
  • query_string_matcher:请求匹配字符串
  • body(str or BufferedReader or Exception):响应体
  • json:JSON 响应体,将自动配置 Content-Type
  • status(int):状态码
  • content_type(content_type):默认为 text/plain
  • headers(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

有两种方案加载适配器:

  1. 上下文管理器
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
  1. 装饰器
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)  # 报错




pytest




参考文献

  1. requests-mock Documentation
  2. requests-mock GitHub
  3. responses GitHub
  4. HTTPretty GitHub
  5. httmock GitHub
  6. Mock Documentation
  7. Mock GitHub
  8. How to mock requests using pytest?
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

XerCis

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值