Test FAST API app with lifespan

There are two common strategies to mock FastAPI lifespan services in tests:

A) Patch the resources the lifespan creates (preferred, minimal intrusion) B) Replace the whole lifespan function with a test double

A) Patch resources created during lifespan Idea: If your lifespan (before yield) creates clients/connections and stores them (e.g., on app.state or via dependency injection), patch those points so real external services aren’t touched.

Typical patterns 1) Patching attributes on app.state - In your lifespan, you might do:

  1. app.state.runtime = AgentRuntime(…)

- In tests, patch these with fakes before the TestClient initializes the app.

Example (pytest)

snippet.python
import pytest
from unittest.mock import Mock, AsyncMock, patch
from fastapi.testclient import TestClient
from dotigent.backend.app.main import app
 
@pytest.fixture
def client_with_fake_runtime():
    fake_runtime = Mock()
    # If your routes call await runtime methods, use AsyncMock for those
    fake_runtime.run_agent = AsyncMock(return_value={"ok": True})
 
    # Patch where the runtime is looked up in your code path.
    # If handlers access app.state.runtime:
    with patch.object(app.state, "runtime", fake_runtime, create=True):
        with TestClient(app) as client:
            yield client

2) Patching dependencies via Depends - If routes use dependency injection (FastAPI Depends), override them in tests:

snippet.python
from fastapi import Depends
 
# app dependency definition (in app code)
def get_runtime():
    return app.state.runtime
 
@app.get("/agents")
async def list_agents(runtime = Depends(get_runtime)):
    return await runtime.list_agents()
 
# test override
@pytest.fixture
def client_override_dep():
    fake_runtime = Mock()
    fake_runtime.list_agents = AsyncMock(return_value=[{"id": "a1"}])
 
    from dotigent.backend.app.main import app, get_runtime
    app.dependency_overrides[get_runtime] = lambda: fake_runtime
    try:
        with TestClient(app) as client:
            yield client
    finally:
        app.dependency_overrides.clear()

3) Patching constructors used in lifespan - If lifespan does AgentRuntime(…) or ExternalClient(…), patch those constructors at the import location used by the lifespan:

snippet.python
@patch("dotigent.backend.app.core.agent_runtime.AgentRuntime")
def test_agents_with_patched_runtime(RuntimeMock):
    instance = RuntimeMock.return_value
    instance.list_agents = AsyncMock(return_value=[{"id":"a1"}])
    from dotigent.backend.app.main import app
    with TestClient(app) as client:
        resp = client.get("/agents")
        assert resp.json() == [{"id":"a1"}]

B) Replace the entire lifespan Idea: If the app uses FastAPI(lifespan=…), you can provide a test lifespan that sets minimal fake resources, yielding immediately without real external setup.

Example: overriding lifespan with a test double

snippet.python
from contextlib import asynccontextmanager
from fastapi.testclient import TestClient
from unittest.mock import Mock, AsyncMock
from dotigent.backend.app.main import app
 
@asynccontextmanager
async def test_lifespan(_app):
    # Setup fake resources
    fake_runtime = Mock()
    fake_runtime.list_agents = AsyncMock(return_value=[{"id": "test"}])
    _app.state.runtime = fake_runtime
    # Signal startup complete
    yield
    # Optionally cleanup
    _app.state.runtime = None
 
def test_with_test_lifespan(monkeypatch):
    # monkeypatch the app.lifespan callable to our test_lifespan
    monkeypatch.setattr(app, "lifespan", test_lifespan)
    with TestClient(app) as client:
        r = client.get("/agents")
        assert r.status_code == 200
        assert r.json() == [{"id":"test"}]

Notes and best practices - Choose patch point closest to consumption:

  1. If routes use app.state.runtime → patch app.state.runtime.
  2. If routes use Depends(getruntime) → use dependencyoverrides.
  3. If lifespan creates the resource via constructor → patch constructor at import path used by lifespan.

- Use AsyncMock for awaited methods to avoid “coroutine not awaited” issues. - Keep overrides localized: clear app.dependency_overrides after tests. - Avoid hitting real external systems in tests; patch network clients, databases, message buses, etc. - If you need to assert that startup code ran, assert the fake resource was set before your request (e.g., assert app.state.runtime is fake).

This mirrors the pattern around yield: your tests provide fakes during the suspended “app running” window, so handlers use them without invoking real services.