You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

377 lines
12 KiB

"""
Tests for unified lifespan handling.
"""
import warnings
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any
import pytest
from fastapi import FastAPI, Request
from fastapi.exceptions import FastAPIDeprecationWarning
from fastapi.lifespan import (
LifespanError,
UnifiedLifespanManager,
create_unified_lifespan,
merge_lifespan_state,
)
from fastapi.testclient import TestClient
# =============================================================================
# Test Fixtures
# =============================================================================
@pytest.fixture
def startup_shutdown_log():
"""Fixture to track startup/shutdown execution order."""
return []
# =============================================================================
# UnifiedLifespanManager Tests
# =============================================================================
class TestUnifiedLifespanManager:
"""Tests for UnifiedLifespanManager class."""
@pytest.mark.anyio
async def test_basic_lifespan_execution(self, startup_shutdown_log):
"""Test basic lifespan execution order."""
@asynccontextmanager
async def lifespan(app):
startup_shutdown_log.append("startup")
yield {"key": "value"}
startup_shutdown_log.append("shutdown")
manager = UnifiedLifespanManager(lifespan=lifespan, emit_deprecation_warnings=False)
with warnings.catch_warnings():
warnings.simplefilter("ignore", FastAPIDeprecationWarning)
async with manager.lifespan(None) as state:
assert state == {"key": "value"}
startup_shutdown_log.append("running")
assert startup_shutdown_log == ["startup", "running", "shutdown"]
@pytest.mark.anyio
async def test_on_event_handlers_execution(self, startup_shutdown_log):
"""Test on_event handlers execute correctly."""
async def startup1():
startup_shutdown_log.append("startup1")
async def startup2():
startup_shutdown_log.append("startup2")
async def shutdown1():
startup_shutdown_log.append("shutdown1")
async def shutdown2():
startup_shutdown_log.append("shutdown2")
manager = UnifiedLifespanManager(
on_startup=[startup1, startup2],
on_shutdown=[shutdown1, shutdown2],
emit_deprecation_warnings=False,
)
async with manager.lifespan(None) as state:
startup_shutdown_log.append("running")
# Startup in order, shutdown in reverse order
assert startup_shutdown_log == [
"startup1",
"startup2",
"running",
"shutdown2",
"shutdown1",
]
@pytest.mark.anyio
async def test_startup_failure_rollback(self, startup_shutdown_log):
"""Test rollback when startup fails."""
async def startup1():
startup_shutdown_log.append("startup1")
async def startup2():
startup_shutdown_log.append("startup2_failed")
raise ValueError("Startup failed")
async def shutdown1():
startup_shutdown_log.append("shutdown1")
async def shutdown2():
startup_shutdown_log.append("shutdown2")
manager = UnifiedLifespanManager(
on_startup=[startup1, startup2],
on_shutdown=[shutdown1, shutdown2],
emit_deprecation_warnings=False,
)
with pytest.raises(LifespanError) as exc_info:
async with manager.lifespan(None):
startup_shutdown_log.append("should_not_run")
# Rollback should clean up startup1 only (shutdown1)
assert "startup1" in startup_shutdown_log
assert "startup2_failed" in startup_shutdown_log
assert "should_not_run" not in startup_shutdown_log
# Rollback runs cleanup for completed handlers
assert "shutdown1" in startup_shutdown_log
# Check error details
assert exc_info.value.phase == "startup"
assert isinstance(exc_info.value.original_error, ValueError)
@pytest.mark.anyio
async def test_mixed_patterns_warning(self):
"""Test warning when mixing lifespan and on_event."""
@asynccontextmanager
async def lifespan(app):
yield
with pytest.warns(FastAPIDeprecationWarning, match="not recommended"):
UnifiedLifespanManager(
on_startup=[lambda: None],
lifespan=lifespan,
)
# =============================================================================
# State Merging Tests
# =============================================================================
class TestStateMerging:
"""Tests for lifespan state merging."""
def test_merge_child_overrides_parent(self):
"""Test default behavior: child overrides parent."""
parent = {"shared": "parent", "parent_only": True}
child = {"shared": "child", "child_only": True}
result = merge_lifespan_state(parent, child, child_overrides_parent=True)
assert result["shared"] == "child" # child wins
assert result["parent_only"] is True
assert result["child_only"] is True
def test_merge_parent_overrides_child(self):
"""Test alternative behavior: parent overrides child."""
parent = {"shared": "parent", "parent_only": True}
child = {"shared": "child", "child_only": True}
result = merge_lifespan_state(parent, child, child_overrides_parent=False)
assert result["shared"] == "parent" # parent wins
assert result["parent_only"] is True
assert result["child_only"] is True
def test_merge_none_states(self):
"""Test merging with None states."""
assert merge_lifespan_state(None, None) == {}
assert merge_lifespan_state({"a": 1}, None) == {"a": 1}
assert merge_lifespan_state(None, {"b": 2}) == {"b": 2}
# =============================================================================
# FastAPI Integration Tests
# =============================================================================
class TestFastAPILifespanIntegration:
"""Integration tests with FastAPI app."""
def test_lifespan_context_manager(self):
"""Test modern lifespan context manager pattern."""
events = []
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[dict[str, Any], None]:
events.append("startup")
yield {"db": "connection"}
events.append("shutdown")
app = FastAPI(lifespan=lifespan)
@app.get("/")
def index():
return {"status": "ok"}
with TestClient(app) as client:
events.append("running")
response = client.get("/")
assert response.status_code == 200
assert events == ["startup", "running", "shutdown"]
def test_on_event_deprecation_warning(self):
"""Test that on_event emits deprecation warning."""
app = FastAPI()
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
@app.on_event("startup")
def startup():
pass
# Check that at least one warning was raised
assert len(w) >= 1
# Check that one of them is about on_event deprecation
deprecation_warnings = [
warning for warning in w
if "on_event" in str(warning.message) and "deprecated" in str(warning.message)
]
assert len(deprecation_warnings) >= 1
def test_on_event_handlers_still_work(self):
"""Test that on_event handlers still execute (backwards compatibility)."""
events = []
app = FastAPI()
# Suppress the deprecation warnings for this test
with warnings.catch_warnings():
warnings.simplefilter("ignore")
@app.on_event("startup")
def startup():
events.append("startup")
@app.on_event("shutdown")
def shutdown():
events.append("shutdown")
@app.get("/")
def index():
return {"status": "ok"}
with TestClient(app) as client:
events.append("running")
response = client.get("/")
assert response.status_code == 200
assert "startup" in events
assert "running" in events
assert "shutdown" in events
def test_state_accessible_in_endpoints(self):
"""Test that lifespan state is accessible in endpoints."""
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[dict[str, Any], None]:
yield {"db_connection": "active"}
app = FastAPI(lifespan=lifespan)
@app.get("/state")
def get_state(request: Request):
# State should be accessible via request.state
return {"has_state": hasattr(request.state, "db_connection")}
with TestClient(app) as client:
response = client.get("/state")
assert response.status_code == 200
# =============================================================================
# create_unified_lifespan Tests
# =============================================================================
class TestCreateUnifiedLifespan:
"""Tests for create_unified_lifespan function."""
@pytest.mark.anyio
async def test_create_from_lifespan_only(self):
"""Test creating unified lifespan from lifespan context only."""
events = []
@asynccontextmanager
async def lifespan(app):
events.append("start")
yield {"key": "value"}
events.append("stop")
unified = create_unified_lifespan(lifespan=lifespan)
with warnings.catch_warnings():
warnings.simplefilter("ignore", FastAPIDeprecationWarning)
async with unified(None) as state:
events.append("running")
assert state == {"key": "value"}
assert events == ["start", "running", "stop"]
@pytest.mark.anyio
async def test_create_from_handlers_only(self):
"""Test creating unified lifespan from handlers only."""
events = []
async def on_start():
events.append("start")
async def on_stop():
events.append("stop")
unified = create_unified_lifespan(
on_startup=[on_start],
on_shutdown=[on_stop],
)
with warnings.catch_warnings():
warnings.simplefilter("ignore", FastAPIDeprecationWarning)
async with unified(None) as state:
events.append("running")
assert "start" in events
assert "running" in events
assert "stop" in events
# =============================================================================
# LifespanError Tests
# =============================================================================
class TestLifespanError:
"""Tests for LifespanError exception."""
def test_error_message_formatting(self):
"""Test error message includes relevant information."""
def my_handler():
pass
error = LifespanError(
"Test error",
phase="startup",
handler=my_handler,
original_error=ValueError("Original"),
)
error_str = str(error)
assert "startup" in error_str
assert "my_handler" in error_str
assert "Original" in error_str
def test_error_attributes(self):
"""Test error attributes are accessible."""
original = ValueError("test")
error = LifespanError(
"Test",
phase="shutdown",
original_error=original,
)
assert error.phase == "shutdown"
assert error.original_error is original