Mock and block real network calls in tests
Real HTTP calls make pytest suites slow and flaky. Mock at the transport with responses, respx, pytest-httpx, aioresponses, moto or vcrpy, then block the network with pytest-socket.
A test that calls a real API waits for DNS, a TCP and TLS handshake, the remote server and the response, often hundreds of milliseconds per call. Retries and timeouts make the slow path much slower: a test that hits an unreachable host can hang for the client's whole timeout.
Network calls are also one of the top causes of flaky tests. The test depends on a service you don't control, on rate limits and on the network of the machine running it, which is why these tests so often pass on a laptop and fail in CI (or the other way around).
Why tests end up on the network
- The code under test calls an API, and nobody mocked it in this particular test.
- A mock was written for one HTTP library, and the code switched to another (
requeststohttpx). - An SDK talks to the network on its own: fetching credentials or a region from instance metadata, sending telemetry, downloading a model or a schema.
- A fixture creates a real client against a staging environment "just for this test", and it spreads.
Mock at the transport
Mock the HTTP layer rather than your own client functions. The code under test still builds the real request (URL, headers, body) and parses a real response, so you test your code, not your mock.
| Library | Mock with |
|---|---|
| requests | responses, or pytest-httpserver (a real local server) |
| httpx | respx or pytest-httpx |
| aiohttp | aioresponses |
| boto3 / botocore | botocore.stub.Stubber, or moto |
| anything | vcrpy / pytest-recording to record and replay real responses |
requests: responses
import responses
from myapp.weather import current_temperature
@responses.activate
def test_current_temperature():
responses.get("https://api.example.com/weather", json={"temp": 21.5})
assert current_temperature("Ljubljana") == 21.5
assert responses.calls[0].request.params == {"city": "Ljubljana"}
Inside @responses.activate, a request to a URL you didn't register raises ConnectionError instead of going out.
httpx: respx or pytest-httpx
import httpx
def test_current_temperature(respx_mock):
route = respx_mock.get("https://api.example.com/weather").mock(
return_value=httpx.Response(200, json={"temp": 21.5})
)
assert current_temperature("Ljubljana") == 21.5
assert route.called
With pytest-httpx the fixture is httpx_mock: httpx_mock.add_response(url="https://api.example.com/weather?city=Ljubljana", json={"temp": 21.5}). By default it fails the test at teardown if a registered response was never requested.
aiohttp: aioresponses
from aioresponses import aioresponses
async def test_current_temperature():
with aioresponses() as mocked:
mocked.get("https://api.example.com/weather?city=Ljubljana", payload={"temp": 21.5})
assert await current_temperature("Ljubljana") == 21.5
AWS: Stubber or moto
botocore.stub.Stubber queues exact responses for one client and checks the parameters. moto fakes whole services in memory, which suits tests that create a bucket, upload and read back:
import boto3
from moto import mock_aws
@mock_aws
def test_upload_report():
s3 = boto3.client("s3", region_name="eu-central-1")
s3.create_bucket(Bucket="reports", CreateBucketConfiguration={"LocationConstraint": "eu-central-1"})
upload_report(s3, "reports", b"data")
assert s3.get_object(Bucket="reports", Key="report.csv")["Body"].read() == b"data"
Record and replay: vcrpy and pytest-recording
For APIs with large or complicated responses, record them once and replay them from a cassette file. With pytest-recording, mark the test with @pytest.mark.vcr, run pytest --record-mode=once once to record, and commit the cassette. The default record mode is none, so later runs replay and never touch the network. Filter secrets out of cassettes with a vcr_config fixture ({"filter_headers": ["authorization"]}).
Block accidental network access
Mocks only help in the tests that have them. To catch the rest, make any real network call fail the test.
pytest-socket
[tool.pytest.ini_options]
addopts = "--disable-socket --allow-unix-socket"
--disable-socket makes any use of socket raise SocketBlockedError, DNS included. --allow-unix-socket keeps Unix domain sockets working (the asyncio event loop needs them, and so may a local database). If tests talk to a local database or Redis over TCP, use --allow-hosts=127.0.0.1,::1 instead of --disable-socket: connections to any other host fail. Entries can be hostnames, IP addresses or CIDR ranges.
Re-enable the network per test with @pytest.mark.enable_socket or the socket_enabled fixture, or allow specific hosts with @pytest.mark.allow_hosts(["127.0.0.1"]).
pytest-recording has a similar --block-network option (with --allowed-hosts) that still lets tests marked @pytest.mark.vcr record.
Without a plugin: patch getaddrinfo
Almost every client resolves the hostname with socket.getaddrinfo before connecting, so patching that one function catches requests from any HTTP library. This is the approach from disabling network requests when running pytest:
# conftest.py
import socket
import pytest
@pytest.fixture(autouse=True)
def block_external_requests(monkeypatch):
original_getaddrinfo = socket.getaddrinfo
allowed_hosts = {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
def assert_only_localhost(host, *args, **kwargs):
assert host in allowed_hosts, f"External request to {host} detected"
return original_getaddrinfo(host, *args, **kwargs)
monkeypatch.setattr(socket, "getaddrinfo", assert_only_localhost)
The failure names the host, which tells you which mock is missing. It doesn't catch connections to a raw IP address (no lookup needed), which pytest-socket does.
Keep the tests correct
- Keep a few explicitly marked integration tests that talk to the real service (
@pytest.mark.integrationplus@pytest.mark.enable_socket), and run them in a separate CI job, so a changed API doesn't go unnoticed. - Mock responses drift from reality. Base them on real responses (recorded cassettes, or copies of real payloads) and refresh them when the API changes.
- Assert on the request too (URL, parameters, body), not only on what your code did with the response.
How flakytest finds it
flakytest puts time spent in socket, ssl, http.client, urllib3, requests, httpx, aiohttp, botocore, gRPC, websockets, smtplib and DNS in the network category (database drivers and Redis clients have their own categories). When network time is more than 0.3 seconds and 1% of test time, it suggests Tests make real network calls, with the tests that spend the most time on the network and the lines of your code that make the calls. Compare a CI run with a local one on flakytest.dev to see whether the network is what makes CI slower.
Further reading
- responses
- respx and pytest-httpx
- aioresponses
- botocore Stubber and moto
- pytest-recording and vcrpy
- pytest-socket
Related guides
- Common causes of flaky tests and how to fix each: Why pytest tests pass and fail on the same code: test order, time zones, randomness, sleeps, unordered results, xdist and slow CI. The symptom and fix for each.
- Tests are sleeping: replace time.sleep: Fixed time.sleep and asyncio.sleep calls make pytest suites slow and flaky. Poll for the condition, patch the sleep in retry and backoff code, and move the clock with time-machine.
- Keep tests independent of each other: Tests should pass alone, in any order and in parallel. Find order-dependent pytest tests with pytest-randomly and pytest-xdist, and fix the shared state behind them.
- Speed up pytest in CI: Speed up slow CI test jobs: cache dependencies with uv or pip, use pytest-xdist on every core, shard with pytest-split, migrate once, and see why CI is slower than local.
Updated October 18, 2026 · Suggest a change