From b14322addac9d049fec6821e8bb2c6ca28b84402 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Sun, 27 Sep 2026 10:39:26 +0000 Subject: [PATCH] test: move TTL expiry on a controlled clock instead of sleeping A clock fixture in tests/conftest.py points the memory backend's time.time() and the session and state modules' datetime.now() at a settable clock. The TTL tests advance it instead of sleeping; tests that also run against a live Redis still sleep there, since the server keeps its own time. The sweeper tests shorten the cleanup interval, the JWT expiry test creates its session in the past because PyJWT reads the real time, and the Memcached dead-timeout test shortens _DEAD_TIMEOUT. The suite without live servers drops from about 25.5 s to 3.6 s. --- tests/backends/test_memcached.py | 14 ++++++-- tests/backends/test_memory.py | 39 +++++++++++++++------- tests/conftest.py | 55 ++++++++++++++++++++++++++++++++ tests/session/test_jwt.py | 13 ++++++-- tests/session/test_manager.py | 8 ++--- tests/session/test_models.py | 10 +++--- tests/state/test_manager.py | 10 +++--- tests/test_cache_manager.py | 29 ++++++++++------- tests/test_proxybackend.py | 7 ++-- 9 files changed, 136 insertions(+), 49 deletions(-) diff --git a/tests/backends/test_memcached.py b/tests/backends/test_memcached.py index 01d2f5b..317a5e4 100644 --- a/tests/backends/test_memcached.py +++ b/tests/backends/test_memcached.py @@ -322,13 +322,21 @@ async def test_memcached_keeps_raising_after_a_connection_failure(call) -> None: @pytest.mark.asyncio -async def test_memcached_tries_a_failed_server_again_after_the_dead_timeout() -> None: - """A failed server is not skipped for pymemcache's default 60 seconds (#197).""" +async def test_memcached_tries_a_failed_server_again_after_the_dead_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failed server is not skipped for pymemcache's default 60 seconds (#197). + + The backend hands `_DEAD_TIMEOUT` to pymemcache, which times it with the + real clock, so the test shortens it rather than wait the documented second. + """ + assert _DEAD_TIMEOUT == 1 # the delay BACKENDS.md documents + monkeypatch.setattr("fastapi_cachex.backends.memcached._DEAD_TIMEOUT", 0.05) backend = _unreachable_backend() with pytest.raises(ConnectionRefusedError): await backend.get("trip") - await asyncio.sleep(_DEAD_TIMEOUT + 0.1) + await asyncio.sleep(0.1) with pytest.raises(ConnectionRefusedError): await backend.get("k") diff --git a/tests/backends/test_memory.py b/tests/backends/test_memory.py index b4fc3d4..1b8070a 100644 --- a/tests/backends/test_memory.py +++ b/tests/backends/test_memory.py @@ -13,6 +13,15 @@ from fastapi_cachex.types import COUNTER_FINGERPRINT from fastapi_cachex.types import CacheEntry from fastapi_cachex.types import counter_entry +from tests.conftest import Clock + + +async def _until(condition: Callable[[], bool]) -> None: + """Give the background sweeper up to a second of real time to act.""" + for _ in range(100): + if condition(): + return + await asyncio.sleep(0.01) @pytest_asyncio.fixture @@ -96,7 +105,7 @@ async def test_memory_backend_clear(memory_backend: MemoryBackend): @pytest.mark.asyncio -async def test_memory_backend_ttl_expiry(memory_backend: MemoryBackend): +async def test_memory_backend_ttl_expiry(memory_backend: MemoryBackend, clock: Clock): key = "test_key" value = CacheEntry( fingerprint="test_etag", @@ -106,14 +115,14 @@ async def test_memory_backend_ttl_expiry(memory_backend: MemoryBackend): ttl = 1 await memory_backend.set(key, value, ttl) - await asyncio.sleep(2) # Wait for the TTL to expire + clock.advance(1.5) retrieved_value = await memory_backend.get(key) assert retrieved_value is None @pytest.mark.asyncio -async def test_memory_backend_cleanup(memory_backend: MemoryBackend): +async def test_memory_backend_cleanup(memory_backend: MemoryBackend, clock: Clock): key1 = "test_key1" value1 = CacheEntry( fingerprint="test_etag1", @@ -131,7 +140,7 @@ async def test_memory_backend_cleanup(memory_backend: MemoryBackend): await memory_backend.set(key1, value1, ttl1) await memory_backend.set(key2, value2, ttl2) - await asyncio.sleep(2) # Wait for the TTL of key1 to expire + clock.advance(1.5) # past key1's TTL, well within key2's await memory_backend.cleanup() retrieved_value1 = await memory_backend.get(key1) @@ -262,7 +271,9 @@ def test_aclose_only_cancels_a_task_on_another_loop(): @pytest.mark.asyncio -async def test_memory_backend_cleanup_task_impl(): +async def test_memory_backend_cleanup_task_impl( + clock: Clock, monkeypatch: pytest.MonkeyPatch +): """The sweeper itself has to drop expired entries. Reading the keys back through `get` proves nothing about the sweeper: @@ -271,16 +282,18 @@ async def test_memory_backend_cleanup_task_impl(): dictionary is inspected directly instead, and neither key is ever read. """ backend = MemoryBackend(cleanup_interval=1) + # Sweep every 10 ms of real time; the entries' TTLs run on `clock`. + monkeypatch.setattr(backend, "cleanup_interval", 0.01) expiring = CacheEntry(fingerprint="test_etag1", content=b"test_value1") surviving = CacheEntry(fingerprint="test_etag2", content=b"test_value2") await backend.set("expiring", expiring, ttl=1) await backend.set("surviving", surviving, ttl=60) + clock.advance(1.5) backend.start_cleanup() try: - # One full interval plus the entry's own TTL. - await asyncio.sleep(2) + await _until(lambda: "expiring" not in backend.cache) assert "expiring" not in backend.cache assert backend.cache["surviving"].value == surviving @@ -735,7 +748,9 @@ async def test_memory_delete_many_counts_only_existing_keys( @pytest.mark.asyncio -async def test_write_only_use_starts_the_cleanup_task(): +async def test_write_only_use_starts_the_cleanup_task( + clock: Clock, monkeypatch: pytest.MonkeyPatch +): """A backend that is only written to still needs its sweeper running. Only `get` used to start it, so a write-mostly caller — `StateManager` @@ -743,12 +758,14 @@ async def test_write_only_use_starts_the_cleanup_task(): expired entries with nothing to remove them. """ backend = MemoryBackend(cleanup_interval=1) + monkeypatch.setattr(backend, "cleanup_interval", 0.01) await backend.set("gone", CacheEntry(fingerprint="e", content=b"v"), ttl=1) await backend.set("stays", CacheEntry(fingerprint="e", content=b"v"), ttl=60) + clock.advance(1.5) try: - await asyncio.sleep(2) + await _until(lambda: "gone" not in backend.cache) # Nothing here ever calls `get`, so only the sweeper can have removed # the expired key — a task that merely exists would leave it in place. @@ -759,7 +776,7 @@ async def test_write_only_use_starts_the_cleanup_task(): @pytest.mark.asyncio -async def test_get_evicts_the_expired_entry_it_skips(): +async def test_get_evicts_the_expired_entry_it_skips(clock: Clock): """A miss on an expired key must also free the memory it was holding. Asserting that `get` returns `None` says nothing about this: it returns @@ -771,7 +788,7 @@ async def test_get_evicts_the_expired_entry_it_skips(): await backend.set("k", CacheEntry(fingerprint="e", content=b"v"), ttl=1) try: - await asyncio.sleep(1.05) + clock.advance(1.05) assert await backend.get("k") is None assert "k" not in backend.cache diff --git a/tests/conftest.py b/tests/conftest.py index df00e67..f31bcc1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,22 @@ +import asyncio +import time +from datetime import datetime +from datetime import tzinfo +from types import SimpleNamespace + import pytest import pytest_asyncio +from fastapi_cachex.backends import memory +from fastapi_cachex.backends.base import BaseCacheBackend from fastapi_cachex.backends.memory import MemoryBackend from fastapi_cachex.manager_proxy import CacheManagerProxy from fastapi_cachex.proxy import BackendProxy +from fastapi_cachex.session import manager as session_manager +from fastapi_cachex.session import models as session_models from fastapi_cachex.session.proxy import SessionManagerProxy +from fastapi_cachex.state import manager as state_manager +from fastapi_cachex.state import models as state_models from fastapi_cachex.state.proxy import StateManagerProxy # Every proxy is a process-wide singleton, so whatever one test installs is @@ -43,3 +55,46 @@ def reset_proxy_singletons(): yield for proxy in _PROXIES: proxy.set(None) + + +class Clock: + """The wall clock the library reads, moved by hand instead of by sleeping.""" + + def __init__(self) -> None: + self.now = time.time() + + def time(self) -> float: + return self.now + + def advance(self, seconds: float) -> None: + self.now += seconds + + async def wait(self, seconds: float, backend: BaseCacheBackend) -> None: + """Let `seconds` pass as far as `backend` can tell. + + A live server expires keys on its own clock, which this one cannot + move, so for anything but the memory backend the time really passes. + """ + self.advance(seconds) + if not isinstance(backend, MemoryBackend): + await asyncio.sleep(seconds) + + +@pytest.fixture +def clock(monkeypatch: pytest.MonkeyPatch) -> Clock: + """Point every time read behind a TTL or expiry check at a `Clock`. + + That is `time.time()` in the memory backend and `datetime.now()` in the + session and state modules. PyJWT and live servers keep real time. + """ + clock = Clock() + + class ClockDatetime(datetime): + @classmethod + def now(cls, tz: tzinfo | None = None) -> datetime: # type: ignore[override] + return datetime.fromtimestamp(clock.now, tz) + + monkeypatch.setattr(memory, "time", SimpleNamespace(time=clock.time)) + for module in (session_manager, session_models, state_manager, state_models): + monkeypatch.setattr(module, "datetime", ClockDatetime) + return clock diff --git a/tests/session/test_jwt.py b/tests/session/test_jwt.py index c33a31b..70dc811 100644 --- a/tests/session/test_jwt.py +++ b/tests/session/test_jwt.py @@ -5,7 +5,7 @@ from __future__ import annotations -import asyncio +from typing import TYPE_CHECKING import pytest @@ -15,6 +15,9 @@ from fastapi_cachex.session.manager import SessionManager from fastapi_cachex.session.models import SessionUser +if TYPE_CHECKING: + from tests.conftest import Clock + jwt = pytest.importorskip("jwt") @@ -79,13 +82,17 @@ async def test_jwt_wrong_audience_rejected() -> None: @pytest.mark.asyncio -async def test_jwt_expiration_enforced() -> None: +async def test_jwt_expiration_enforced(clock: Clock) -> None: backend = MemoryBackend() config = SessionConfig(secret_key="a" * 32, token_format="jwt", session_ttl=1) manager = SessionManager(backend, config) + # PyJWT checks `exp` against the real time, which `clock` cannot move, so + # the session is created in the past instead: its `exp` has already gone + # by. The backend entry and the session itself go by `clock` and are still + # current, so only the token check can reject it. + clock.advance(-5) _session, token = await manager.create_session(user=SessionUser(user_id="u1")) - await asyncio.sleep(1.2) # JWT should be expired before reaching session checks with pytest.raises(SessionTokenError): diff --git a/tests/session/test_manager.py b/tests/session/test_manager.py index bd5706a..39218c0 100644 --- a/tests/session/test_manager.py +++ b/tests/session/test_manager.py @@ -25,6 +25,7 @@ from fastapi_cachex.session.models import SessionUser from fastapi_cachex.types import CacheEntry from fastapi_cachex.types import CacheItem +from tests.conftest import Clock class DummySerializer: @@ -223,10 +224,8 @@ async def test_user_agent_binding(backend: MemoryBackend) -> None: @pytest.mark.asyncio -async def test_sliding_expiration(backend: MemoryBackend) -> None: +async def test_sliding_expiration(backend: MemoryBackend, clock: Clock) -> None: """Test sliding expiration.""" - import asyncio - config = SessionConfig( secret_key="a" * 32, session_ttl=3600, @@ -240,8 +239,7 @@ async def test_sliding_expiration(backend: MemoryBackend) -> None: original_expiry = session.expires_at - # Sleep briefly to ensure time passes - await asyncio.sleep(0.01) + clock.advance(1) # Set expiry to be past the threshold (only 1000 seconds left vs 3600 TTL) session.expires_at = datetime.now(timezone.utc) + timedelta(seconds=1000) diff --git a/tests/session/test_models.py b/tests/session/test_models.py index 646b5df..f043d2c 100644 --- a/tests/session/test_models.py +++ b/tests/session/test_models.py @@ -11,6 +11,7 @@ from fastapi_cachex.session.models import SessionToken from fastapi_cachex.session.models import SessionUser from fastapi_cachex.session.token_serializers import SimpleTokenSerializer +from tests.conftest import Clock def test_session_user_creation() -> None: @@ -80,20 +81,17 @@ def test_session_is_expired() -> None: assert not session.is_expired() -def test_session_renew() -> None: +def test_session_renew(clock: Clock) -> None: """Test session renewal.""" - import time - session = Session() session.expires_at = datetime.now(timezone.utc) + timedelta(hours=1) old_expiry = session.expires_at - # Sleep briefly to ensure time difference - time.sleep(0.01) + clock.advance(1) session.renew(3600) # Renew for 1 hour - assert session.expires_at >= old_expiry + assert session.expires_at > old_expiry def test_session_regenerate_id() -> None: diff --git a/tests/state/test_manager.py b/tests/state/test_manager.py index 64b41da..c104f39 100644 --- a/tests/state/test_manager.py +++ b/tests/state/test_manager.py @@ -22,6 +22,7 @@ from fastapi_cachex.state.manager import StateManager from fastapi_cachex.state.models import StateData from fastapi_cachex.types import CacheEntry +from tests.conftest import Clock from tests.live_servers import REDIS_HOST from tests.live_servers import REDIS_PORT from tests.live_servers import requires_redis @@ -339,15 +340,14 @@ async def test_state_reuse_prevention(state_manager: StateManager) -> None: @pytest.mark.asyncio -async def test_get_state_metadata_after_expire(state_manager: StateManager) -> None: +async def test_get_state_metadata_after_expire( + state_manager: StateManager, clock: Clock +) -> None: """Test retrieving metadata from an expired state.""" # Create state with very short TTL state = await state_manager.create_state(ttl=1, metadata={"test": "data"}) - # Wait for state to expire - import asyncio - - await asyncio.sleep(1.1) + await clock.wait(1.1, state_manager.backend) # Try to retrieve metadata - should return None since it's expired retrieved = await state_manager.get_state_metadata(state) diff --git a/tests/test_cache_manager.py b/tests/test_cache_manager.py index 2f18aad..3952608 100644 --- a/tests/test_cache_manager.py +++ b/tests/test_cache_manager.py @@ -17,6 +17,7 @@ from fastapi_cachex.manager_proxy import CacheManagerProxy from fastapi_cachex.proxy import BackendProxy from fastapi_cachex.types import CacheEntry +from tests.conftest import Clock from tests.live_servers import REDIS_HOST from tests.live_servers import REDIS_PORT from tests.live_servers import requires_redis @@ -216,14 +217,14 @@ async def load_user(user_id: int) -> dict[str, int]: @pytest.mark.asyncio async def test_get_or_set_honors_ttl_on_created_value( - cache_manager: CacheManager, + cache_manager: CacheManager, clock: Clock ) -> None: """get_or_set() applies the given ttl to a newly created value.""" result = await cache_manager.get_or_set("key", lambda: "value", ttl=1) assert result == "value" assert await cache_manager.get("key") == "value" - await asyncio.sleep(1.2) + await clock.wait(1.2, cache_manager.backend) assert await cache_manager.get("key") is None @@ -278,24 +279,28 @@ async def test_add_concurrent_callers_have_exactly_one_winner( @pytest.mark.asyncio -async def test_add_ttl_expires_the_claim(cache_manager: CacheManager) -> None: +async def test_add_ttl_expires_the_claim( + cache_manager: CacheManager, clock: Clock +) -> None: """An explicit ttl applies, and once it lapses the key can be added again.""" assert await cache_manager.add("event:1", "first", ttl=1) is True assert await cache_manager.add("event:1", "second", ttl=1) is False - await asyncio.sleep(1.2) + await clock.wait(1.2, cache_manager.backend) assert await cache_manager.add("event:1", "third") is True assert await cache_manager.get("event:1") == "third" @pytest.mark.asyncio -async def test_add_uses_default_ttl(memory_backend: MemoryBackend) -> None: +async def test_add_uses_default_ttl( + memory_backend: MemoryBackend, clock: Clock +) -> None: """add() without an explicit ttl falls back to the manager's default_ttl.""" manager = CacheManager(backend=memory_backend, default_ttl=1) assert await manager.add("event:1", "value") is True - await asyncio.sleep(1.2) + clock.advance(1.2) assert await manager.get("event:1") is None @@ -360,19 +365,19 @@ async def test_has_missing_key_false(cache_manager: CacheManager) -> None: @pytest.mark.asyncio -async def test_ttl_expiry(cache_manager: CacheManager) -> None: +async def test_ttl_expiry(cache_manager: CacheManager, clock: Clock) -> None: """A value set with a short ttl expires and is no longer retrievable.""" await cache_manager.set("key", "value", ttl=1) assert await cache_manager.get("key") == "value" - await asyncio.sleep(1.2) + await clock.wait(1.2, cache_manager.backend) assert await cache_manager.get("key") is None @pytest.mark.asyncio async def test_default_ttl_used_when_not_specified( - memory_backend: MemoryBackend, + memory_backend: MemoryBackend, clock: Clock ) -> None: """set() without an explicit ttl uses the manager's default_ttl.""" BackendProxy.set(memory_backend) @@ -381,21 +386,21 @@ async def test_default_ttl_used_when_not_specified( await manager.set("key", "value") assert await manager.get("key") == "value" - await asyncio.sleep(1.2) + clock.advance(1.2) assert await manager.get("key") is None @pytest.mark.asyncio async def test_explicit_ttl_overrides_default_ttl( - memory_backend: MemoryBackend, + memory_backend: MemoryBackend, clock: Clock ) -> None: """An explicit ttl on set() overrides a long default_ttl.""" BackendProxy.set(memory_backend) manager = CacheManager(default_ttl=3600) await manager.set("key", "value", ttl=1) - await asyncio.sleep(1.2) + clock.advance(1.2) assert await manager.get("key") is None diff --git a/tests/test_proxybackend.py b/tests/test_proxybackend.py index 13e0056..35cdd70 100644 --- a/tests/test_proxybackend.py +++ b/tests/test_proxybackend.py @@ -1,4 +1,3 @@ -import asyncio import threading import time from collections.abc import Iterator @@ -21,6 +20,7 @@ from fastapi_cachex.session.proxy import SessionManagerProxy from fastapi_cachex.state.proxy import StateManagerProxy from fastapi_cachex.types import CacheEntry +from tests.conftest import Clock app = FastAPI() client = TestClient(app) @@ -84,7 +84,7 @@ async def test_endpoint(): @pytest.mark.asyncio -async def test_backend_cleanup(): +async def test_backend_cleanup(clock: Clock): # Run cleanup task in async environment memory_backend = MemoryBackend() BackendProxy.set(memory_backend) @@ -101,8 +101,7 @@ async def test_backend_cleanup(): assert cached_value is not None assert cached_value.content == b"test_value" - # Wait for data to expire (1 second + extra time) - await asyncio.sleep(1.1) + clock.advance(1.1) # Execute cleanup await memory_backend.cleanup()