Isolation & Contracts

Blocking Accidental Network Calls in pytest

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

Python
# 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)
TOML
# pyproject.toml
[tool.pytest.ini_options]
markers = ["allow_network: test may open non-loopback network connections"]
How the network guard decides Every socket connection from a test passes through the guard. If the test carries the allow_network marker, the connection proceeds. Otherwise a loopback destination proceeds and any other destination raises an error naming the test and the address it tried to reach. Two questions per connection socket.connect from any code path allow_network? marker on the test yes no connect normally loopback destination? yes → connect (local server, container) no → RuntimeError naming test and host
Loopback stays open so local servers and port-mapped containers work; everything leaving the machine fails unless the test says otherwise.

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.getaddrinfo runs before connect and can itself hit a remote resolver. Blocking at connect still stops the connection, but a slow or unreachable resolver can add seconds. Patch getaddrinfo too 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-socket handles 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. connect receives a path string rather than a tuple. The guard above lets strings through as local, which is correct for Unix sockets.
  • Async clients. asyncio transports 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.

Three causes of accidental network calls Three cards. An unfaked client created at import time, such as telemetry, is fixed by injecting it or using an offline mode. A mock placed at the wrong layer is fixed by faking at the transport instead. A test that genuinely needs the network is fixed by marking it and moving it to the integration stage. What the guard finds on its first run hidden client telemetry, flags, SDKs created at import inject or run offline wrong-layer mock one path faked, a sibling path real fake at the transport genuine need remote sandbox, public schema download mark and move
Each failure is attributable to one of these three, and each has a specific fix. None of them should be resolved by disabling the guard.

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.

Hand-rolled guard compared with pytest-socket Two options. The hand-rolled fixture blocks at connect, gives a custom error naming the test and destination, and is easy to extend with computed allowed addresses or to switch to logging during adoption. pytest-socket blocks at socket creation, catches more C-level drivers, and is configured through command-line flags and an enable marker. Either one beats no guard at all hand-rolled fixture blocks socket.connect custom, actionable message computed allowlist, log mode the friendlier default pytest-socket blocks socket creation catches more C drivers flags + enable_socket marker the stricter option
Start with whichever the team will actually keep enabled; a strict guard that gets turned off helps nobody.

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.

← Back to Mocking Network and HTTP Calls