You patched the HTTP client. The CLI test still hangs on a DNS name that should not exist. The unit tests above it were green, so the hang feels like flakiness. It is not flakiness. You tested one interpreter and then spawned another.
A CLI that boots with subprocess.run([sys.executable, ...]) is a fresh Python. Your unittest.mock.patch lived in the parent and died at the process door. The child imported the real module, read the real environment, and reached for the real network. That is the bug this article pins down.
Green tests, then a two-second hang
Picture a weather CLI. In-process tests replace fetch_forecast and return {"summary": "sunny"}. Those tests are honest about the parent process. They say nothing about the console script your users actually run.
You add a “real” CLI test because packaging scared you. The test launches the file as a program. It should print sunny and exit zero. Instead it waits on urlopen, then raises URLError. You did patch the client. You patched the wrong machine.
Think of a stage whisper. The actor beside you hears it. The understudy behind the door does not. A parent-process fake is that whisper. The child is the understudy.
A CLI small enough to indict
Keep the program boring. One module, stdlib only, no framework. The point is the process boundary, not weather math.
# weather_cli.py
import argparse
import json
import os
import urllib.request
DEFAULT_URL = "https://api.example.invalid/v1/forecast"
def build_url(city: str) -> str:
base = os.environ.get("WEATHER_ENDPOINT", DEFAULT_URL)
return f"{base}?city={city}"
def fetch_forecast(city: str) -> dict:
req = urllib.request.Request(build_url(city), method="GET")
token = os.environ.get("WEATHER_TOKEN", "")
if token:
req.add_header("Authorization", f"Bearer {token}")
with urllib.request.urlopen(req, timeout=2) as resp:
return json.loads(resp.read().decode("utf-8"))
def main(argv=None) -> int:
parser = argparse.ArgumentParser(prog="weather")
parser.add_argument("city")
args = parser.parse_args(argv)
print(fetch_forecast(args.city).get("summary", "unknown"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
DEFAULT_URL is not a reachable service. That is deliberate. If a test forgets to point the child at a local fake, the call should fail fast. A hanging suite is a missing contract, not a slow API.
The patch that evaporates
This is the test many suites grow by accident. It looks careful. It is not.
# test_parent_patch.py
import subprocess
import sys
import unittest
from unittest.mock import patch
class ParentPatchLies(unittest.TestCase):
def test_patch_does_not_enter_child(self):
with patch(
"weather_cli.fetch_forecast",
return_value={"summary": "sunny"},
):
result = subprocess.run(
[sys.executable, "weather_cli.py", "Oslo"],
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0)
self.assertEqual(result.stdout.strip(), "sunny")
Run it and watch the child miss the fake.
python -m unittest test_parent_patch.py
The parent wrapped weather_cli.fetch_forecast. The child started a new interpreter, imported a clean copy of weather_cli, and called the real urlopen. Mock objects are memory in one process. They do not serialize across exec.
A second trap sits next to that one. Mutating os.environ in the parent does cross the boundary, because the child inherits the parent environment by default. Yesterday’s test can leave WEATHER_TOKEN set. Today’s CLI test then sends a token you never passed in that file. The first bug hides a network call. The second bug leaks secrets and hostnames into later children.
Treat the child as another machine
Once you spawn, you no longer share imports, mocks, or monkeypatched methods. You share only what a Unix process can see: argv, an env dict, stdin, and maybe a working directory. Design the CLI as if the test were a remote operator. Remote operators do not reach into your RAM.
That does not mean every CLI test must spawn. If you only need argument parsing and function flow, call main(["Oslo"]) in-process and patch there. Spawn when you care about the entry point, import-time work, or the exact env the packaged script will receive. Mixing those goals in one test is how the whisper/understudy mix-up starts.
The child-visible contract for this sample is small. WEATHER_ENDPOINT chooses the host. WEATHER_TOKEN chooses the header. Nothing else should be required. If you need a third knob, make it a flag, not a patch.
Pass a private env, not the parent’s leftovers
Do not call subprocess.run(...) and hope os.environ is clean. Build a dict. Copy only the keys the interpreter needs to start, then add the contract. Windows still needs SYSTEMROOT. Unix still needs PATH if the child launches tools. Neither needs your shell’s cloud tokens.
# child_env.py
import os
def child_env(endpoint: str, token: str) -> dict:
env = {
"PATH": os.environ.get("PATH", ""),
"PYTHONPATH": os.environ.get("PYTHONPATH", ""),
"WEATHER_ENDPOINT": endpoint,
"WEATHER_TOKEN": token,
}
for key in ("SYSTEMROOT", "WINDIR", "LD_LIBRARY_PATH"):
if key in os.environ:
env[key] = os.environ[key]
return env
Pass that dict as env=. The child cannot see a WEATHER_TOKEN that some other test left on os.environ. Inheritance stops being an accidental API.
Prove the network stayed dark
A fake server on loopback is the child’s stand-in for production. A second server is the trap. The CLI must hit the fake. If it hits the trap, the contract failed and the test must fail, even when stdout looks fine.
# test_weather_child.py
import json
import subprocess
import sys
import threading
import unittest
from http.server import BaseHTTPRequestHandler, HTTPServer
from child_env import child_env
def start_server(handler):
server = HTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
host, port = server.server_address
return server, f"http://{host}:{port}"
class FakeWeather(BaseHTTPRequestHandler):
hits = 0
def do_GET(self):
FakeWeather.hits += 1
body = json.dumps({"summary": "sunny"}).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *_args):
return
class Trap(BaseHTTPRequestHandler):
hits = 0
def do_GET(self):
Trap.hits += 1
self.send_error(500, "trap")
def log_message(self, *_args):
return
class ChildContract(unittest.TestCase):
def test_child_uses_endpoint_and_misses_trap(self):
FakeWeather.hits = 0
Trap.hits = 0
fake, fake_url = start_server(FakeWeather)
trap, _trap_url = start_server(Trap)
try:
result = subprocess.run(
[sys.executable, "weather_cli.py", "Oslo"],
capture_output=True,
text=True,
env=child_env(fake_url, "test-token"),
timeout=5,
)
finally:
fake.shutdown()
trap.shutdown()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), "sunny")
self.assertEqual(FakeWeather.hits, 1)
self.assertEqual(Trap.hits, 0)
python -m unittest test_weather_child.py
The trap does not need to be the production hostname. It only needs to be a destination a buggy child could choose if it ignored WEATHER_ENDPOINT and guessed another test’s port. If you want a stricter check, bind the trap first and assert that a child without WEATHER_ENDPOINT fails before the two-second urlopen timeout. Failure is part of the contract.
What actually crosses the boundary
| You change this in the parent | Child sees it after subprocess.run? |
Typical damage |
|---|---|---|
unittest.mock.patch on a function |
No | Real network, real DNS |
| In-memory fake client object | No | Same as above |
os.environ["WEATHER_TOKEN"] = ... |
Yes, inherited | Leaked tokens, order-dependent tests |
env= dict on subprocess.run
|
Yes, isolated | The contract you wanted |
CLI flag such as --endpoint
|
Yes | Best long-term API |
Calling main(["Oslo"]) in-process |
N/A, no child | Patches work; packaging path untested |
Read the table as a routing rule, not as taste. If the value cannot travel through argv or env, the child will never learn it. Put the fake on the other side of that door, or stop spawning.
Extra cases without turning the model into the oracle
Once the trap test is green, you can ask a model to propose more child-visible cases: missing token, empty city, a non-JSON body, a 503 from the fake, a timeout smaller than the client’s. That is draft work. It is not proof.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode offers free model access and a free server option, which is enough to generate those extra cases and run the unittest file where you already run Python. Keep the trap assertions in your own review. Discard any generated test that patches weather_cli.fetch_forecast in the parent and then spawns a child. That pattern is how this whole mess starts.
Leave this workflow on the shelf when it does not apply
Do not spawn a process to unit-test a pure function. Call the function. A child process will hide TypeErrors behind subprocess plumbing and waste seconds per test.
Do not treat a loopback HTTP server as a TLS rehearsal. This sample never proves certificates, proxies, or HTTP/2. If those matter, add a separate contract, still inside the child.
Do not assume multiprocessing on Linux behaves like subprocess. A fork can still see parent memory until exec. This article is about a new interpreter, not about copy-on-write surprises after fork().
Do not “fix” a leaking suite by mutating os.environ back in tearDown and calling it isolation. A crash before tearDown leaves the token in place. An env= dict does not.
The durable habit is simple to say and easy to skip. If the user runs a new Python, your fake has to live where that Python can see it. Whispering in the parent is not a test of the CLI. It is a test of your mock library.