A unit test that silently calls a real API is a test that passes on a developer's laptop, times out in a CI container without egress, sends a real email to a real customer from a staging credential, and fails one run in fifty when the third-party service hiccups. None of those failures point at the cause, because nothing in the test says "this makes a network call". The call is buried in a client three layers down, reached because someone forgot to fake it.
Blocking the network in the test process turns every such call into an immediate failure, in the test that made it, with the destination in the message. It is one autouse fixture, a dozen lines, and it is one of the most valuable guards a suite can have — precisely because it catches a mistake that is otherwise invisible until it causes an outage in the build or, worse, a side effect outside it.
Prerequisites
pytest >= 8.0.- Knowledge of which tests legitimately need a network: integration tests against containers usually use loopback, while tests against a remote sandbox genuinely leave the machine.
- The autouse guidance from taming autouse fixtures in large suites, since this is the textbook good autouse fixture.
Solution
# tests/conftest.py
import ipaddress
import socket
import pytest
_ALLOWED_HOSTS = {"localhost"}
def _is_loopback(host: str) -> bool:
if host in _ALLOWED_HOSTS:
return True
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False # a hostname that is not localhost
@pytest.fixture(autouse=True)
def block_network(request, monkeypatch):
if request.node.get_closest_marker("allow_network"):
return # explicit, visible opt-out
real_connect = socket.socket.connect
def guarded_connect(sock, address):
host = address[0] if isinstance(address, tuple) else address
if isinstance(host, str) and _is_loopback(host):
return real_connect(sock, address)
raise RuntimeError(
f"{request.node.nodeid} tried to connect to {address!r}; "
"fake the client or mark the test @pytest.mark.allow_network"
)
monkeypatch.setattr(socket.socket, "connect", guarded_connect)
# pyproject.toml
[tool.pytest.ini_options]
markers = ["allow_network: test may open non-loopback network connections"]
Why this works
Nearly every networking library in Python — requests, httpx, urllib3, database drivers, SDK clients — eventually calls socket.socket.connect. Patching that one method therefore intercepts outbound connections from all of them without knowing which libraries the code uses. monkeypatch.setattr restores the original at the end of each test, so the guard never leaks between tests or into pytest's own machinery.
Inspecting the destination rather than blocking everything keeps legitimate local traffic working. Test servers bound to 127.0.0.1, containers whose ports are mapped to the loopback interface, and local Redis or Postgres instances all connect normally. Only connections that would leave the machine are refused — which is exactly the set that makes a test slow, flaky or dangerous.
The error message is part of the design, not an afterthought. It names the test by node id, so the failure is attributed correctly even when it surfaces from a background thread; it names the destination, so the reader immediately knows which service was being called; and it states the two possible fixes. A guard that raised a bare RuntimeError("network disabled") would still prevent the call, but it would leave every developer who hits it to reverse-engineer the same three facts from a traceback. Spending a line on the message saves that investigation every single time the guard fires, across the whole life of the suite.
Edge cases and failure modes
- DNS resolution before connect.
socket.getaddrinforuns beforeconnectand can itself hit a remote resolver. Blocking atconnectstill stops the connection, but a slow or unreachable resolver can add seconds. Patchgetaddrinfotoo if resolution latency matters. - C extensions with their own sockets. A few drivers open sockets in C and bypass the Python method. They are rare;
pytest-sockethandles more of them by disabling socket creation outright. - Docker-in-Docker. Containers may be reached through a non-loopback bridge address. Add that address, read from the Testcontainers API, to the allowed set rather than disabling the guard.
- Unix domain sockets.
connectreceives a path string rather than a tuple. The guard above lets strings through as local, which is correct for Unix sockets. - Async clients.
asynciotransports ultimately use the same socket objects, so the guard applies to them, but the error surfaces inside the event loop. Configure the loop's exception handler, or the test may see a timeout rather than the clear message.
Finding the code that reached out
The first time the guard is enabled on an established suite, some tests will fail — and each failure is a genuine discovery. The error names the test and the destination; the traceback shows the call path. Three causes account for nearly all of them.
The most common is an unfaked client constructed deep inside the code, often a telemetry or feature-flag client created at import time that phones home on first use. The fix is to inject it, or to configure it for tests with an offline mode most such libraries provide.
The second is a mock at the wrong layer. The test patched a high-level function, but a sibling code path it did not anticipate calls the real client. Faking at the transport — with respx or responses — rather than at the function catches every path, as argued in mocking network and HTTP calls.
The third is a test that genuinely needs the network and was never labelled as such. These get the marker, and usually move to the integration stage where network access is expected.
On a large suite, a gentler adoption is to run the guard in logging mode first — record the offending test and destination instead of raising — for a week of CI runs, then fix the list and switch to failing. That avoids a single enormous change and gives a complete inventory before anything turns red. Working through the list once cleans up a category of flakiness permanently. From then on, any new accidental call fails on the pull request that introduced it, with a message saying exactly what to do. That is the moment the fix is cheapest: the author has the code open and knows which client they just added. Weeks later, the same call would be a mystery timeout in someone else's build. The guard moves the discovery to the cheapest possible moment.
Hand-rolled guard or pytest-socket
The fixture above is small enough to own, and owning it means the allowed hosts, the error message and the marker are exactly what the project wants. pytest-socket is the packaged alternative, and it is worth knowing where each is the better choice.
pytest-socket disables socket creation entirely with --disable-socket, which is stricter than blocking connect: code that merely creates a socket without connecting also fails. It offers --allow-hosts for a loopback allowlist and an enable_socket marker for opting out, and because it works at socket creation it catches a few C-level drivers that a connect patch misses. Its configuration lives in command-line flags, which some teams prefer and others find easy to lose in CI scripts.
The hand-rolled guard gives a friendlier error — naming the test, the destination and the fix — and makes the allowlist easy to extend with computed addresses such as a Docker bridge IP read from Testcontainers. It is also trivially adapted to log rather than fail during a migration period, which makes adopting it on a large suite gentler.
Either is far better than neither. A reasonable default is the hand-rolled fixture for its clearer failures, switching to pytest-socket if a C-level driver is found bypassing it.
Frequently Asked Questions
Why block the network in unit tests at all? Because an unmocked call makes the test slow, dependent on an external service's availability, and able to cause real side effects — a sent email, a charged card, a written record in a shared environment. Blocking turns every accidental call into an immediate, attributable failure instead of an intermittent one.
Should localhost be allowed?
Usually yes. Tests that start a local server, talk to a container on the loopback interface or use a local Redis are legitimate. Allow 127.0.0.1, ::1 and localhost explicitly, and block everything else.
How do integration tests that need the network opt out?
With a marker the guard checks — @pytest.mark.allow_network or similar. The exception is visible on the test itself, can be selected or excluded with -m, and cannot be applied accidentally.
Related
- Mocking Network and HTTP Calls — faking at the transport so the guard never fires.
- Simulating Timeouts and Connection Errors — testing failure paths without a real network.
- Taming Autouse Fixtures in Large Suites — why this fixture passes every audit question.
- Spinning Up Services with Testcontainers — local services the guard deliberately allows.
← Back to Mocking Network and HTTP Calls