├── .coveragerc ├── .github └── workflows │ └── python-package.yml ├── .gitignore ├── AUTHORS ├── CHANGELOG ├── CONTRIBUTING ├── INSTALL ├── LICENSE ├── MANIFEST.in ├── README.rst ├── responsemock ├── __init__.py ├── entry.py └── utils.py ├── setup.cfg ├── setup.py ├── tests └── test_module.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = responsemock/ 3 | 4 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: [3.7, 3.8, 3.9, "3.10"] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install deps 26 | run: | 27 | python -m pip install pytest coverage coveralls 28 | - name: Run tests 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.github_token }} 31 | run: | 32 | coverage run --source=responsemock setup.py test 33 | coveralls --service=github 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .project 2 | .pydevproject 3 | .idea 4 | .tox 5 | __pycache__ 6 | *.pyc 7 | *.pyo 8 | *.egg-info 9 | docs/_build/ 10 | 11 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | pytest-responsemock authors 2 | =========================== 3 | 4 | Created by Igor `idle sign` Starikov. 5 | 6 | 7 | Contributors 8 | ------------ 9 | 10 | Here could be your name. 11 | 12 | 13 | 14 | Translators 15 | ----------- 16 | 17 | Here could be your name. 18 | 19 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | pytest-responsemock changelog 2 | ============================= 3 | 4 | 5 | v1.1.1 [2022-03-10] 6 | ------------------- 7 | * Made responses 0.19.0 compatible. 8 | 9 | 10 | v1.1.0 [2022-02-04] 11 | ------------------- 12 | ! Dropped support for Python 3.6. 13 | 14 | 15 | v1.0.1 [2020-10-10] 16 | ------------------- 17 | * Handle setting 'Content-Type' header correctly. 18 | 19 | 20 | v1.0.0 [2020-07-26] 21 | ------------------- 22 | + Add support for binary responses. 23 | 24 | 25 | v0.2.0 [2020-03-31] 26 | ------------------- 27 | + Added support for response header fileds. 28 | 29 | 30 | v0.1.1 [2020-03-10] 31 | ------------------- 32 | * Plugin made friendly to shared virtual envs. 33 | 34 | 35 | v0.1.0 [2020-03-05] 36 | ------------------- 37 | + Basic functionality. -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | pytest-responsemock contributing 2 | ================================ 3 | 4 | 5 | Submit issues 6 | ------------- 7 | 8 | If you spotted something weird in application behavior or want to propose a feature you are welcome. 9 | 10 | 11 | Write code 12 | ---------- 13 | If you are eager to participate in application development and to work on an existing issue (whether it should 14 | be a bugfix or a feature implementation), fork, write code, and make a pull request right from the forked project page. 15 | 16 | 17 | Spread the word 18 | --------------- 19 | 20 | If you have some tips and tricks or any other words that you think might be of interest for the others — publish it 21 | wherever you find convenient. 22 | 23 | 24 | See also: https://github.com/idlesign/pytest-responsemock 25 | 26 | -------------------------------------------------------------------------------- /INSTALL: -------------------------------------------------------------------------------- 1 | pytest-responsemock installation 2 | ================================ 3 | 4 | 5 | Python ``pip`` package is required to install ``pytest-responsemock``. 6 | 7 | 8 | From sources 9 | ------------ 10 | 11 | Use the following command line to install ``pytest-responsemock`` from sources directory (containing setup.py): 12 | 13 | pip install . 14 | 15 | or 16 | 17 | python setup.py install 18 | 19 | 20 | From PyPI 21 | --------- 22 | 23 | Alternatively you can install ``pytest-responsemock`` from PyPI: 24 | 25 | pip install pytest-responsemock 26 | 27 | 28 | Use `-U` flag for upgrade: 29 | 30 | pip install -U pytest-responsemock 31 | 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020-2022, Igor `idle sign` Starikov 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the pytest-responsemock nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | 29 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include CHANGELOG 3 | include INSTALL 4 | include LICENSE 5 | include README.rst 6 | 7 | include docs/Makefile 8 | recursive-include docs *.rst 9 | recursive-include docs *.py 10 | recursive-include tests * 11 | 12 | recursive-exclude * __pycache__ 13 | recursive-exclude * *.py[co] 14 | recursive-exclude * empty 15 | 16 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pytest-responsemock 2 | =================== 3 | https://github.com/idlesign/pytest-responsemock 4 | 5 | |release| |lic| |coverage| 6 | 7 | .. |release| image:: https://img.shields.io/pypi/v/pytest-responsemock.svg 8 | :target: https://pypi.python.org/pypi/pytest-responsemock 9 | 10 | .. |lic| image:: https://img.shields.io/pypi/l/pytest-responsemock.svg 11 | :target: https://pypi.python.org/pypi/pytest-responsemock 12 | 13 | .. |coverage| image:: https://img.shields.io/coveralls/idlesign/pytest-responsemock/master.svg 14 | :target: https://coveralls.io/r/idlesign/pytest-responsemock 15 | 16 | 17 | Description 18 | ----------- 19 | 20 | *Simplified requests calls mocking for pytest* 21 | 22 | Provides ``response_mock`` fixture, exposing simple context manager. 23 | 24 | Any request under that manager will be intercepted and mocked according 25 | to one or more ``rules`` passed to the manager. If actual request won't fall 26 | under any of given rules then an exception is raised (by default). 27 | 28 | Rules are simple strings, of the pattern: ``HTTP_METHOD URL -> STATUS_CODE :BODY``. 29 | 30 | 31 | Requirements 32 | ------------ 33 | 34 | * Python 3.7+ 35 | 36 | 37 | Usage 38 | ----- 39 | 40 | When this package is installed ``response_mock`` is available for ``pytest`` test functions. 41 | 42 | .. code-block:: python 43 | 44 | def for_test(): 45 | return requests.get('http://some.domain') 46 | 47 | 48 | def test_me(response_mock): 49 | 50 | # Pass response rule as a string, 51 | # or many rules (to mock consequent requests) as a list of strings/bytes. 52 | # Use optional `bypass` argument to disable mock conditionally. 53 | 54 | with response_mock('GET http://some.domain -> 200 :Nice', bypass=False): 55 | 56 | result = for_test() 57 | 58 | assert result.ok 59 | assert result.content == b'Nice' 60 | 61 | # mock consequent requests 62 | with response_mock([ 63 | 'GET http://some.domain -> 200 :Nice', 64 | 'GET http://other.domain -> 200 :Sweet', 65 | ]): 66 | for_test() 67 | requests.get('http://other.domain') 68 | 69 | 70 | Use with ``pytest-datafixtures``: 71 | 72 | .. code-block:: python 73 | 74 | def test_me(response_mock, datafix_read): 75 | 76 | with response_mock(f"GET http://some.domain -> 200 :{datafix_read('myresponse.html')}"): 77 | ... 78 | 79 | 80 | Describe response header fields using multiline strings: 81 | 82 | .. code-block:: python 83 | 84 | with response_mock( 85 | ''' 86 | GET http://some.domain 87 | 88 | Allow: GET, HEAD 89 | Content-Language: ru 90 | 91 | -> 200 :OK 92 | ''' 93 | ): 94 | ... 95 | 96 | Test json response: 97 | 98 | .. code-block:: python 99 | 100 | response = json.dumps({'key': 'value', 'another': 'yes'}) 101 | 102 | with response_mock(f'POST http://some.domain -> 400 :{response}'): 103 | ... 104 | 105 | To test binary response pass rule as bytes: 106 | 107 | .. code-block:: python 108 | 109 | with response_mock(b'GET http://some.domain -> 200 :' + my_bytes): 110 | ... 111 | 112 | Access underlying RequestsMock (from ``responses`` package) as ``mock``: 113 | 114 | .. code-block:: python 115 | 116 | with response_mock('HEAD http://some.domain -> 200 :Nope') as mock: 117 | 118 | mock.add_passthru('http://other.domain') 119 | 120 | -------------------------------------------------------------------------------- /responsemock/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | VERSION = (1, 1, 1) 4 | """Application version number tuple.""" 5 | 6 | VERSION_STR = '.'.join(map(str, VERSION)) 7 | """Application version number string.""" -------------------------------------------------------------------------------- /responsemock/entry.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from .utils import response_mock as response_mock_ 4 | 5 | 6 | @pytest.fixture 7 | def response_mock() -> response_mock_: 8 | """Fixture exposing simple context manager to mock responses for `requests` package. 9 | 10 | Any request under that manager will be intercepted and mocked according 11 | to one or more ``rules`` passed to the manager. If actual request won't fall 12 | under any of given rules then an exception is raised (by default). 13 | 14 | Rules are simple strings, of pattern: ``HTTP_METHOD URL -> STATUS_CODE :BODY``. 15 | 16 | Example:: 17 | 18 | def test_me(response_mock): 19 | 20 | 21 | json_response = json.dumps({'key': 'value', 'another': 'yes'}) 22 | 23 | with response_mock([ 24 | 25 | 'GET http://a.b -> 200 :Nice', 26 | 27 | f'POST http://some.domain -> 400 :{json_response}', 28 | 29 | ''' 30 | GET https://some.domain 31 | 32 | Allow: GET, HEAD 33 | Content-Language: ru 34 | 35 | -> 200 :OK 36 | ''', 37 | 38 | ], bypass=False) as mock: 39 | 40 | mock.add_passthru('http://c.d') 41 | 42 | this_mades_requests() 43 | 44 | 45 | """ 46 | return response_mock_ 47 | -------------------------------------------------------------------------------- /responsemock/utils.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from textwrap import dedent 3 | from typing import Union, List, Generator, Optional 4 | 5 | if False: # pragma: nocover 6 | from responses import RequestsMock # noqa 7 | 8 | 9 | @contextmanager 10 | def response_mock( 11 | rules: Union[List[str], str, List[bytes], bytes], 12 | *, 13 | bypass: bool = False, 14 | **kwargs 15 | ) -> Generator[Optional['RequestsMock'], None, None]: 16 | """Simple context manager to mock responses for `requests` package. 17 | 18 | Any request under that manager will be intercepted and mocked according 19 | to one or more ``rules`` passed to the manager. If actual request won't fall 20 | under any of given rules then an exception is raised (by default). 21 | 22 | Rules are simple strings, of pattern: ``HTTP_METHOD URL -> STATUS_CODE :BODY``. 23 | 24 | Example:: 25 | 26 | def test_me(response_mock): 27 | 28 | 29 | json_response = json.dumps({'key': 'value', 'another': 'yes'}) 30 | 31 | with response_mock([ 32 | 33 | 'GET http://a.b -> 200 :Nice', 34 | 35 | b'GET http://a.b/binary/ -> 200 :\xd1\x82\xd0\xb5\xd1\x81\xd1\x82', 36 | 37 | f'POST http://some.domain -> 400 :{json_response}' 38 | 39 | ''' 40 | GET https://some.domain 41 | 42 | Allow: GET, HEAD 43 | Content-Language: ru 44 | 45 | -> 200 :OK 46 | ''' 47 | 48 | ], bypass=False) as mock: 49 | 50 | mock.add_passthru('http://c.d') 51 | 52 | this_makes_requests() 53 | 54 | :param rules: One or several rules for response. 55 | :param bypass: Whether to bypass (disable) mocking. 56 | :param kwargs: Additional keyword arguments to pass to `RequestsMock`. 57 | 58 | """ 59 | from responses import RequestsMock 60 | 61 | if bypass: 62 | 63 | yield 64 | 65 | else: 66 | 67 | with RequestsMock(**kwargs) as mock: 68 | 69 | def enc(val): 70 | return val.encode() if is_binary else val 71 | 72 | def dec(val): 73 | return val.decode() if is_binary else val 74 | 75 | if isinstance(rules, (str, bytes)): 76 | rules = [rules] 77 | 78 | for rule in rules: 79 | 80 | if not rule: 81 | continue 82 | 83 | is_binary = isinstance(rule, bytes) 84 | 85 | if not is_binary: 86 | rule = dedent(rule) 87 | 88 | rule = rule.strip() 89 | directives, _, response = rule.partition(enc('->')) 90 | 91 | headers = {} 92 | 93 | if (enc('\n')) in directives: 94 | directives, *headers_block = directives.splitlines() 95 | 96 | for header_line in headers_block: 97 | header_line = header_line.strip() 98 | 99 | if not header_line: 100 | continue 101 | 102 | key, _, val = header_line.partition(enc(':')) 103 | val = val.strip() 104 | 105 | if val: 106 | headers[dec(key.strip())] = dec(val) 107 | 108 | add_kwargs = {} 109 | 110 | content_type = headers.pop('Content-Type', None) 111 | 112 | if content_type: 113 | add_kwargs['content_type'] = content_type 114 | 115 | directives = list( 116 | filter( 117 | None, 118 | map( 119 | (bytes if is_binary else str).strip, 120 | directives.split(enc(' ')) 121 | ) 122 | )) 123 | 124 | if len(directives) != 2: 125 | raise ValueError(f'Unsupported directives: {directives}. Expected two entries: HTTP_METHOD and URL') 126 | 127 | status, _, response = response.partition(enc(':')) 128 | 129 | status = int(status.strip()) 130 | 131 | mock.add( 132 | method=dec(directives[0]), 133 | url=dec(directives[1]), 134 | body=response, 135 | status=status, 136 | adding_headers=headers or None, 137 | **add_kwargs 138 | ) 139 | 140 | yield mock 141 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | release = clean --all sdist bdist_wheel upload 3 | 4 | test = pytest 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import re 4 | 5 | from setuptools import setup, find_packages 6 | 7 | import sys 8 | 9 | PATH_BASE = os.path.dirname(__file__) 10 | 11 | 12 | def read_file(fpath): 13 | """Reads a file within package directories.""" 14 | with io.open(os.path.join(PATH_BASE, fpath)) as f: 15 | return f.read() 16 | 17 | 18 | def get_version(): 19 | """Returns version number, without module import (which can lead to ImportError 20 | if some dependencies are unavailable before install.""" 21 | contents = read_file(os.path.join('responsemock', '__init__.py')) 22 | version = re.search('VERSION = \(([^)]+)\)', contents) 23 | version = version.group(1).replace(', ', '.').strip() 24 | return version 25 | 26 | 27 | setup( 28 | name='pytest-responsemock', 29 | version=get_version(), 30 | url='https://github.com/idlesign/pytest-responsemock', 31 | 32 | description='Simplified requests calls mocking for pytest', 33 | long_description=read_file('README.rst'), 34 | license='BSD 3-Clause License', 35 | 36 | author='Igor `idle sign` Starikov', 37 | author_email='idlesign@yandex.ru', 38 | 39 | packages=find_packages(exclude=['tests']), 40 | include_package_data=True, 41 | zip_safe=False, 42 | 43 | install_requires=[ 44 | 'pytest', 45 | 'responses>=0.18.0', 46 | ], 47 | setup_requires=[] + (['pytest-runner'] if 'test' in sys.argv else []) + [], 48 | 49 | python_requires=">=3.7", 50 | 51 | entry_points={ 52 | 'pytest11': ['responsemock = responsemock.entry'], 53 | }, 54 | 55 | test_suite='tests', 56 | 57 | tests_require=[ 58 | 'pytest', 59 | 'requests', 60 | ], 61 | 62 | classifiers=[ 63 | # As in https://pypi.python.org/pypi?:action=list_classifiers 64 | 'Development Status :: 5 - Production/Stable', 65 | 'Operating System :: OS Independent', 66 | 'Programming Language :: Python', 67 | 'Programming Language :: Python :: 3', 68 | 'Programming Language :: Python :: 3.7', 69 | 'Programming Language :: Python :: 3.8', 70 | 'Programming Language :: Python :: 3.9', 71 | 'Programming Language :: Python :: 3.10', 72 | 'License :: OSI Approved :: BSD License', 73 | 'Framework :: Pytest', 74 | 'Intended Audience :: Developers', 75 | 'Topic :: Software Development :: Testing', 76 | ], 77 | ) 78 | 79 | 80 | -------------------------------------------------------------------------------- /tests/test_module.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | def run_get(): 5 | response = requests.get('http://yandex.ru', allow_redirects=False) 6 | return response 7 | 8 | 9 | def test_oneline(response_mock): 10 | 11 | with response_mock('GET http://yandex.ru -> 200 :Nice'): 12 | result = run_get() 13 | assert result.ok 14 | assert result.content == b'Nice' 15 | 16 | 17 | def test_binary(response_mock): 18 | 19 | with response_mock( 20 | b'GET http://yandex.ru \n' 21 | b'Allow: GET, HEAD\n' 22 | b'Content-Language: ru\n' 23 | b'-> 200 :\xd1\x82\xd0\xb5\xd1\x81\xd1\x82' 24 | ): 25 | result = requests.get('http://yandex.ru', allow_redirects=False) 26 | assert result.ok 27 | assert result.content.decode() == 'тест' 28 | assert result.headers['Content-Language'] == 'ru' 29 | 30 | 31 | def test_header_fields(response_mock): 32 | 33 | with response_mock(''' 34 | GET http://yandex.ru 35 | 36 | Content-Type: image/png 37 | Cache-Control: no-cache,no-store,max-age=0,must-revalidate 38 | Set-Cookie: key1=val1 39 | 40 | -> 200 :Nicer 41 | '''): 42 | result = run_get() 43 | assert result.ok 44 | assert result.content == b'Nicer' 45 | assert dict(result.headers) == { 46 | 'Content-Type': 'image/png', 47 | 'Cache-Control': 'no-cache,no-store,max-age=0,must-revalidate', 48 | 'Set-Cookie': 'key1=val1' 49 | } 50 | assert dict(result.cookies) == {'key1': 'val1'} 51 | 52 | 53 | def test_many_lines(response_mock): 54 | 55 | with response_mock([ 56 | 'GET http://yandex.ru -> 200 :Nice', 57 | '', # Support empty rule for debug actual request in tests. 58 | ]): 59 | result = run_get() 60 | assert result.content == b'Nice' 61 | 62 | 63 | def test_status(response_mock): 64 | 65 | with response_mock('GET http://yandex.ru -> 500 :Bad'): 66 | result = run_get() 67 | assert not result.ok 68 | assert result.content == b'Bad' 69 | 70 | 71 | def test_bypass(response_mock): 72 | 73 | with response_mock('GET http://yandex.ru -> 500 :Nice', bypass=True): 74 | result = run_get() 75 | assert result.status_code in {301, 302} # https redirect 76 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # See http://tox.readthedocs.org/en/latest/examples.html for samples. 2 | [tox] 3 | envlist = 4 | py{37,38,39,310} 5 | 6 | skip_missing_interpreters = True 7 | 8 | install_command = pip install {opts} {packages} 9 | 10 | [testenv] 11 | commands = 12 | python setup.py test 13 | 14 | deps = 15 | 16 | --------------------------------------------------------------------------------