|
@@ -1,8 +1,9 @@
|
|
|
from __future__ import annotations
|
|
|
|
|
|
import functools
|
|
|
+import inspect
|
|
|
from contextlib import contextmanager
|
|
|
-from typing import Any, Callable, Generator, Iterable, Tuple
|
|
|
+from typing import Any, Callable, Generator, Iterable, Tuple, cast
|
|
|
from unittest import TestCase
|
|
|
|
|
|
import pytest
|
|
@@ -17,34 +18,26 @@ TestMethod = Callable[..., None]
|
|
|
class SiloModeTest:
|
|
|
"""Decorate a test case that is expected to work in a given silo mode.
|
|
|
|
|
|
- By default, the test is executed if the environment is in that silo mode or
|
|
|
- in monolith mode. The test is skipped in an incompatible mode.
|
|
|
-
|
|
|
- If the SILO_MODE_SPLICE_TESTS environment flag is set, any decorated test
|
|
|
- class will be modified by having new test methods inserted. These new
|
|
|
- methods run in the given modes and have generated names (such as
|
|
|
- "test_response__in_region_silo"). This can be used in a dev environment to
|
|
|
- test in multiple modes conveniently during a single test run. Individually
|
|
|
- decorated methods and stand-alone functions are treated as normal.
|
|
|
+ Tests marked to work in monolith mode are always executed.
|
|
|
+ Tests marked additionally to work in silo or control mode only do so when either
|
|
|
+ 1. the test is marked as stable=True
|
|
|
+ 2. the test is being run with SILO_MODE_UNSTABLE_TESTS=1
|
|
|
"""
|
|
|
|
|
|
def __init__(self, *silo_modes: SiloMode) -> None:
|
|
|
self.silo_modes = frozenset(silo_modes)
|
|
|
- self.splice = bool(settings.SILO_MODE_SPLICE_TESTS)
|
|
|
+ self.run_unstable_tests = bool(settings.SILO_MODE_UNSTABLE_TESTS)
|
|
|
|
|
|
@staticmethod
|
|
|
def _find_all_test_methods(test_class: type) -> Iterable[Tuple[str, TestMethod]]:
|
|
|
for attr_name in dir(test_class):
|
|
|
- if attr_name.startswith("test_"):
|
|
|
+ if attr_name.startswith("test_") or attr_name == "test":
|
|
|
attr = getattr(test_class, attr_name)
|
|
|
if callable(attr):
|
|
|
yield attr_name, attr
|
|
|
|
|
|
- def _create_mode_methods_to_splice(
|
|
|
- self, test_method: TestMethod
|
|
|
- ) -> Iterable[Tuple[str, TestMethod]]:
|
|
|
- for mode in self.silo_modes:
|
|
|
-
|
|
|
+ def _create_mode_methods(self, test_method: TestMethod) -> Iterable[Tuple[str, TestMethod]]:
|
|
|
+ def method_for_mode(mode: SiloMode) -> Iterable[Tuple[str, TestMethod]]:
|
|
|
def replacement_test_method(*args: Any, **kwargs: Any) -> None:
|
|
|
with override_settings(SILO_MODE=mode):
|
|
|
test_method(*args, **kwargs)
|
|
@@ -54,31 +47,63 @@ class SiloModeTest:
|
|
|
replacement_test_method.__name__ = modified_name
|
|
|
yield modified_name, replacement_test_method
|
|
|
|
|
|
- def _splice_mode_methods(self, test_class: type) -> type:
|
|
|
+ for mode in self.silo_modes:
|
|
|
+ yield from method_for_mode(mode)
|
|
|
+
|
|
|
+ def _add_silo_modes_to_methods(self, test_class: type) -> type:
|
|
|
for (method_name, test_method) in self._find_all_test_methods(test_class):
|
|
|
- for (new_name, new_method) in self._create_mode_methods_to_splice(test_method):
|
|
|
- setattr(test_class, new_name, new_method)
|
|
|
+ for (new_method_name, new_test_method) in self._create_mode_methods(test_method):
|
|
|
+ setattr(test_class, new_method_name, new_test_method)
|
|
|
return test_class
|
|
|
|
|
|
- def __call__(self, decorated_obj: Any) -> Any:
|
|
|
+ def __call__(self, decorated_obj: Any = None, stable: bool = False) -> Any:
|
|
|
+ if decorated_obj:
|
|
|
+ return self._call(decorated_obj, stable)
|
|
|
+
|
|
|
+ def receive_decorated_obj(f: Any) -> Any:
|
|
|
+ return self._call(f, stable)
|
|
|
+
|
|
|
+ return receive_decorated_obj
|
|
|
+
|
|
|
+ def _mark_parameterized_by_silo_mode(self, test_method: TestMethod) -> TestMethod:
|
|
|
+ def replacement_test_method(*args: Any, **kwargs: Any) -> None:
|
|
|
+ with override_settings(SILO_MODE=kwargs.pop("silo_mode")):
|
|
|
+ return test_method(*args, **kwargs)
|
|
|
+
|
|
|
+ orig_sig = inspect.signature(test_method)
|
|
|
+ new_test_method = functools.update_wrapper(replacement_test_method, test_method)
|
|
|
+ if "silo_mode" not in orig_sig.parameters:
|
|
|
+ new_params = tuple(orig_sig.parameters.values()) + (
|
|
|
+ inspect.Parameter("silo_mode", inspect.Parameter.KEYWORD_ONLY),
|
|
|
+ )
|
|
|
+ new_sig = orig_sig.replace(parameters=new_params)
|
|
|
+ new_test_method.__setattr__("__signature__", new_sig)
|
|
|
+ return cast(
|
|
|
+ TestMethod,
|
|
|
+ pytest.mark.parametrize("silo_mode", [mode for mode in self.silo_modes])(
|
|
|
+ new_test_method
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ def _call(self, decorated_obj: Any, stable: bool) -> Any:
|
|
|
is_test_case_class = isinstance(decorated_obj, type) and issubclass(decorated_obj, TestCase)
|
|
|
is_function = callable(decorated_obj)
|
|
|
if not (is_test_case_class or is_function):
|
|
|
raise ValueError("@SiloModeTest must decorate a function or TestCase class")
|
|
|
|
|
|
- if self.splice and is_test_case_class:
|
|
|
- return self._splice_mode_methods(decorated_obj)
|
|
|
+ # Only run non monolith tests when they are marked stable or we are explicitly running for that mode.
|
|
|
+ if not stable and not self.run_unstable_tests:
|
|
|
+ # In this case, simply force the current silo mode (monolith)
|
|
|
+ return decorated_obj
|
|
|
|
|
|
- current_silo_mode = SiloMode.get_current_mode()
|
|
|
- is_skipped = (
|
|
|
- current_silo_mode != SiloMode.MONOLITH and current_silo_mode not in self.silo_modes
|
|
|
- )
|
|
|
- reason = f"Test case is not part of {current_silo_mode} mode"
|
|
|
- return pytest.mark.skipif(is_skipped, reason=reason)(decorated_obj)
|
|
|
+ if is_test_case_class:
|
|
|
+ return self._add_silo_modes_to_methods(decorated_obj)
|
|
|
+
|
|
|
+ return self._mark_parameterized_by_silo_mode(decorated_obj)
|
|
|
|
|
|
|
|
|
-control_silo_test = SiloModeTest(SiloMode.CONTROL)
|
|
|
-region_silo_test = SiloModeTest(SiloMode.REGION)
|
|
|
+control_silo_test = SiloModeTest(SiloMode.CONTROL, SiloMode.MONOLITH)
|
|
|
+region_silo_test = SiloModeTest(SiloMode.REGION, SiloMode.MONOLITH)
|
|
|
|
|
|
|
|
|
@contextmanager
|