A cross-platform pytest plugin that runs marked tests in isolated subprocesses with intelligent grouping.
Ever had a test that passes alone but fails in the suite? Or tests that mysteriously hang on CI? pytest-isolated solves this by running tests in completely isolated subprocesses.
Common problems it solves:
- ✅ Segfaults and crashes don't kill your entire test suite
- ✅ Tests modifying global state don't affect each other
- ✅ Hanging tests timeout without blocking other tests
- ✅ C extension crashes are contained
- ✅ Resource leaks are isolated per test
This plugin is inspired by pytest-forked. See pytest-forked migration guide for a quick reference comparing features.
1. Install:
pip install pytest-isolated2. Mark a flaky test:
# Example 1: Simple isolation
@pytest.mark.isolated
def test_with_clean_state():
import os
os.environ["DEBUG"] = "true"
# Other tests won't see this change
# Example 2: Crash protection
@pytest.mark.isolated
def test_that_crashes():
import ctypes
ctypes.string_at(0) # Crash is contained!3. Run pytest normally:
pytest # Crash is isolated, suite continues- Run tests in fresh Python subprocesses to prevent state pollution
- Group related tests to run together in the same subprocess
- Run up to N Group subprocesses in parallel (
--isolated-numprocesses) - Handles crashes, timeouts, and setup/teardown failures
- Respects pytest's standard output capture settings (
-s,--capture) - Works with pytest reporters (JUnit XML, etc.)
- Configurable timeouts to prevent hanging subprocesses
- Cross-platform: Linux, macOS, Windows
Mark tests to run in isolated subprocesses:
import pytest
@pytest.mark.isolated
def test_isolated():
# Runs in a fresh subprocess
assert TrueTests with the same group run together in one subprocess:
# Using keyword argument
@pytest.mark.isolated(group="mygroup")
def test_one():
shared_state.append(1)
@pytest.mark.isolated(group="mygroup")
def test_two():
# Sees state from test_one
assert len(shared_state) == 2
# Or using positional argument
@pytest.mark.isolated("mygroup")
def test_three():
shared_state.append(3)Set timeout per test group:
@pytest.mark.isolated(timeout=30)
def test_with_timeout():
# This group gets 30 second timeout (overrides global setting)
expensive_operation()Note: Tests without an explicit group parameter each run in their own unique subprocess for maximum isolation.
Apply to entire classes to share state between methods:
@pytest.mark.isolated
class TestDatabase:
def test_setup(self):
self.db = create_database()
def test_query(self):
# Shares state with test_setup
result = self.db.query("SELECT 1")
assert resultApply to entire modules using pytestmark:
import pytest
pytestmark = pytest.mark.isolated
def test_one():
# Runs in isolated subprocess
pass
def test_two():
# Shares subprocess with test_one
passWhen @pytest.mark.isolated appears at multiple scopes, the closest marker wins (following pytest's standard get_closest_marker convention):
- Explicit
groupalways wins.@pytest.mark.isolated(group="name")uses that group regardless of scope. - Function > class > module. A function-level
@pytest.mark.isolatedbreaks out of any class or module group into its own subprocess. - Class > module. A class marker groups its methods together, even inside a module with
pytestmark.
# Run all tests in isolation (even without @pytest.mark.isolated)
pytest --isolated
# Set isolated test timeout (seconds)
pytest --isolated-timeout=60
# Disable subprocess isolation for debugging
pytest --no-isolation
# Control output capture (standard pytest flags work with isolated tests)
pytest -s # Disable capture, show all output
pytest --capture=sys # Capture at sys.stdout/stderr level
# Combine with pytest debugger
pytest --no-isolation --pdb[pytest]
isolated_timeout = 300
isolated_numprocesses = auto
isolated_shared_fixtures = db_urlOr in pyproject.toml:
[tool.pytest.ini_options]
isolated_timeout = "300"
isolated_numprocesses = "auto"
isolated_shared_fixtures = ["db_url"]By default each Isolated Group runs in its own subprocess, but only one subprocess runs at a time. To run Groups concurrently:
# Up to 8 Group subprocesses at once
pytest --isolated --isolated-numprocesses=8
# One per usable CPU (affinity-aware)
pytest --isolated --isolated-numprocesses=auto- The default (flag absent,
isolated_numprocessesunset) is serial: results and output are byte-identical to earlier releases. - Outcomes, durations, exit codes, and reporter files (JUnit XML etc.) are
unchanged by parallelism; only the wall clock moves. Group output arrives as one
burst when each Child finishes, so
-k/report ordering is preserved while live stdout may appear in completion order. - Groups that must not overlap anything get
@pytest.mark.isolated(serial=True): the marker propagates to the whole Group, which then runs alone — nothing else launches while it does — and keeps its place in collection order. - With
--isolated-numprocesses> 1, each Child also gets its owntmp_pathroot and its own fresh pytest cache directory (both under the run's--basetemp, when given), so concurrent Children never race on shared state — while--lf/--ffinherited viaPYTEST_ADDOPTS/addoptskeep working. - When Shared Fixtures are in
use with an explicit
--basetemp, serial Children also get separate per-Group temporary directories under the Parent's base, so a Child's temporary directory cleanup cannot delete live Parent resources. Serial Children keep their shared pytest cache and last-failed policy. - Caution: with
-s/--capture=no, Children write straight to the Parent's terminal (live, like plain pytest); under parallelism those streams interleave and a one-time warning is issued. Test outcomes and reporters stay correct.
| pytest-xdist | pytest-isolated |
|---|---|
-n 4 |
--isolated-numprocesses=4 |
@pytest.mark.xdist_group("name") |
@pytest.mark.isolated(group="name") |
-n 4 -x (drain) |
same: in-flight Groups finish, unstarted ones do not launch |
Unlike xdist, every Child is a complete fresh pytest process for one Group — no controller/worker protocol to break on Windows — at the cost of one pytest startup per Group rather than per worker.
Each Child is a fresh pytest session and runs the fixtures its Group's tests
need, including scope="session" fixtures. Those run once per Child, as in
pytest-xdist workers. The Parent runs no fixtures for Isolated tests. Fixture
objects cannot cross a process boundary.
Some resources should be paid for once per run, not once per Child: a
database or solver server, a container, a built artifact. Mark the session
fixture as shared. It then runs once in the Parent, before the first
Child launches. Every Child receives its value and never runs the fixture body
or the dependencies reachable only through it. Teardown runs once, in the
Parent, before pytest_sessionfinish hooks.
# conftest.py
import pytest
import pytest_isolated
@pytest.fixture(scope="session")
@pytest_isolated.shared
def db_url(tmp_path_factory):
server = start_database(tmp_path_factory.mktemp("db")) # runs once
yield server.url # Children receive this string
server.stop() # runs once, in the Parent
@pytest.mark.isolated(group="a")
def test_a(db_url): ...
@pytest.mark.isolated(group="b")
def test_b(db_url): ...Or list fixture names in the ini instead of decorating them:
[pytest]
isolated_shared_fixtures = db_url license_serverRules:
- The fixture must be
scope="session"and not parametrized. - The value must be JSON-serializable: share a handle (URL, port, path,
dict of settings), not a live object. Children get a JSON copy, so tuples
arrive as lists. Fixtures without a value (
yieldwith no value) work too: "start a service for the whole run". - Autouse shared fixtures set themselves up whenever an Isolated test runs. Other shared fixtures set up only when an Isolated test requests them. Tests skipped by marks don't count.
- If the fixture fails or skips in the Parent, only the tests that use it error or skip, each in its own Child, as in plain pytest. An invalid shared fixture (wrong scope, parametrized, or a non-JSON value) is a usage error raised before any Child launches.
- Sharing belongs to a fixture definition: a same-name override in a subdirectory is not shared unless it is decorated or named in the ini too.
- In-process tests (unmarked, or
--no-isolation) use the Parent's value directly, so the fixture still runs only once. - Shared Fixtures require pytest >= 8. On pytest 7, shared fixtures run once per Child, with a warning.
Prevent environment variable and configuration changes from leaking between tests:
@pytest.mark.isolated
def test_modifies_environ():
import os
os.environ["MY_VAR"] = "value"
# Won't affect other tests
@pytest.mark.isolated
def test_clean_environ():
import os
assert "MY_VAR" not in os.environ # Fresh environmentGroup tests that share singleton state, or isolate them completely:
@pytest.mark.isolated(group="singleton_tests")
def test_singleton_init():
from myapp import DatabaseConnection
db = DatabaseConnection.get_instance()
assert db is not None
@pytest.mark.isolated(group="singleton_tests")
def test_singleton_reuse():
db = DatabaseConnection.get_instance()
# Same instance as previous test in groupSafely modify signal handlers and other process-level settings:
@pytest.mark.isolated
def test_signal_handlers():
import signal
signal.signal(signal.SIGTERM, custom_handler)
# Won't interfere with pytest or other testsGroup expensive operations while maintaining isolation from other tests:
@pytest.mark.isolated(group="database")
class TestDatabase:
def test_migration(self):
db.migrate() # Expensive operation, runs once
def test_query(self):
# Reuses same DB from test_migration
result = db.query("SELECT 1")
assert result == [(1,)]Isolate tests that might crash from C code:
@pytest.mark.isolated
def test_numpy_operation():
import numpy as np
# If this segfaults, other tests still run
result = np.array([1, 2, 3])
assert len(result) == 3Failed tests automatically capture and display stdout/stderr:
@pytest.mark.isolated
def test_failing():
print("Debug info")
assert FalseWorks with standard pytest reporters:
pytest --junitxml=report.xml --durations=10Fixtures: Module/session fixtures run in each subprocess group. Cannot share fixture objects between parent and subprocess; share JSON values with Shared Fixtures.
Debugging: Use --no-isolation to run all tests in the main process for easier debugging with pdb or IDE debuggers.
Performance: Subprocess creation adds ~100-500ms per group. Group related tests to minimize overhead. Only mark tests that need isolation.
pytest-isolated uses a forward-by-default (blacklist) model:
- Some options are blocked with a clear
UsageErrorwhen isolation is active. - Some options are supported but handled in the parent process (not forwarded).
- All remaining options (including custom plugin options) are forwarded to children.
See Incompatible options reference for the full list and workarounds.
To collect coverage from isolated tests, enable subprocess tracking in pyproject.toml:
[tool.coverage.run]
parallel = true
concurrency = ["subprocess"]See the coverage.py subprocess documentation for details.
pytest --isolated-timeout=30Timeout errors are clearly reported with the group name and timeout duration.
If a subprocess crashes, tests in that group are marked as failed with exit code information.
import os
if os.environ.get("PYTEST_RUNNING_IN_SUBPROCESS") == "1":
# Running in subprocess
passTests timing out: Increase timeout with --isolated-timeout=600
Interleaved output with -s: With capture disabled, Children write straight to the
Parent's terminal (as with plain pytest), so concurrent Children interleave when
--isolated-numprocesses > 1. Nothing is captured for per-test reports in that mode —
drop -s if you want output attached to failures, or drop to serial
(--isolated-numprocesses=1) if you need strictly ordered live output.
Missing output: Use -s or --capture=no to see output from passing tests, or -v for verbose output. pytest-isolated respects pytest's standard capture settings.
Subprocess crashes: Check for segfaults, OOM, or signal issues. Run with -v for details.
- Install pre-commit:
pip install pre-commit && pre-commit install - Run tests:
pytest tests/ -v - Open an issue before submitting PRs for new features
MIT License - see LICENSE file for details.