├── .coveragerc ├── .github └── workflows │ ├── main.yml │ └── publish-to-pypi.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── poetry-1.0.3.checksum ├── pyproject.toml ├── pytest.ini ├── pytest_hoverfly ├── __init__.py ├── base.py ├── cert.pem ├── helpers.py └── pytest_hoverfly.py └── tests ├── __pycache__ ├── conftest.cpython-39-pytest-6.2.2.pyc └── test_pytest_hoverfly.cpython-39-pytest-6.2.2.pyc ├── conftest.py ├── simulations └── archive_org_simulation.json └── test_pytest_hoverfly.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = 4 | pytest_hoverfly 5 | 6 | [paths] 7 | source = 8 | pytest_hoverfly 9 | 10 | [report] 11 | show_missing = True 12 | skip_covered = True 13 | exclude_lines = 14 | pragma: no cover 15 | if t.TYPE_CHECKING: 16 | \.\.\. 17 | raise NotImplementedError 18 | def __repr__(self) -> str: 19 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | workflow_dispatch: 10 | 11 | jobs: 12 | lint-and-test: 13 | runs-on: ubuntu-latest 14 | 15 | services: 16 | hoverfly: 17 | image: spectolabs/hoverfly:v1.3.7 18 | ports: 19 | - 8500:8500 20 | - 8888:8888 21 | 22 | env: 23 | HOVERFLY_HOST: localhost 24 | HOVERFLY_PROXY_PORT: 8500 25 | HOVERFLY_ADMIN_PORT: 8888 26 | 27 | steps: 28 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 29 | - uses: actions/checkout@v2 30 | - uses: "actions/setup-python@v2" 31 | with: 32 | python-version: "3.7" 33 | - name: Install poetry 34 | run: make install_poetry 35 | - name: Install 36 | run: make prepare 37 | - name: Lint 38 | run: | 39 | . .venv/bin/activate 40 | make lint 41 | - name: Test 42 | run: | 43 | . .venv/bin/activate 44 | make test 45 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish pytest-hoverfly to PypI 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | publish-to-pypi: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | with: 12 | fetch-depth: 0 13 | - name: Set up Python 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: "3.7" 17 | - name: Install poetry 18 | run: make install_poetry 19 | - name: Install 20 | run: make prepare 21 | - name: Publish package to PyPI 22 | env: 23 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }} 24 | run: | 25 | . .venv/bin/activate 26 | make publish 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | .venv/ 12 | bin/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | eggs/ 17 | .eggs/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | .token.cache 26 | 27 | # Installer logs 28 | pip-log.txt 29 | pip-delete-this-directory.txt 30 | 31 | # Unit test / coverage reports 32 | htmlcov/ 33 | .tox/ 34 | .coverage 35 | .cache 36 | .mypy_cache 37 | nosetests.xml 38 | coverage.xml 39 | report.xml 40 | 41 | # Translations 42 | *.mo 43 | 44 | # Mr Developer 45 | .mr.developer.cfg 46 | .project 47 | .pydevproject 48 | 49 | # Rope 50 | .ropeproject 51 | 52 | # Django stuff: 53 | *.log 54 | *.pot 55 | 56 | # Sphinx documentation 57 | docs/_build/ 58 | 59 | .idea/ 60 | .vagrant/ 61 | .python-version 62 | config/ 63 | 64 | 65 | poetry.lock 66 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [5.0.4] - 2023-01-28 8 | ### Changed 9 | - Allow specifying hoverfly startup timeout 10 | 11 | ## [5.0.3] - 2022-08-31 12 | ### Changed 13 | - Bumped default Hoverfly image to 1.3.7 to support Apple silicon 14 | 15 | ## [5.0.2] - 2022-03-28 16 | ### Changed 17 | - Bumped default Hoverfly image to 1.3.6 18 | - Updated outdated certificate 19 | 20 | ## [5.0.1] - 2022-01-20 21 | ### Added 22 | - Allow specifying the service host via `SERVICE_HOST` env var 23 | 24 | ## [5.0.0] - 2022-01-17 25 | ### Changed 26 | - Allow specifying Docker API host via `DOCKER_HOST` env var 27 | - Remove the dependency on six 28 | - Bump minimal `docker` version to 5.0.3 29 | ### Fixed 30 | - Fix the situation when Hoverfly proxy wasn't ready to be used 31 | 32 | ## [4.0.2] - 2021-07-06 33 | ### Fixed 34 | - Fix specifying a different Hoverfly image 35 | ### Changed 36 | - Bumped default Hoverfly image to 1.3.2 37 | 38 | ## [4.0.2] - 2021-06-11 39 | ### Fixed 40 | - Wait until container's ports are available before considering it created 41 | - Fix installation by explicitly including six as a dependency 42 | 43 | ## [4.0.0] - 2021-03-03 44 | ### Changed 45 | - Remove dependency on Wrike's internal library 46 | - Rework fixture creation for simulation; no longer parse all simulation directory on startup 47 | 48 | ## [3.0.0] - 2021-02-14 49 | ### Changed 50 | - `hoverfly` fixture renamed to `hoverfly_instance` 51 | - Added `@hoverfly` decorator to specify a simulation to use. It replaces `simulation_recorder`s 52 | and directly specifying simulations as fixtures. See the updated readme for instructions. 53 | 54 | ## [2.1.0] - 2021-02-14 55 | ### Changed 56 | - `--hoverfly-simulation-path` can now be specified as a path relative to `pytest.ini` file 57 | 58 | ## [2.0.3] - 2020-12-30 59 | ### Fixed 60 | - Fix a bug when container wouldn't start because the host port we wanted to map onto was already in use 61 | 62 | ## [2.0.2] - 2020-09-14 63 | ### Fixed 64 | - This is a technical release to publish to PyPI 65 | 66 | ## [2.0.1] - 2020-09-14 67 | ### Added 68 | - Added ependency on wrike-pytest-containers 69 | 70 | ### Fixed 71 | - Fix working with docker>=4.3.1 by using wrike-pytest-containers 72 | 73 | ## [2.0.0] - 2020-05-30 74 | ### Changed 75 | - Use `docker` instead of `docker-py`. This is a breaking change, since it requires you 76 | to recreate venv (uninstalling docker-py and installing docker won't work). 77 | - Reuse code from wrike-pytest-containers 78 | 79 | ## [1.0.6] - 2020-04-22 80 | ### Changed 81 | - Updated README.md and docstrings 82 | 83 | ## [1.0.5] - 2020-03-29 84 | ### Changed 85 | - Bumped hoverfly to 1.1.5 to fix openssl issues on debian buster 86 | 87 | ## [1.0.4] - 2020-03-02 88 | ### Added 89 | - Added classifiers to pyproject.toml 90 | 91 | ## [1.0.3] - 2020-02-24 92 | ### Fixed 93 | - Fixed `simulation_recorder`s when path to simulations dir contains environment variables. 94 | 95 | ## [1.0.2] - 2020-02-24 96 | ### Changed 97 | - Add value of `hoverfly_simulation_path` option to error text if directory doesn't exist. 98 | 99 | ## [1.0.1] - 2020-02-11 100 | ### Fixed 101 | - Made test work by fiddling with openssl config in .gitlab-ci.yml 102 | 103 | ## [1.0.0] - 2020-02-11 104 | ### Added 105 | - Initial version. 106 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Wrike Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PYTHON_MODULE := pytest_hoverfly 2 | ENVIRONMENT := dev 3 | VENV_NAME := .venv 4 | PYTHON_BIN := python3 5 | POETRY_VERSION := 1.0.3 6 | POETRY_BIN := ${HOME}/.poetry/bin/poetry 7 | MAX_LINE_LENGTH := 120 8 | 9 | .PHONY: help test lint pyfmt prepare clean version name install_poetry 10 | help: 11 | @echo "Help" 12 | @echo "----" 13 | @echo 14 | @echo " prepare - create venv and install requirements" 15 | @echo " tests - run pytest" 16 | @echo " lint - run available linters" 17 | @echo " pyfmt - run available formatters" 18 | @echo " clean - clean directory from created files" 19 | @echo " version - print package version" 20 | @echo " name - print package name" 21 | @echo " install_poetry - install poetry" 22 | 23 | 24 | install_poetry: 25 | curl -sSL https://raw.githubusercontent.com/sdispater/poetry/${POETRY_VERSION}/get-poetry.py > get-poetry.py \ 26 | && cat get-poetry.py | sha256sum -c poetry-${POETRY_VERSION}.checksum \ 27 | && python get-poetry.py --version ${POETRY_VERSION} -y \ 28 | && rm get-poetry.py 29 | 30 | prepare: 31 | ${PYTHON_BIN} -m venv ${VENV_NAME} \ 32 | && . ${VENV_NAME}/bin/activate \ 33 | && (if [ "${ENVIRONMENT}" = "prod" ]; \ 34 | then ${POETRY_BIN} install --no-dev --no-root \ 35 | && ${POETRY_BIN} build --format=wheel \ 36 | && python -m pip install dist/*.whl; \ 37 | else ${POETRY_BIN} install; \ 38 | fi) 39 | 40 | test: 41 | python -m pytest tests/ --cov=${PYTHON_MODULE} 42 | 43 | lint: 44 | flake8 --max-line-length=120 ${PYTHON_MODULE} tests 45 | black -l ${MAX_LINE_LENGTH} --check ${PYTHON_MODULE} tests 46 | isort -l ${MAX_LINE_LENGTH} --check-only --diff --jobs 4 ${PYTHON_MODULE} tests 47 | 48 | pyfmt: 49 | black -l ${MAX_LINE_LENGTH} --quiet ${PYTHON_MODULE} tests 50 | isort -l ${MAX_LINE_LENGTH} ${PYTHON_MODULE} tests --jobs 4 51 | 52 | 53 | clean: 54 | rm -rf ${VENV_NAME} 55 | find . -name \*.pyc -delete 56 | 57 | version: 58 | @sed -n 's/.*version = "\(.*\)".*/\1/p' pyproject.toml 59 | 60 | name: 61 | @sed -n 's/.*name = "\(.*\)".*/\1/p' pyproject.toml 62 | 63 | publish: 64 | ${POETRY_BIN} publish --build 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI](https://github.com/wrike/pytest-hoverfly/actions/workflows/main.yml/badge.svg)](https://github.com/wrike/pytest-hoverfly/actions/workflows/main.yml) 2 | 3 | 4 | A helper for working with [Hoverfly](https://hoverfly.readthedocs.io/en/latest/) from `pytest`. Works both locally and in CI. 5 | 6 | ### Installation 7 | `pip install pytest-hoverfly` 8 | 9 | or 10 | 11 | `poetry add pytest-hoverfly --dev` 12 | 13 | 14 | ### Usage 15 | There are two use cases: to record a new test and to use recordings. 16 | 17 | #### Prerequisites 18 | You need to have [Docker](https://www.docker.com/) installed. `pytest-hoverfly` uses it under the hood to create Hoverfly instances. 19 | 20 | Create a directory to store simulation files. Pass `--hoverfly-simulation-path` option 21 | when calling `pytest`. The path may be absolute or relative to your `pytest.ini` file. 22 | E.g. if you have a structure like this: 23 | ``` 24 | ├── myproject 25 | ├── ... 26 | ├── pytest.ini 27 | └── tests 28 | ├── conftest.py 29 | ├── simulations 30 | ``` 31 | 32 | Then put this in you pytest.ini: 33 | ``` 34 | [pytest] 35 | addopts = 36 | --hoverfly-simulation-path=tests/simulations 37 | ``` 38 | 39 | #### Without Docker Desktop 40 | If you're using something like [lima](https://github.com/lima-vm/lima) instead of Docker Desktop, you need to specify a path to Docker API. For lima: 41 | 42 | `export DOCKER_HOST=unix:///Users//.lima/default/sock/docker.sock` 43 | 44 | If you're using [minikube](https://github.com/kubernetes/minikube) instead of Docker Desktop, you need to specify the service host because the exposed ports are not available on localhost. For minikube you get the service IP with `minikube ip` command and then put it in the env var: 45 | 46 | `export SERVICE_HOST=192.168.0.xxx` 47 | 48 | #### How to record a test 49 | ```python 50 | from pytest_hoverfly import hoverfly 51 | import requests 52 | 53 | 54 | @hoverfly('my-simulation-file', record=True) 55 | def test_google_with_hoverfly(): 56 | assert requests.get('https://google.com').status_code == 200 57 | ``` 58 | 59 | Write a test. Decorate it with `@hoverfly`, specifying a name of a file to save the simulation to. 60 | Run the test. A Hoverfly container will be created, and `HTTP_PROXY` and `HTTPS_PROXY` env vars 61 | will be set to point to this container. After test finishes, the resulting simulation will 62 | be exported from Hoverfly and saved to a file you specified. After test session ends, Hoverfly 63 | container will be destroyed (unless `--hoverfly-reuse-container` is passed to pytest). 64 | 65 | This will work for cases when a server always returns the same response for the same 66 | request. If you need to work with stateful endpoints (e.g. wait for Teamcity build 67 | to finish), use `@hoverfly('my-simulation, record=True, stateful=True)`. See 68 | [Hoverfly docs](https://docs.hoverfly.io/en/latest/pages/tutorials/basic/capturingsequences/capturingsequences.html) 69 | for details. 70 | 71 | #### How to use recordings 72 | Remove `record` parameter. That's it. When you run the test, it will create a container 73 | with Hoverfly, upload your simulation into it, and use it instead of a real service. 74 | 75 | ```python 76 | from pytest_hoverfly import hoverfly 77 | import requests 78 | 79 | 80 | @hoverfly('my-simulation-file') 81 | def test_google_with_hoverfly(): 82 | assert requests.get('https://google.com').status_code == 200 83 | ``` 84 | 85 | Caveat: if you're using an HTTP library other than `aiohttp` or `requests` you need to 86 | tell it to use Hoverfly as HTTP(S) proxy and to trust Hoverfly's certificate. See 87 | `_patch_env` fixture for details on how it's done for `aiohttp` and `requests`. 88 | 89 | #### How to re-record a test 90 | Add `record=True` again, and run the test. The simulation file will be overwritten. 91 | 92 | 93 | #### Change Hoverfly version 94 | To use a different Hoverfly version, specify `--hoverfly-image`. It must be a valid Docker image tag. 95 | 96 | #### Start Hoverfly with custom parameters 97 | Use `--hoverfly-args`. It is passed as is to a Hoverfly container. 98 | 99 | ### Usage in CI 100 | CI systems like Gitlab CI or Github Actions allow you to run arbitrary services as containers. `pytest-hoverfly` can detect if a Hoverfly instance is already running by looking at certain environment variables. If it detects a running instance, `pytest-hovefly` uses it, and doesn't create a new container. 101 | 102 | For Github Actions: 103 | 104 | ``` 105 | services: 106 | hoverfly: 107 | image: spectolabs/hoverfly:v1.3.2 108 | ports: 109 | - 8500:8500 110 | - 8888:8888 111 | 112 | env: 113 | HOVERFLY_HOST: localhost 114 | HOVERFLY_PROXY_PORT: 8500 115 | HOVERFLY_ADMIN_PORT: 8888 116 | ``` 117 | 118 | Mind that all three variables must be specified. 119 | -------------------------------------------------------------------------------- /poetry-1.0.3.checksum: -------------------------------------------------------------------------------- 1 | 40cf7b39a926acae9d1d0293983a508c58a6788554c6120a84e65d18ce044e51 - 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pytest_hoverfly" 3 | version = "5.0.4" 4 | description = "Simplify working with Hoverfly from pytest" 5 | authors = ["Devops team at Wrike "] 6 | repository = "https://github.com/wrike/pytest-hoverfly" 7 | license = "MIT" 8 | readme = "README.md" 9 | homepage = "https://github.com/wrike/pytest-hoverfly" 10 | keywords = ['hoverfly', 'pytest', 'tests'] 11 | exclude = ["tests", "*.tests"] 12 | classifiers = [ 13 | "Framework :: Pytest" 14 | ] 15 | 16 | [tool.poetry.dependencies] 17 | python = "^3.7" 18 | pytest = ">=5.0" 19 | requests = ">=2.22.0" 20 | docker = ">=5.0.3" 21 | typing_extensions = ">=3.7.4" 22 | 23 | [tool.poetry.dev-dependencies] 24 | flake8 = "^5.0.4" 25 | toml = ">=0.10" 26 | isort = "^5.11" 27 | pytest-cov = ">=2.7.1" 28 | black = "^22.8.0" 29 | 30 | [tool.poetry.plugins] 31 | [tool.poetry.plugins."pytest11"] 32 | "hoverfly" = "pytest_hoverfly.pytest_hoverfly" 33 | 34 | [build-system] 35 | requires = ["poetry>=1.0.3"] 36 | build-backend = "poetry.masonry.api" 37 | 38 | [tool.isort] 39 | line_length = 120 40 | multi_line_output = 3 41 | include_trailing_comma = true 42 | lines_after_imports = 2 43 | force_grid_wrap = 3 44 | forced_separate = ["pytest_hovefly"] 45 | add_imports = "from __future__ import annotations" 46 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = 3 | --disable-pytest-warnings 4 | --strict-markers 5 | --strict-config 6 | --pythonwarnings error 7 | --quiet 8 | --no-cov-on-fail 9 | -------------------------------------------------------------------------------- /pytest_hoverfly/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .pytest_hoverfly import hoverfly # noqa 4 | 5 | 6 | __all__ = (hoverfly,) 7 | -------------------------------------------------------------------------------- /pytest_hoverfly/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses as dc 4 | import os 5 | import socket 6 | import time 7 | import typing as t 8 | import urllib.error 9 | import urllib.request 10 | import uuid 11 | from http.client import RemoteDisconnected 12 | 13 | from docker import DockerClient 14 | from docker.errors import ImageNotFound 15 | from docker.models.containers import Container 16 | 17 | 18 | IMAGE = "spectolabs/hoverfly:v1.3.7" 19 | CONTAINER_BASENAME = "test-hoverfly" 20 | 21 | 22 | @dc.dataclass(frozen=True) 23 | class Hoverfly: 24 | host: str 25 | admin_port: int 26 | proxy_port: int 27 | 28 | @property 29 | def admin_endpoint(self) -> str: 30 | return f"http://{self.host}:{self.admin_port}/api/v2" 31 | 32 | @property 33 | def proxy_url(self) -> str: 34 | return f"http://{self.host}:{self.proxy_port}" 35 | 36 | @classmethod 37 | def from_container(cls, service_host: str, container: Container) -> Hoverfly: 38 | return Hoverfly( 39 | host=service_host, 40 | admin_port=int(container.ports["8888/tcp"][0]["HostPort"]), 41 | proxy_port=int(container.ports["8500/tcp"][0]["HostPort"]), 42 | ) 43 | 44 | @classmethod 45 | def try_from_env(cls, env: t.Mapping[str, str]) -> t.Optional[Hoverfly]: 46 | hoverfly_host = env.get("HOVERFLY_HOST") 47 | proxy_port = env.get("HOVERFLY_PROXY_PORT") 48 | admin_port = env.get("HOVERFLY_ADMIN_PORT") 49 | 50 | if hoverfly_host and proxy_port and admin_port: 51 | return Hoverfly(hoverfly_host, int(admin_port), int(proxy_port)) 52 | 53 | def is_ready(self) -> bool: 54 | return self.admin_endpoint_is_ready() and self.proxy_is_ready() 55 | 56 | def admin_endpoint_is_ready(self): 57 | try: 58 | urllib.request.urlopen(f"{self.admin_endpoint}/state") 59 | return True 60 | except (urllib.error.URLError, RemoteDisconnected): 61 | return False 62 | 63 | def proxy_is_ready(self): 64 | try: 65 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 66 | s.connect((self.host, self.proxy_port)) 67 | return True 68 | except ConnectionRefusedError: 69 | return False 70 | 71 | 72 | def get_container( 73 | container_name: t.Optional[str] = None, 74 | ports: t.Optional[t.Dict[str, t.Optional[t.List[t.Dict[str, int]]]]] = None, 75 | image: str = IMAGE, 76 | timeout: float = 3.0, 77 | docker_factory: t.Callable[[], DockerClient] = DockerClient.from_env, 78 | create_container_kwargs: t.Optional[t.Mapping[str, t.Any]] = None, 79 | ): 80 | external_service = Hoverfly.try_from_env(os.environ) 81 | if external_service: 82 | yield external_service 83 | return 84 | 85 | if not ports: 86 | ports = {"8500/tcp": None, "8888/tcp": None} 87 | 88 | if not container_name: 89 | container_name = f"{CONTAINER_BASENAME}-{uuid.uuid4().hex}" 90 | 91 | # DockerClient goes to docker API to fetch version during initialization 92 | # we instantiate it only here to avoid network calls if we don't need the client 93 | docker = docker_factory() 94 | 95 | try: 96 | docker.images.get(image) 97 | except ImageNotFound: 98 | docker.images.pull(image) 99 | 100 | raw_container = docker.containers.create( 101 | image=image, 102 | name=container_name, 103 | detach=True, 104 | ports=ports, 105 | **(create_container_kwargs or {}), 106 | ) 107 | 108 | raw_container.start() 109 | _wait_until_ports_are_ready(raw_container, ports, timeout) 110 | 111 | container = Hoverfly.from_container( 112 | os.environ.get("SERVICE_HOST", "localhost"), 113 | raw_container, 114 | ) 115 | 116 | try: 117 | _wait_until_ready(container, timeout) 118 | yield container 119 | finally: 120 | # we don't care about gracefull exit 121 | raw_container.kill(signal=9) 122 | raw_container.remove(v=True, force=True) 123 | 124 | 125 | def _wait_until_ready(container: Hoverfly, timeout: float) -> None: 126 | now = time.monotonic() 127 | delay = 0.001 128 | 129 | while (time.monotonic() - now) < timeout: 130 | if container.is_ready(): 131 | break 132 | else: 133 | time.sleep(delay) 134 | delay *= 2 135 | else: 136 | raise TimeoutError(f"Container for Hoverfly did not start in {timeout}s") 137 | 138 | 139 | def _wait_until_ports_are_ready(raw_container: Container, ports: t.Dict[str, t.Any], timeout: float) -> None: 140 | """Docker takes some time to allocate ports so they may not be immediately available.""" 141 | now = time.monotonic() 142 | delay = 0.001 143 | 144 | while (time.monotonic() - now) < timeout: 145 | raw_container.reload() 146 | # value of a port is either a None or an empty list when it's not ready 147 | ready = {k: v for k, v in raw_container.ports.items() if v} 148 | if set(ports).issubset(ready): 149 | break 150 | else: 151 | time.sleep(delay) 152 | delay *= 2 153 | else: 154 | raise TimeoutError(f"Docker failed to expose ports in {timeout}s") 155 | -------------------------------------------------------------------------------- /pytest_hoverfly/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDbTCCAlWgAwIBAgIVAPFUKC/hDKXSN4nF4Gh/fG7Oby4KMA0GCSqGSIb3DQEB 3 | CwUAMDYxGzAZBgNVBAoTEkhvdmVyZmx5IEF1dGhvcml0eTEXMBUGA1UEAxMOaG92 4 | ZXJmbHkucHJveHkwHhcNMjIwMzI3MjE0OTA4WhcNMzIwMzI0MjE0OTA4WjA2MRsw 5 | GQYDVQQKExJIb3ZlcmZseSBBdXRob3JpdHkxFzAVBgNVBAMTDmhvdmVyZmx5LnBy 6 | b3h5MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6mqE6O8H14tsul0B 7 | UGhLuxFYdSFsjtWtcR4v5PJDK118pdoYC3hgvejcQHdjzuYfVJybo2UHxhEyomhu 8 | r3KrpcjC0VnGfeibNXY01JDWMVxC2QgutGZb92/wChMBfOKYq5z4MhK+5gdiBkz2 9 | C8/1Q724sw14iIcQB+POY6lVBj3YI5Ja+hjSm6SWVvMVRk1uMgx2CcW6zbgErkNg 10 | xvDnDPHlRl5aIIHNyDMlSczVtq0SlBrTtExmjSg2Edzo1v25DG1LBzV58zYE5/cr 11 | Yh+Dm1XKB88sSBb8bUoAjZCWsl3Dkn8eR8wOdabZZxU/STP/g9yxTM1fcnc4v4e/ 12 | QF0TnQIDAQABo3IwcDAOBgNVHQ8BAf8EBAMCAqQwEwYDVR0lBAwwCgYIKwYBBQUH 13 | AwEwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUgrvEDpBhKH6SOUAC2fs8oTri 14 | 5XMwGQYDVR0RBBIwEIIOaG92ZXJmbHkucHJveHkwDQYJKoZIhvcNAQELBQADggEB 15 | AOXmvQtdsH4kBGAnMI87SlFbAskrbeY/Kqr1PQyDTt2MVj/SjpsVxNEoIsS5ghcI 16 | EyvhD/3t2q15D3XNc+wixSu8jCTe9N1CGXdiolfZ09SqiBtItvOh9R7pdkCcquh+ 17 | 69JJayOMInoSnmaf+ic+gbzLiEgfW+Dv/OR2Bmuelrs1zOnHdXhY45bN6PRQFrWt 18 | +Wkr7OqTfoCAz6NGgSWcKrXymTtErX7ZJGYwSc2+nHQznl7RBdyL2BfQAVWaWmhI 19 | s+IfxcKlYBr/nKWOkhD81VrNXFEj6R5kEYOdXYe9ovRmQhKWSz4cpCcMqtx1ye7K 20 | 1iBU2wVABfQZhp/3eiIyF9w= 21 | -----END CERTIFICATE----- -------------------------------------------------------------------------------- /pytest_hoverfly/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from pathlib import Path 5 | 6 | 7 | def extract_simulation_name_from_request(request): 8 | try: 9 | marker = [m for m in request.node.own_markers if m.name == "hoverfly"][0] 10 | except IndexError as e: 11 | raise RuntimeError("Test does not have Hoverfly marker") from e 12 | 13 | if marker.args: 14 | name = marker.args[0] 15 | else: 16 | name = marker.kwargs["name"] 17 | 18 | return name if ".json" in name else f"{name}.json" 19 | 20 | 21 | def get_simulations_path(config) -> Path: 22 | path = Path(os.path.expandvars(str(config.option.hoverfly_simulation_path))) 23 | if path.is_absolute(): 24 | return path 25 | 26 | return config.inipath.parent / path 27 | 28 | 29 | def del_header(pair, header: str): 30 | try: 31 | del pair["request"]["headers"][header] 32 | except KeyError: 33 | pass 34 | 35 | 36 | def del_gcloud_credentials(pair): 37 | if pair["request"]["destination"][0]["value"] == "oauth2.googleapis.com": 38 | if pair["request"]["path"][0]["value"] == "/token": 39 | del pair["request"]["body"] 40 | del_header(pair, "Content-Length") 41 | 42 | 43 | def ensure_simulation_dir(config) -> Path: 44 | path = get_simulations_path(config) 45 | if not path.exists(): 46 | raise ValueError(f"To use pytest-hoverfly you must specify --hoverfly-simulation-path. Current value: {path}") 47 | 48 | return path 49 | -------------------------------------------------------------------------------- /pytest_hoverfly/pytest_hoverfly.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import os 5 | import typing as t 6 | from pathlib import Path 7 | 8 | import pytest 9 | import requests 10 | import typing_extensions as te 11 | 12 | from .base import ( 13 | IMAGE, 14 | Hoverfly, 15 | get_container, 16 | ) 17 | from .helpers import ( 18 | del_gcloud_credentials, 19 | del_header, 20 | ensure_simulation_dir, 21 | extract_simulation_name_from_request, 22 | get_simulations_path, 23 | ) 24 | 25 | 26 | class HoverflyMarker(te.Protocol): 27 | def __call__( 28 | self, 29 | name: str, 30 | *, 31 | record: bool = False, 32 | stateful: bool = False, 33 | ) -> t.Callable[..., t.Any]: 34 | ... 35 | 36 | 37 | hoverfly: HoverflyMarker = pytest.mark.hoverfly 38 | 39 | 40 | def pytest_addoption(parser): 41 | parser.addoption( 42 | "--hoverfly-simulation-path", 43 | dest="hoverfly_simulation_path", 44 | help="Path to a directory with simulation files. Environment variables will be expanded.", 45 | type=Path, 46 | ) 47 | 48 | parser.addoption( 49 | "--hoverfly-image", 50 | dest="hoverfly_image", 51 | default=IMAGE, 52 | ) 53 | 54 | parser.addoption( 55 | "--hoverfly-cert", 56 | dest="hoverfly_cert", 57 | default=Path(__file__).parent / "cert.pem", 58 | help="Path to hoverfly SSL certificate. Needed for requests and aiohttp to trust hoverfly.", 59 | type=Path, 60 | ) 61 | 62 | parser.addoption( 63 | "--hoverfly-start-timeout", 64 | dest="hoverfly_start_timeout", 65 | default=3.0, 66 | help=( 67 | "Timeout used while starting the container. " 68 | "It's used two times: waiting for ports to start accepting connections " 69 | "and waiting for endpoints to start handling requests." 70 | ), 71 | type=float, 72 | ) 73 | 74 | parser.addoption( 75 | "--hoverfly-args", 76 | dest="hoverfly_args", 77 | help="Arguments for hoverfly command. Passed as is.", 78 | ) 79 | 80 | 81 | @pytest.hookimpl(tryfirst=True, hookwrapper=True) 82 | def pytest_runtest_makereport(item): 83 | """Add test result to request to make it available to fixtures. Used to print Hoverfly logs 84 | (if any) when tests fail. 85 | 86 | Copied from 87 | http://pytest.org/en/latest/example/simple.html#making-test-result-information-available-in-fixtures 88 | """ 89 | # execute all other hooks to obtain the report object 90 | outcome = yield 91 | rep = outcome.get_result() 92 | 93 | # set a report attribute for each phase of a call, which can 94 | # be "setup", "call", "teardown" 95 | 96 | setattr(item, "rep_" + rep.when, rep) 97 | 98 | 99 | @pytest.hookimpl(tryfirst=True) 100 | def pytest_configure(config): 101 | config.addinivalue_line("markers", "hoverfly(simulation): run Hoverfly with the specified simulation") 102 | 103 | 104 | @pytest.hookimpl(tryfirst=True) 105 | def pytest_runtest_setup(item): 106 | """Add replay or record fixtures to the test, based on @hoverfly decorator.""" 107 | marker = item.get_closest_marker(name="hoverfly") 108 | if not marker: 109 | return 110 | 111 | ensure_simulation_dir(item.config) 112 | 113 | stateful = marker.kwargs.pop("stateful", False) 114 | record = marker.kwargs.pop("record", False) 115 | 116 | if set(marker.kwargs) - {"name"}: 117 | raise RuntimeError(f"Unknown argments passed to @hoverfly: {marker.kwargs}") 118 | 119 | if record: 120 | item.fixturenames.append("_stateful_simulation_recorder" if stateful else "_simulation_recorder") 121 | else: 122 | item.fixturenames.append("_simulation_replayer") 123 | 124 | 125 | @pytest.fixture 126 | def _simulation_recorder(hoverfly_instance: Hoverfly, request, _patch_env): 127 | """Use to start Hoverfly and have it proxy-and-record all network requests. 128 | At the end of the test a `simulation.json` will appear in ${SIMULATIONS_DIR}. 129 | 130 | Don't use it with more than one test at a time, otherwise `simulation.json` 131 | will be overwritten. 132 | 133 | See README.md for details on how to use the generated file. 134 | """ 135 | yield from _recorder(hoverfly_instance, request, stateful=False) 136 | 137 | 138 | @pytest.fixture 139 | def _stateful_simulation_recorder(hoverfly_instance: Hoverfly, request, _patch_env): 140 | """Use this for stateful services, where response to the same request is not 141 | always the same. E.g. when you poll a service waiting for some job to finish. 142 | 143 | See also: 144 | https://docs.hoverfly.io/en/latest/pages/tutorials/basic/capturingsequences/capturingsequences.html 145 | """ 146 | yield from _recorder(hoverfly_instance, request, stateful=True) 147 | 148 | 149 | @pytest.fixture(scope="session") 150 | def hoverfly_instance(request) -> Hoverfly: 151 | """Returns Hoverfly's instance host and ports. 152 | 153 | Two modes are supported: 154 | 1. Externally managed instance. You provide connection details via 155 | environment variables, and nothing is done. 156 | Env vars: 157 | ${HOVERFLY_HOST} 158 | ${HOVERFLY_PROXY_PORT} 159 | ${HOVERFLY_ADMIN_PORT} 160 | 161 | 2. Instance managed by plugin. Container will be created and destroyed after. 162 | """ 163 | yield from get_container( 164 | create_container_kwargs={"command": request.config.option.hoverfly_args}, 165 | image=request.config.option.hoverfly_image, 166 | timeout=request.config.option.hoverfly_start_timeout, 167 | ) 168 | 169 | 170 | @pytest.fixture 171 | def _simulation_replayer(hoverfly_instance: Hoverfly, request, _patch_env): 172 | """Upload given simulation file to Hoverfly and set it to simulate mode. 173 | Clean up Hoverfly state at the end. If test failed and Hoverfly's last 174 | log record is an error, print it. Usually that error is the reason for 175 | test failure. 176 | """ 177 | # so that requests to hoverfly admin endpoint are not proxied :) 178 | session = requests.Session() 179 | session.trust_env = False 180 | 181 | filename = extract_simulation_name_from_request(request) 182 | 183 | # noinspection PyTypeChecker 184 | with open(get_simulations_path(request.config) / filename) as f: 185 | data = f.read() 186 | 187 | res = session.put(f"{hoverfly_instance.admin_endpoint}/simulation", data=data) 188 | res.raise_for_status() 189 | 190 | res = session.put(f"{hoverfly_instance.admin_endpoint}/hoverfly/mode", json={"mode": "simulate"}) 191 | res.raise_for_status() 192 | 193 | yield 194 | 195 | # see pytest_runtest_makereport 196 | if request.node.rep_setup.passed and request.node.rep_call.failed: 197 | resp = session.get(f"{hoverfly_instance.admin_endpoint}/logs") 198 | resp.raise_for_status() 199 | logs = resp.json()["logs"] 200 | last_log = logs[-1] 201 | if "error" in last_log: 202 | print("----------------------------") 203 | print("Hoverfly's log has an error!") 204 | print(last_log["error"]) 205 | 206 | r = session.delete(f"{hoverfly_instance.admin_endpoint}/simulation") 207 | r.raise_for_status() 208 | 209 | 210 | @pytest.fixture 211 | def _patch_env(request, hoverfly_instance: Hoverfly): 212 | os.environ["HTTP_PROXY"] = hoverfly_instance.proxy_url 213 | os.environ["HTTPS_PROXY"] = hoverfly_instance.proxy_url 214 | 215 | # So that aiohttp and requests trust hoverfly 216 | # Default cert is from 217 | # https://hoverfly.readthedocs.io/en/latest/pages/tutorials/basic/https/https.html 218 | path_to_cert = request.config.option.hoverfly_cert 219 | if not path_to_cert.exists(): 220 | raise ValueError(f"Cert file not found: {path_to_cert}") 221 | 222 | os.environ["SSL_CERT_FILE"] = str(path_to_cert) 223 | os.environ["REQUESTS_CA_BUNDLE"] = str(path_to_cert) 224 | 225 | yield 226 | 227 | del os.environ["HTTP_PROXY"] 228 | del os.environ["HTTPS_PROXY"] 229 | del os.environ["SSL_CERT_FILE"] 230 | del os.environ["REQUESTS_CA_BUNDLE"] 231 | 232 | 233 | def _recorder(hoverfly_instance: Hoverfly, request, stateful: bool): 234 | filename = extract_simulation_name_from_request(request) 235 | 236 | # so that requests to hoverfly admin endpoint are not proxied :) 237 | session = requests.Session() 238 | session.trust_env = False 239 | 240 | resp = session.put( 241 | f"{hoverfly_instance.admin_endpoint}/hoverfly/mode", 242 | json={ 243 | "mode": "capture", 244 | # capture all headers 245 | "arguments": {"headersWhitelist": ["*"], "stateful": stateful}, 246 | }, 247 | ) 248 | resp.raise_for_status() 249 | 250 | yield 251 | 252 | resp = session.get(f"{hoverfly_instance.admin_endpoint}/simulation") 253 | data = resp.json() 254 | 255 | # Delete common sensitive or excess data 256 | for pair in data["data"]["pairs"]: 257 | del_header(pair, "Authorization") 258 | del_header(pair, "User-Agent") 259 | del_header(pair, "X-Goog-Api-Client") 260 | del_header(pair, "Private-Token") 261 | del_gcloud_credentials(pair) 262 | 263 | # noinspection PyTypeChecker 264 | with open(get_simulations_path(request.config) / filename, "w+") as f: 265 | json.dump(data, f, indent=2) 266 | 267 | r = session.delete(f"{hoverfly_instance.admin_endpoint}/simulation") 268 | r.raise_for_status() 269 | -------------------------------------------------------------------------------- /tests/__pycache__/conftest.cpython-39-pytest-6.2.2.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wrike/pytest-hoverfly/d4da17fa3686979a724abf07b5540ff7dd93851c/tests/__pycache__/conftest.cpython-39-pytest-6.2.2.pyc -------------------------------------------------------------------------------- /tests/__pycache__/test_pytest_hoverfly.cpython-39-pytest-6.2.2.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wrike/pytest-hoverfly/d4da17fa3686979a724abf07b5540ff7dd93851c/tests/__pycache__/test_pytest_hoverfly.cpython-39-pytest-6.2.2.pyc -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | pytest_plugins = "pytester" 5 | -------------------------------------------------------------------------------- /tests/simulations/archive_org_simulation.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "pairs": [ 4 | { 5 | "request": { 6 | "path": [ 7 | { 8 | "matcher": "exact", 9 | "value": "/metadata/SPD-SLRSY-1867/metadata/identifier" 10 | } 11 | ], 12 | "method": [ 13 | { 14 | "matcher": "exact", 15 | "value": "GET" 16 | } 17 | ], 18 | "destination": [ 19 | { 20 | "matcher": "exact", 21 | "value": "archive.org" 22 | } 23 | ], 24 | "scheme": [ 25 | { 26 | "matcher": "exact", 27 | "value": "https" 28 | } 29 | ], 30 | "body": [ 31 | { 32 | "matcher": "exact", 33 | "value": "" 34 | } 35 | ], 36 | "headers": { 37 | "Accept": [ 38 | { 39 | "matcher": "exact", 40 | "value": "application/json" 41 | } 42 | ], 43 | "Connection": [ 44 | { 45 | "matcher": "exact", 46 | "value": "keep-alive" 47 | } 48 | ] 49 | } 50 | }, 51 | "response": { 52 | "status": 200, 53 | "body": "{\"result\":\"SPD-SLRSY-1867\"}", 54 | "encodedBody": false, 55 | "headers": { 56 | "Access-Control-Allow-Origin": [ 57 | "*" 58 | ], 59 | "Connection": [ 60 | "keep-alive" 61 | ], 62 | "Content-Type": [ 63 | "application/json" 64 | ], 65 | "Date": [ 66 | "Sun, 29 Jan 2023 14:47:47 GMT" 67 | ], 68 | "Hoverfly": [ 69 | "Was-Here" 70 | ], 71 | "Referrer-Policy": [ 72 | "no-referrer-when-downgrade" 73 | ], 74 | "Server": [ 75 | "nginx/1.18.0 (Ubuntu)" 76 | ], 77 | "Strict-Transport-Security": [ 78 | "max-age=15724800" 79 | ], 80 | "Vary": [ 81 | "Accept-Encoding" 82 | ] 83 | }, 84 | "templated": false 85 | } 86 | } 87 | ], 88 | "globalActions": { 89 | "delays": [], 90 | "delaysLogNormal": [] 91 | } 92 | }, 93 | "meta": { 94 | "schemaVersion": "v5.1", 95 | "hoverflyVersion": "v1.3.7", 96 | "timeExported": "2023-01-29T14:47:47Z" 97 | } 98 | } -------------------------------------------------------------------------------- /tests/test_pytest_hoverfly.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import os 5 | from pathlib import Path 6 | 7 | import pytest 8 | import requests 9 | 10 | 11 | CURDIR = Path(__file__).parent 12 | os.environ["__XXX_HOVERFLY_SIMULATION_PATH_XXX__"] = str(CURDIR / "simulations") 13 | 14 | 15 | @pytest.mark.parametrize( 16 | "simulation_path", 17 | ( 18 | str(Path(__file__).parent / "simulations"), 19 | # test that we handle the env vars expanding correctly 20 | "${__XXX_HOVERFLY_SIMULATION_PATH_XXX__}", 21 | ), 22 | ) 23 | def test_hoverfly_decorator(testdir, simulation_path): 24 | # create a temporary pytest test file 25 | testdir.makepyfile( 26 | """ 27 | import requests 28 | from pytest_hoverfly import hoverfly 29 | 30 | 31 | @hoverfly('archive_org_simulation') 32 | def test_simulation_replayer(): 33 | resp = requests.get( 34 | 'https://archive.org/metadata/SPD-SLRSY-1867/metadata/identifier', 35 | headers={'Accept': 'application/json'}, 36 | ) 37 | 38 | assert resp.json() == {"result": "SPD-SLRSY-1867"} 39 | 40 | # Hoverfly adds Hoverfly: Was-Here header 41 | assert 'Hoverfly' in resp.headers 42 | """ 43 | ) 44 | 45 | # run all tests with pytest 46 | result = testdir.runpytest_subprocess("--hoverfly-simulation-path", simulation_path, "-vv") 47 | 48 | result.assert_outcomes(passed=1) 49 | 50 | 51 | def test_hoverfly_decorator_name_kwarg(testdir): 52 | """Simulation name may be passed as a keyword argument.""" 53 | # create a temporary pytest test file 54 | testdir.makepyfile( 55 | """ 56 | import requests 57 | from pytest_hoverfly import hoverfly 58 | 59 | 60 | @hoverfly(name='archive_org_simulation') 61 | def test_simulation_replayer(): 62 | resp = requests.get( 63 | 'https://archive.org/metadata/SPD-SLRSY-1867/metadata/identifier', 64 | headers={'Accept': 'application/json'}, 65 | ) 66 | 67 | assert resp.json() == {"result": "SPD-SLRSY-1867"} 68 | 69 | # Hoverfly adds Hoverfly: Was-Here header 70 | assert 'Hoverfly' in resp.headers 71 | """ 72 | ) 73 | 74 | # run all tests with pytest 75 | result = testdir.runpytest_subprocess("--hoverfly-simulation-path", str(CURDIR / "simulations"), "-vv") 76 | 77 | result.assert_outcomes(passed=1) 78 | 79 | 80 | def test_hoverfly_decorator_unknown_argument(testdir): 81 | """Unknown arguments must raise an error.""" 82 | # create a temporary pytest test file 83 | testdir.makepyfile( 84 | """ 85 | from pytest_hoverfly import hoverfly 86 | 87 | 88 | @hoverfly(name='archive_org_simulation', doge='doge') 89 | def test_simulation_replayer(): 90 | ... 91 | """ 92 | ) 93 | 94 | # run all tests with pytest 95 | result = testdir.runpytest_subprocess("--hoverfly-simulation-path", str(CURDIR / "simulations"), "-vv") 96 | 97 | result.assert_outcomes(errors=1) 98 | 99 | 100 | def test_hoverfly_decorator_recorder(testdir, tmpdir): 101 | """This test hits a network!""" 102 | # create a temporary pytest test file 103 | testdir.makepyfile( 104 | """ 105 | import requests 106 | from pytest_hoverfly import hoverfly 107 | 108 | @hoverfly('archive_org_simulation', record=True) 109 | def test_stateful_simulation_recorder(): 110 | resp = requests.get( 111 | 'https://archive.org/metadata/SPD-SLRSY-1867/metadata/identifier', 112 | headers={ 113 | 'Accept': 'application/json', 114 | # If we do not add it, hoverlfy would save encoded body, which is harder to verify. 115 | 'Accept-Encoding': 'identity' 116 | }, 117 | ) 118 | 119 | assert resp.json() == {"result": "SPD-SLRSY-1867"} 120 | """ 121 | ) 122 | 123 | # run all tests with pytest 124 | result = testdir.runpytest_subprocess("--hoverfly-simulation-path", tmpdir, "-vv") 125 | 126 | result.assert_outcomes(passed=1) 127 | 128 | with open(tmpdir / "archive_org_simulation.json") as f: 129 | simulation = json.load(f) 130 | 131 | assert len(simulation["data"]["pairs"]) == 1 132 | assert "SPD-SLRSY-1867" in simulation["data"]["pairs"][0]["response"]["body"] 133 | 134 | 135 | def test_hoverfly_decorator_stateful_recorder(testdir, tmpdir): 136 | """This test hits a network!""" 137 | # create a temporary pytest test file 138 | testdir.makepyfile( 139 | """ 140 | import requests 141 | from pytest_hoverfly import hoverfly 142 | 143 | @hoverfly('archive_org_simulation', record=True, stateful=True) 144 | def test_stateful_simulation_recorder(): 145 | requests.get( 146 | 'https://archive.org/metadata/SPD-SLRSY-1867/metadata/identifier', 147 | headers={'Accept': 'application/json'}, 148 | 149 | ) 150 | 151 | resp = requests.get( 152 | 'https://archive.org/metadata/SPD-SLRSY-1867/metadata/identifier', 153 | headers={'Accept': 'application/json'}, 154 | ) 155 | 156 | assert resp.json() == {"result": "SPD-SLRSY-1867"} 157 | """ 158 | ) 159 | 160 | # run all tests with pytest 161 | result = testdir.runpytest_subprocess("--hoverfly-simulation-path", tmpdir, "-vv") 162 | 163 | result.assert_outcomes(passed=1) 164 | 165 | with open(Path(tmpdir) / "archive_org_simulation.json") as f: 166 | simulation = json.load(f) 167 | 168 | assert len(simulation["data"]["pairs"]) == 2 169 | 170 | 171 | def test_(_patch_env): 172 | """It's only purpose to invoke _patch_env fixture so that the following 173 | test may check whether there are unintended side effects. 174 | """ 175 | 176 | 177 | def test_lack_of_unintended_side_effects(): 178 | """If no hoverfly fixtures are used, requests should not use proxy. 179 | This test must be run after at least one hoverfly-using test has 180 | been run, so that session-scoped fixtures with potential side-effects 181 | are initialized. 182 | 183 | This test hits a network! 184 | """ 185 | resp = requests.get( 186 | "https://archive.org/metadata/SPD-SLRSY-1867/metadata/identifier", headers={"Accept": "application/json"} 187 | ) 188 | 189 | try: 190 | assert resp.json() == {"result": "SPD-SLRSY-1867"}, resp.text 191 | except json.decoder.JSONDecodeError: 192 | pytest.fail(resp.text + "\n\n(Request went to Hoverfly insted of archive.org)") 193 | 194 | # Hoverfly adds Hoverfly: Was-Here header 195 | assert "Hoverfly" not in resp.headers 196 | 197 | assert "HTTP_PROXY" not in os.environ 198 | assert "HTTPS_PROXY" not in os.environ 199 | assert "SSL_CERT_FILE" not in os.environ 200 | assert "REQUESTS_CA_BUNDLE" not in os.environ 201 | 202 | 203 | def test_timeout_option(testdir): 204 | """Test timeout option is parsed correctly.""" 205 | testdir.makepyfile( 206 | """ 207 | import requests 208 | from pytest_hoverfly import hoverfly 209 | 210 | 211 | @hoverfly(name='archive_org_simulation') 212 | def test_timeout_parsing(request): 213 | resp = requests.get( 214 | 'https://archive.org/metadata/SPD-SLRSY-1867/metadata/identifier', 215 | headers={'Accept': 'application/json'}, 216 | ) 217 | 218 | assert resp.json() == {"result": "SPD-SLRSY-1867"} 219 | 220 | # Hoverfly adds Hoverfly: Was-Here header 221 | assert 'Hoverfly' in resp.headers 222 | 223 | assert request.config.option.hoverfly_start_timeout == 55.0 224 | """ 225 | ) 226 | 227 | # run all tests with pytest 228 | result = testdir.runpytest_subprocess( 229 | "--hoverfly-simulation-path", str(CURDIR / "simulations"), "--hoverfly-start-timeout", "55", "-vv" 230 | ) 231 | 232 | result.assert_outcomes(passed=1) 233 | --------------------------------------------------------------------------------