diff --git a/changelog/13844.bugfix.rst b/changelog/13844.bugfix.rst new file mode 100644 index 00000000000..5a81673c359 --- /dev/null +++ b/changelog/13844.bugfix.rst @@ -0,0 +1,2 @@ +Fixed :envvar:`PYTEST_CURRENT_TEST` to work correctly in multi-threaded test execution (:issue:`13844`). +The environment variable now uses thread-local storage, ensuring each thread sees its own current test. diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index d1090aace89..62be3fe3b7e 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -8,6 +8,7 @@ import dataclasses import os import sys +import threading import types from typing import cast from typing import final @@ -195,6 +196,9 @@ def pytest_runtest_teardown(item: Item, nextitem: Item | None) -> None: _update_current_test_var(item, None) +_current_test_local = threading.local() + + def _update_current_test_var( item: Item, when: Literal["setup", "call", "teardown"] | None ) -> None: @@ -202,14 +206,15 @@ def _update_current_test_var( If ``when`` is None, delete ``PYTEST_CURRENT_TEST`` from the environment. """ - var_name = "PYTEST_CURRENT_TEST" if when: value = f"{item.nodeid} ({when})" - # don't allow null bytes on environment variables (see #2644, #2957) value = value.replace("\x00", "(null)") - os.environ[var_name] = value + _current_test_local.value = value + os.environ["PYTEST_CURRENT_TEST"] = value else: - os.environ.pop(var_name) + if hasattr(_current_test_local, "value"): + del _current_test_local.value + os.environ.pop("PYTEST_CURRENT_TEST", None) def pytest_report_teststatus(report: BaseReport) -> tuple[str, str, str] | None: diff --git a/testing/test_runner.py b/testing/test_runner.py index 0245438a47d..c36a5877bbe 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -1077,6 +1077,76 @@ def test(fix): assert "PYTEST_CURRENT_TEST" not in os.environ +def test_current_test_env_var_thread_safety() -> None: + """Test thread-local PYTEST_CURRENT_TEST in multi-threaded scenarios.""" + import threading + + from _pytest.runner import _current_test_local + from _pytest.runner import _update_current_test_var + + class MockItem: + def __init__(self, nodeid: str): + self.nodeid = nodeid + + results = {} + errors = [] + + def worker(thread_id: int): + try: + item = MockItem(f"test_file_{thread_id}.py::test_func_{thread_id}") + expected = f"test_file_{thread_id}.py::test_func_{thread_id} (call)" + _update_current_test_var(item, "call") # type: ignore[arg-type] + assert os.environ["PYTEST_CURRENT_TEST"] == expected + + env_value = os.environ.get("PYTEST_CURRENT_TEST") + local_value = getattr(_current_test_local, "value", None) + + if local_value != expected: + errors.append( + f"Thread {thread_id}: local expected {expected!r}, got {local_value!r}" + ) + if env_value is None: + errors.append(f"Thread {thread_id}: os.environ not set") + elif expected not in env_value: + errors.append(f"Thread {thread_id}: expected substring in env_value") + + results[thread_id] = local_value + _update_current_test_var(item, None) # type: ignore[arg-type] + except Exception as e: + errors.append(f"Thread {thread_id} exception: {e}") + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, "\n".join(errors) + assert len(results) == 10 + for i in range(10): + assert f"test_func_{i} (call)" in results[i] # type: ignore[operator] + assert "PYTEST_CURRENT_TEST" not in os.environ + + +def test_current_test_env_var_cleanup() -> None: + """Test that thread-local and os.environ are cleaned up.""" + from _pytest.runner import _current_test_local + from _pytest.runner import _update_current_test_var + + class MockItem: + def __init__(self): + self.nodeid = "test_module.py::test_func" + + item = MockItem() + _update_current_test_var(item, "call") # type: ignore[arg-type] + assert hasattr(_current_test_local, "value") + assert os.environ["PYTEST_CURRENT_TEST"] == "test_module.py::test_func (call)" + + _update_current_test_var(item, None) # type: ignore[arg-type] + assert not hasattr(_current_test_local, "value") + assert "PYTEST_CURRENT_TEST" not in os.environ + + class TestReportContents: """Test user-level API of ``TestReport`` objects."""