Library / SDK
kiwicom/pytest-recording avatar
kiwicom/pytest-recording

pytest-recording, replaying HTTP traffic in pytest instead of hitting the network

A pytest plugin powered by VCR.py to record and replay HTTP traffic.

617 stars52 forksPythonMIT

At a glance

What is it?
pytest-recording is a pytest plugin on top of VCR.py that turns a network call into a cassette on disk, and by default refuses to make network requests at all so a test suite cannot quietly depend on a live service. It also adds network blocking, a rewrite mode and per-agent configuration merging.
Who is it for?
pytest-recording suits a Python team whose tests talk to HTTP services and want the suite to run offline and reproducibly from checked-in cassettes. It does not suit a team that needs the real service in the loop for every run, and it does not suit a project still on Python 3.9, since the plugin requires 3.10 or newer.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 8 days ago.
What is it written in?
Mainly Python, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

A pytest plugin that sits on top of VCR.py

pytest-recording is a pytest plugin powered by VCR.py to record and replay HTTP traffic. That relationship defines both its capability and its limits, because the plugin does not implement a recording layer of its own, it exposes VCR.py through pytest's own vocabulary.

The feature list is short: a straightforward `pytest.mark.vcr` that reflects the `VCR.use_cassettes` API, combining multiple VCR cassettes, network access blocking, and a `rewrite` recording mode that rewrites cassettes from scratch.

The `pytest.mark.vcr` design is what makes it feel like a pytest tool rather than a VCR wrapper. The mark takes cassette paths as arguments, so a test that needs more than one recorded exchange simply lists them, and the default cassette is derived from the module and test name when nothing is given.

python
    import pytest
    import requests

    # cassettes/{module_name}/test_single.yaml will be used
    @pytest.mark.vcr
    def test_single():
        assert requests.get("http://httpbin.org/get").text == '{"get": true}'

    # cassettes/{module_name}/example.yaml will be used
    @pytest.mark.default_cassette("example.yaml")
    @pytest.mark.vcr
    def test_default():
        assert requests.get("http://httpbin.org/get").text == '{"get": true}'

There is a `default_cassette` mark for the case where the cassette name does not follow the module and test name convention, and extra cassettes can be added in addition to the default one.

python
    # these cassettes will be used in addition to the default one
    @pytest.mark.vcr("/path/to/ip.yaml", "/path/to/get.yaml")
    def test_multiple():
        assert requests.get("http://httpbin.org/get").text == '{"get": true}'
        assert requests.get("http://httpbin.org/ip").text == '{"ip": true}'

The audience is a Python developer with a test suite that talks to HTTP services and wants those tests to be fast, deterministic and runnable without network access. The plugin is published to PyPI as `pytest-recording`, requires Python 3.10 or newer, and depends on `vcrpy>=2.0.1` and `pytest>=3.5.0`, with the project describing itself as a development status of beta.

The default record mode is none, and that is the point

The most important design decision in the plugin is its default, and it is the opposite of what most people expect from a recording tool.

pytest-recording uses the `none` VCR recording mode by default, and the reason given is to prevent unintentional network requests. To allow them you need to pass a different recording mode, such as `once`, via the `--record-mode` CLI option on the test command.

bash
    pytest --record-mode=once test_network.py

Read that as a policy, not a limitation. If a cassette exists, the test replays it and never touches the network. If a cassette is missing, the test fails rather than silently calling the live service. That is the behaviour you want in CI, where a suite that starts passing or failing based on a third party's uptime is worse than useless, and it is the behaviour you want locally once the cassettes are committed.

The cost of that choice is that the first run of a new test needs an explicit opt-in. A developer adding a test for a new endpoint runs the suite with `--record-mode=once`, the cassette is written, and from then on the test is offline. The `once` mode itself means record once and replay thereafter, so a second run with the same flag does not overwrite the cassette.

The plugin also adds a `rewrite` mode that the documentation explains is not the same as VCR.py's own `all` mode. It is possible to rewrite a cassette from scratch and not extend it with new entries as it works now with the `all` record mode from VCR.py. So `all` appends new interactions to an existing cassette, and `rewrite` replaces the file. The limitation is stated plainly: it will rewrite only the default cassette and won't touch extra cassettes.

python
    import pytest

    @pytest.fixture(scope="module")
    def vcr_config():
        return {"record_mode": "rewrite"}
bash
    $ pytest --record-mode=rewrite tests/

The rewrite mode is the tool for the moment when a service changes its response shape and every recorded interaction in the cassette is now wrong, and for the case where appending to the old file would leave a mix of old and new responses in one cassette.

Configuration merges from broad scope to narrow scope

The configuration model is a fixture plus marks, and the merge order is documented precisely enough that you can predict the result.

The `vcr_config` fixture provides the recording configuration, and it can be any scope: session, package, module or function. It should return a dictionary that will be passed directly to `VCR.use_cassettes` under the hood. The usual use is filtering sensitive headers.

python
    import pytest

    @pytest.fixture(scope="module")
    def vcr_config():
        return {"filter_headers": ["authorization"]}

For finer control, keyword arguments go on the individual `pytest.mark.vcr` marks, and in that case all arguments are merged into a single dictionary with a stated priority from low to high: the `vcr_config` fixture first, then all marks from the most broad scope, session, to the most narrow one, function.

The documentation works this through with a module-level mark and a fixture together.

python
    import pytest

    pytestmark = [pytest.mark.vcr(ignore_localhost=True)]

    @pytest.fixture(scope="module")
    def vcr_config():
        return {"filter_headers": ["authorization"]}

    @pytest.mark.vcr(filter_headers=[])
    def test_one():
        ...

    @pytest.mark.vcr(filter_query_parameters=["api_key"])
    def test_two():
        ...

The results are given explicitly. `test_one` ends up with `{"ignore_localhost": True, "filter_headers": []}`, because the mark's empty list overrides the fixture's list, and `test_two` ends up with `{"ignore_localhost": True, "filter_headers": ["authorization"], "filter_query_parameters": ["api_key"]}`.

That last example is the one to understand carefully. An empty `filter_headers` on a mark removes the fixture's filter, which means the Authorization header is recorded in that cassette. If your fixture exists to keep a token out of a file on disk, a mark that clears the filter is a way to leak it, and it is an easy thing to do by accident.

Because the fixture forwards any keyword argument to VCR.py, options like `ignore_hosts` work directly.

python
    import pytest

    @pytest.fixture(scope="module")
    def vcr_config():
        return {
            "filter_headers": ["authorization"],
            "ignore_hosts": ["169.254.169.254", "metadata.google.internal"],
        }

Those two hosts in the example are the cloud metadata addresses, which is a sensible thing to ignore in a test suite running in a cloud environment.

Blocking the network so a test cannot reach out by accident

The network blocking feature is the part of the plugin that goes beyond VCR.py, and it addresses a failure mode that is easy to underestimate. A test that intends to use a cassette but has a bug in its setup can fall through to the real network, and then the test passes on your machine and fails in CI, or worse, passes in CI and depends on a service nobody agreed to depend on.

The per-test mark is `pytest.mark.block_network`.

python
    import pytest
    import requests

    @pytest.mark.block_network
    def test_multiple():
        assert requests.get("http://httpbin.org/get").text == '{"get": true}'

    ...
    # in case of access
    RuntimeError: Network is disabled

The failure is a `RuntimeError` with the message Network is disabled, so the test fails loudly and for a reason that names the cause.

There is also a global form, the `--block-network` command-line option, and an important interaction with VCR recording. If VCR.py recording is enabled, the network is not blocked for tests with `pytest.mark.vcr`, because the point of those tests is to record in the first place. The two features coexist.

bash
    $ pytest --record-mode=once --block-network tests/

The blocking supports socket-based transports and pycurl, which is a narrower guarantee than it sounds: a library that uses a different transport, a subprocess, or a native extension with its own networking would not be caught.

Hosts can be allowed during blocking, either per test with `allowed_hosts` or globally with the `--allowed-hosts` option, and the host patterns are glob-like.

python
    import pytest
    import requests

    @pytest.mark.block_network(allowed_hosts=["httpbin.*"])
    def test_access():
        assert requests.get("http://httpbin.org/get").text == '{"get": true}'
        with pytest.raises(RuntimeError, match=r"^Network is disabled$"):
            requests.get("http://example.com")
bash
    $ pytest --record-mode=once --block-network --allowed-hosts=httpbin.*,localhost tests/

Or through the `vcr_config` fixture with an `allowed_hosts` key, set on an autouse fixture in the example.

This is the feature that makes the plugin worth having even if you already use VCR.py directly, since VCR.py itself has no equivalent.

Assertions on the cassette, custom matchers and turning it off

A recording tool that only replays is half a tool. pytest-recording exposes the cassette to the test so you can assert on the interactions themselves, not only on the final response.

python
    # Make assertions based on the cassette calls/responses:
    @pytest.mark.vcr
    def test_call_count(vcr):
        assert requests.get("http://httpbin.org/get").text == '{"get": true}'
        assert requests.get("http://httpbin.org/ip").text == '{"ip": true}'
        # See https://vcrpy.readthedocs.io/en/latest/advanced.html for more info
        # about the Cassette object:
        assert vcr.play_count == 2

The `vcr` argument is injected by the plugin, and `play_count` tells you how many interactions were played back. A test can therefore assert that the code under test made exactly one call, which catches a client that retries when it should not.

For anything more specific, the `pytest_recording_configure` hook gives you the `VCR` instance itself, and the documentation suggests it might be useful for registering custom matchers, persisters and similar.

python
    # conftest.py

    def jurassic_matcher(r1, r2):
        assert r1.uri == r2.uri and "JURASSIC PARK" in r1.body, \
            "required string (JURASSIC PARK) not found in request body"

    def pytest_recording_configure(config, vcr):
        vcr.register_matcher("jurassic", jurassic_matcher)

A custom matcher lets you match a recorded request on something other than the default method and URI, which is what you need when the request body carries the interesting part, as it does here.

And there is an escape hatch for the case where a test genuinely needs to reach the network, or where a third-party plugin in the same suite conflicts with the recording. The VCR.py integration can be disabled entirely by passing the `--disable-recording` CLI option.

One compatibility note that is easy to miss. The documentation carries an incompatibility warning: if you have `pytest-vcr` installed, please uninstall it before using `pytest-recording`, as the two plugins are incompatible. Both register a `vcr` marker, and pytest does not allow two plugins to own the same mark, so the failure mode is a startup error rather than a subtle misbehaviour.

The licence is MIT, with the LICENSE file at the root of the repository, and the plugin is authored and maintained by Dmitry Dygalo.

Where the recorded cassette becomes your problem

The plugin makes recording easy, and the interesting work is what happens after the cassette is written.

The first issue is that a cassette is a file containing real response bodies. It is checked into the repository, it is read by everyone who clones it, and it is visible in the pull request where the test was added. If the response contained a token, a customer record or an internal identifier, it is now in your git history. The `filter_headers` setting in the `vcr_config` fixture exists for this, and the documentation's own example filters the authorization header, but the filter list is opt-in and a per-test mark can override it with an empty list as shown earlier.

The second issue is that cassettes go stale in a way unit tests do not. A test that asserts on a response body will keep passing against a recorded cassette from a year ago, while the live service has moved on. Nothing in the plugin detects this. The `rewrite` mode helps when you know a service changed, but there is no expiry mechanism, and the fixture is scoped, so a cassette that is three months old and a cassette from last week are indistinguishable to the suite.

The third is that the default record mode of none means a missing cassette is a test failure, which is correct, but it also means a new contributor running the suite on a fresh checkout sees failures unless the cassettes are committed. If your project keeps cassettes out of version control, for instance because of the secret exposure above, then every developer has to record locally, and the reproducibility argument for the whole approach weakens considerably.

None of this is a reason to avoid the plugin. It is a reason to treat the cassette directory as a reviewed artefact, to filter headers centrally in a session-scoped fixture rather than per test, and to make rewriting cassettes part of a normal maintenance task rather than something done when a test starts failing.

On the dependency side, the plugin requires vcrpy 2.0.1 or newer and pytest 3.5.0 or newer, with the project's own test extra pulling in pytest-httpbin, pytest-mock, requests and a pinned Werkzeug 3.1.8. The last push to the repository was on 2026-10-15 and the recorded release is 0.14.0 from 2026-10-01, so the project is actively worked on.

Editorial conclusion

pytest-recording suits a Python team whose tests talk to HTTP services and want the suite to run offline and reproducibly from checked-in cassettes. It does not suit a team that needs the real service in the loop for every run, and it does not suit a project still on Python 3.9, since the plugin requires 3.10 or newer. Verify first that the cassette directory is committed and that secrets are filtered, since the default configuration records the response bodies and the Authorization header unless you filter it, then confirm that the default record mode of none is what you want in CI, because that mode fails on a cache miss rather than reaching for the network.

Frequently asked questions

How do I install pytest-recording?

It is installed from PyPI with pip install pytest-recording. The plugin requires Python 3.10 or newer and depends on vcrpy 2.0.1 or newer and pytest 3.5.0 or newer.

Why does my test fail with a missing cassette instead of making a request?

pytest-recording uses the none VCR recording mode by default, to prevent unintentional network requests, so a cache miss fails rather than reaching the network. Pass a different mode such as once with the --record-mode option to record the cassette.

How do I stop a test from making network requests at all?

Use the pytest.mark.block_network mark, or the --block-network command line option for the whole run, and a blocked access raises RuntimeError with the message Network is disabled. Blocking supports socket-based transports and pycurl, and allowed hosts can be listed with the allowed_hosts argument or the --allowed-hosts option.

How do I keep authorization headers out of the recorded cassettes?

Return a filter_headers list from the vcr_config fixture, for example returning {"filter_headers": ["authorization"]}. The fixture accepts any scope, and its values are merged with mark arguments, with the narrowest scope taking priority.

Can I use pytest-recording together with pytest-vcr?

No. The documentation carries an incompatibility warning stating that if you have pytest-vcr installed you should uninstall it before using pytest-recording, because the two plugins are incompatible.

Official sources

  1. Issues
  2. kiwicom/pytest-recording on GitHub
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/kiwicom-pytest-recording.svg)](https://hysenlabs.com/projects/kiwicom-pytest-recording)

Was this useful?