diff --git a/api/tests/unit_tests/conftest.py b/api/tests/unit_tests/conftest.py index db8ba7e1809..977f9a93079 100644 --- a/api/tests/unit_tests/conftest.py +++ b/api/tests/unit_tests/conftest.py @@ -2,9 +2,11 @@ from __future__ import annotations import os import shutil +import sys from collections.abc import Callable, Iterator from pathlib import Path from tempfile import TemporaryDirectory +from types import ModuleType from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch @@ -53,16 +55,20 @@ if TYPE_CHECKING: def _patch_redis_clients_on_loaded_modules() -> None: - """Ensure any module-level redis_client references point to the shared redis_mock.""" + """Rebind concrete module globals without invoking module-level ``__getattr__``. - import sys - - for module in list(sys.modules.values()): - if module is None: + Inspect each time so late imports, reloads, and newly added globals are still + covered. Attribute lookup on thousands of unrelated lazy modules is costly + and can itself trigger imports; only stored references need rebinding. + """ + for module in sys.modules.copy().values(): + if not isinstance(module, ModuleType): continue - for client_attribute in ("redis_client", "_pubsub_redis_client"): - if hasattr(module, client_attribute): - setattr(module, client_attribute, redis_mock) + namespace = module.__dict__ + if "redis_client" in namespace: + namespace["redis_client"] = redis_mock + if "_pubsub_redis_client" in namespace: + namespace["_pubsub_redis_client"] = redis_mock @pytest.fixture diff --git a/api/tests/unit_tests/test_redis_fixtures.py b/api/tests/unit_tests/test_redis_fixtures.py new file mode 100644 index 00000000000..ad3a4be91c0 --- /dev/null +++ b/api/tests/unit_tests/test_redis_fixtures.py @@ -0,0 +1,43 @@ +import sys +from types import ModuleType + +import pytest + +from tests.unit_tests.conftest import _patch_redis_clients_on_loaded_modules, redis_mock + + +@pytest.mark.parametrize("attribute", ["redis_client", "_pubsub_redis_client"]) +def test_redis_rebinding_covers_new_globals_and_replaced_modules( + monkeypatch: pytest.MonkeyPatch, attribute: str +) -> None: + name = "_redis_fixture_probe" + module = ModuleType(name) + monkeypatch.setitem(sys.modules, name, module) + _patch_redis_clients_on_loaded_modules() + + # A module can gain a reference after it was already seen by another test. + setattr(module, attribute, object()) + _patch_redis_clients_on_loaded_modules() + assert vars(module)[attribute] is redis_mock + + # Rebind on every test, even when the set of imported modules is unchanged. + setattr(module, attribute, object()) + _patch_redis_clients_on_loaded_modules() + assert vars(module)[attribute] is redis_mock + + replacement = ModuleType(name) + setattr(replacement, attribute, object()) + monkeypatch.setitem(sys.modules, name, replacement) + _patch_redis_clients_on_loaded_modules() + assert vars(replacement)[attribute] is redis_mock + + +def test_redis_rebinding_does_not_resolve_lazy_module_attributes(monkeypatch: pytest.MonkeyPatch) -> None: + module = ModuleType("_lazy_redis_fixture_probe") + + def resolve(name: str) -> object: + pytest.fail(f"Redis rebinding must not import unrelated lazy attributes: {name}") + + vars(module)["__getattr__"] = resolve + monkeypatch.setitem(sys.modules, module.__name__, module) + _patch_redis_clients_on_loaded_modules()