Process Isolation for Hermetic Tests¶
Process isolation is a test enforcement mechanism that prevents small tests from spawning subprocesses during execution. This ensures tests are hermetic and run in a single process with no external dependencies.
When enabled, the pytest-test-categories plugin intercepts subprocess spawning and either blocks it or warns about it, depending on your configuration.
Why Process Isolation Matters¶
Tests that spawn subprocesses introduce several problems:
Non-Determinism¶
External processes have their own state and behavior:
Process startup times vary across machines
Environment variables may differ between systems
Child process behavior is harder to control and predict
Exit codes and output can vary based on system state
I/O Overhead¶
Process creation involves significant system overhead:
Fork/exec system calls are expensive
Memory pages must be copied or marked copy-on-write
File descriptors are duplicated
Process scheduling adds latency
Resource Leakage¶
Spawned processes may outlive tests if not properly managed:
Zombie processes accumulate if not waited on
Child processes may continue running after test failure
Open file handles and network connections persist
Memory is not reclaimed until all children exit
Environment Coupling¶
Subprocesses inherit environment state:
Environment variables affect behavior
Working directory matters for relative paths
PATH and other system settings vary
Shell differences (bash vs sh vs zsh) cause inconsistencies
Test Size Restrictions¶
Process isolation follows Google’s test size definitions from “Software Engineering at Google”:
Test Size |
Subprocess Spawning |
Rationale |
|---|---|---|
Small |
Blocked |
Must be hermetic, single-process |
Medium |
Allowed |
May spawn processes for integration |
Large |
Allowed |
May run external commands |
XLarge |
Allowed |
May orchestrate multiple processes |
Small Tests¶
Small tests run in a single process:
Fast: No process creation overhead
Hermetic: No dependency on external executables
Deterministic: No subprocess behavior variation
Parallelizable: Safe to run concurrently without process conflicts
Process isolation enforces the single-process constraint by blocking subprocess spawning in small tests.
Medium, Large, and XLarge Tests¶
These tests may spawn subprocesses freely, enabling:
CLI testing with real command execution
Integration tests that start local services
End-to-end tests with multiple processes
Performance tests that measure real execution
How It Works¶
The plugin intercepts subprocess spawning by patching Python’s subprocess and os modules:
Patched Entry Points¶
The following process spawning entry points are intercepted:
subprocess module:
subprocess.Popen- Base class for all subprocess operationssubprocess.run- High-level convenience functionsubprocess.call- Run command, return exit codesubprocess.check_call- Run command, raise on non-zero exitsubprocess.check_output- Run command, return stdout
os module:
os.system- Run command in shellos.popen- Open pipe to/from command
multiprocessing module:
multiprocessing.Process- Spawn new Python interpreter process
Spawn Interception¶
When a test attempts to spawn a subprocess:
The blocker intercepts the spawn call
It extracts the command and arguments
It checks if spawning is allowed based on test size
For violations, it either raises an exception (STRICT) or warns (WARN)
Enabling Process Isolation¶
Process isolation is controlled by the test_categories_enforcement configuration option.
Configuration via pyproject.toml¶
[tool.pytest.ini_options]
# Enable process isolation enforcement
test_categories_enforcement = "strict"
Configuration via pytest.ini¶
[pytest]
test_categories_enforcement = strict
Configuration via Command Line¶
pytest --test-categories-enforcement=strict
Enforcement Modes¶
The plugin supports three enforcement modes:
STRICT Mode¶
test_categories_enforcement = "strict"
In strict mode, subprocess violations immediately fail the test with a detailed error message:
[TC003] Subprocess Spawn Violation
Test: tests/test_cli.py::test_run_command
Category: SMALL
What happened:
Attempted subprocess.run: python script.py --verbose
How to fix:
1. Mock subprocess.run using pytest-mock (mocker.patch)
2. Use dependency injection to provide a fake command executor
3. Test the logic that prepares subprocess arguments, not the spawn itself
4. Change test category to @pytest.mark.medium (if subprocess is required)
Documentation: https://pytest-test-categories.readthedocs.io/errors/TC003
Use strict mode in CI pipelines to catch violations before merge.
WARN Mode¶
test_categories_enforcement = "warn"
In warn mode, subprocess violations emit a warning but allow the test to continue:
PytestWarning: Subprocess spawn violation in test_run_command:
attempted subprocess.run: python script.py --verbose
Use warn mode during migration to identify violations without breaking the build.
OFF Mode¶
test_categories_enforcement = "off"
In off mode, process isolation is disabled entirely.
Common Remediation Strategies¶
1. Mock subprocess.run¶
The most common pattern for CLI testing:
import pytest
@pytest.mark.small
def test_git_status(mocker):
mock_run = mocker.patch("subprocess.run")
mock_run.return_value.returncode = 0
mock_run.return_value.stdout = b"On branch main\nnothing to commit"
from myapp.git import get_status
status = get_status()
assert "main" in status
mock_run.assert_called_once_with(
["git", "status"],
capture_output=True,
check=False,
)
2. Test Command Building Logic¶
Instead of testing subprocess execution, test the logic that builds commands:
import pytest
# Production code
def build_ffmpeg_command(input_path: str, output_path: str, quality: int) -> list[str]:
return [
"ffmpeg",
"-i", input_path,
"-q:v", str(quality),
output_path,
]
# Test the command building, not the execution
@pytest.mark.small
def test_build_ffmpeg_command():
cmd = build_ffmpeg_command("input.mp4", "output.mp4", quality=2)
assert cmd[0] == "ffmpeg"
assert "-i" in cmd
assert "input.mp4" in cmd
assert "-q:v" in cmd
assert "2" in cmd
3. Use Dependency Injection¶
Design code to accept a command executor:
from typing import Protocol
import subprocess
import pytest
# Define interface
class CommandExecutor(Protocol):
def run(self, args: list[str]) -> subprocess.CompletedProcess: ...
# Production implementation
class RealExecutor:
def run(self, args: list[str]) -> subprocess.CompletedProcess:
return subprocess.run(args, capture_output=True, check=True)
# Test implementation
class FakeExecutor:
def __init__(self, outputs: dict[str, str]):
self.outputs = outputs
self.calls: list[list[str]] = []
def run(self, args: list[str]) -> subprocess.CompletedProcess:
self.calls.append(args)
key = " ".join(args)
return subprocess.CompletedProcess(
args=args,
returncode=0,
stdout=self.outputs.get(key, b"").encode(),
)
# Production code using dependency injection
def deploy(executor: CommandExecutor) -> str:
result = executor.run(["kubectl", "apply", "-f", "deployment.yaml"])
return result.stdout.decode()
# Small test with fake executor
@pytest.mark.small
def test_deploy():
executor = FakeExecutor({"kubectl apply -f deployment.yaml": "deployed"})
result = deploy(executor)
assert "deployed" in result
4. Use pytest’s pytester Fixture¶
For testing pytest plugins that need to run pytest:
import pytest
@pytest.mark.medium # pytester spawns subprocesses
def test_my_plugin(pytester):
pytester.makepyfile("""
def test_example():
assert True
""")
result = pytester.runpytest()
result.assert_outcomes(passed=1)
Note: Tests using pytester should be marked as @pytest.mark.medium because pytester.runpytest() spawns a subprocess.
5. Change Test Size¶
If the test legitimately requires subprocess execution:
import subprocess
import pytest
@pytest.mark.medium # Medium tests can spawn processes
def test_cli_integration():
result = subprocess.run(
["myapp", "--version"],
capture_output=True,
text=True,
)
assert result.returncode == 0
assert "1.0.0" in result.stdout
6. Mock os.system and os.popen¶
For legacy code using shell commands:
import pytest
@pytest.mark.small
def test_legacy_command(mocker):
mock_system = mocker.patch("os.system")
mock_system.return_value = 0
from myapp.legacy import run_backup
result = run_backup()
assert result is True
mock_system.assert_called_once()
7. Mock multiprocessing.Process¶
For code using multiprocessing:
import pytest
@pytest.mark.small
def test_parallel_processing(mocker):
mock_process = mocker.patch("multiprocessing.Process")
mock_instance = mocker.MagicMock()
mock_process.return_value = mock_instance
from myapp.parallel import start_worker
start_worker()
mock_process.assert_called_once()
mock_instance.start.assert_called_once()
Best Practices¶
1. Start with WARN Mode¶
When first enabling process isolation, use warn mode to identify all violations:
pytest --test-categories-enforcement=warn 2>&1 | grep "Subprocess spawn violation"
2. Separate Command Logic from Execution¶
Design your code to separate:
Command building: Pure functions that return command lists
Command execution: Thin wrappers around subprocess
This makes the command building easily testable in small tests:
# command_builder.py - easily testable
def build_docker_command(image: str, cmd: list[str]) -> list[str]:
return ["docker", "run", "--rm", image] + cmd
# executor.py - integration tested
def execute(command: list[str]) -> int:
import subprocess
return subprocess.run(command).returncode
3. Use Fixtures for Medium Test Setup¶
Create fixtures that manage subprocess lifecycle:
import subprocess
import pytest
@pytest.fixture
def redis_server():
"""Start a Redis server for medium tests."""
proc = subprocess.Popen(["redis-server", "--port", "6380"])
yield proc
proc.terminate()
proc.wait()
@pytest.mark.medium
def test_redis_integration(redis_server):
import redis
r = redis.Redis(port=6380)
r.set("key", "value")
assert r.get("key") == b"value"
4. Consider Container-Based Testing¶
For complex integration scenarios, use containers:
import pytest
@pytest.fixture(scope="session")
def docker_compose():
"""Start services via docker-compose."""
import subprocess
subprocess.run(["docker-compose", "up", "-d"], check=True)
yield
subprocess.run(["docker-compose", "down"], check=True)
@pytest.mark.large
def test_full_stack(docker_compose):
# Test against containerized services
...
Troubleshooting¶
“SubprocessViolationError” when using pytester¶
The pytester fixture spawns a subprocess to run pytest. Tests using pytester must be marked as medium:
@pytest.mark.medium # Required for pytester
def test_my_plugin(pytester):
...
“subprocess.run not being mocked correctly”¶
Ensure you’re patching the right location:
# Wrong - patches the subprocess module directly
mocker.patch("subprocess.run")
# Right - patches where it's imported
mocker.patch("myapp.commands.subprocess.run")
# Or patch at usage location
mocker.patch.object(myapp.commands, "subprocess")
“Test passes but warns about multiprocessing”¶
Some libraries use multiprocessing internally:
concurrent.futures.ProcessPoolExecutorParallel data processing libraries
Machine learning frameworks
Solution: Mock the library’s parallel execution or use @pytest.mark.medium.