ASGI 앱 테스트에서 HTTP 200만 확인하면 시작 단계의 실패를 놓칠 수 있습니다. Starlette의 TestClient는 with로 사용해야 lifespan을 실행합니다. HTTPX의 ASGITransport는 앱의 시작·종료를 실행하지 않습니다. 작은 팀이라면 상태 코드 검사 옆에 시작 → 요청 → 종료 순서와 시작 실패가 요청 전에 드러나는지를 확인하는 테스트를 하나씩 두는 것이 좋습니다.
확인일: 2026년 10월 14일. 새 릴리스 소개가 아니라, 기존 테스트의 빠진 부분을 찾는 실험입니다. Linux의 Python 3.12.14, Starlette 1.6.0, HTTPX 0.28.1, AnyIO 4.14.2에서 아래 코드를 실행했습니다. 외부 서버·DB·유료 API는 사용하지 않았습니다.
200 응답과 앱 초기화는 서로 다른 검사입니다
예제의 /health는 항상 정상 응답을 내는 단순한 라우트입니다. DB 연결이나 모델 준비 상태를 확인하지 않습니다. 시작 함수가 실행되지 않아도 이 라우트는 200을 돌려줄 수 있습니다. 실제 서비스의 모든 health endpoint가 이렇게 동작한다는 뜻은 아닙니다.
이번 실험은 시작 함수를 일부러 실패하게 만든 뒤에도 상태 코드만 보는 테스트가 통과하는지 비교합니다. 응답 본문과 별도로 이벤트 목록을 남겨, 라우트 실행과 lifespan 실행을 구별합니다. 각 경우마다 새 앱과 새 목록을 만들어 앞선 실행 결과가 다음 검사를 가리지 않게 했습니다.
실행 전에 버전과 경고를 확인하세요
테스트에 사용한 조합은 현재 권장 설치 명령이 아닙니다. Starlette 1.6.0에서도 일반 httpx를 이용한 TestClient 사용은 지원되지만 deprecated 경고가 나며 httpx2를 권합니다. 아래 코드는 이 경고를 모아서 출력합니다. httpx2 및 최신 Starlette로 같은 결과를 검증하지는 않았습니다. 기존 프로젝트의 잠금 파일을 유지한 상태에서 재현하고, 의존성 교체는 별도 변경으로 검증하세요.
Starlette 1.6.0은 Python 3.10 이상, HTTPX 0.28.1은 Python 3.8 이상을 요구하며 두 패키지는 BSD-3-Clause 라이선스입니다. 이 글의 실행 검증은 Python 3.12 환경에 한정됩니다.
여섯 가지 경로를 한 파일로 비교하기
필요한 패키지가 이미 있는 테스트 환경에서 아래를 lifespan_probe.py로 저장한 뒤 python lifespan_probe.py로 실행합니다. assert를 사용하는 검사이므로 python -O로 실행하지 마세요. http://test는 인프로세스 전송의 기준 URL이며 네트워크 서버에 접속하지 않습니다.
"""Reproduce ASGI lifespan test blind spots with installed, real packages.
No network calls, installs, pytest, or hand-written ASGI protocol driver.
Run with: python lifespan_probe.py
"""
import asyncio
from contextlib import asynccontextmanager
from importlib.metadata import version
import json
import platform
import warnings
import httpx
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
with warnings.catch_warnings(record=True) as recorded_warnings:
warnings.simplefilter("always")
from starlette.testclient import TestClient
def make_app(*, broken_startup=False):
events = []
@asynccontextmanager
async def lifespan(app):
events.append("startup")
if broken_startup:
raise RuntimeError("startup failed")
try:
yield
finally:
events.append("shutdown")
async def health(request):
events.append("request")
return JSONResponse({"ok": True})
app = Starlette(routes=[Route("/health", health)], lifespan=lifespan)
return app, events
def run_probe():
results = []
# Case 1: closing a non-context TestClient does not start a lifespan.
app, events = make_app()
client = TestClient(app)
try:
status = client.get("/health").status_code
finally:
client.close()
assert status == 200 and events == ["request"]
results.append({"case": "TestClient without with", "status": status, "events": events})
# Case 2: assert ordering inside AND outside the context.
app, events = make_app()
with TestClient(app) as client:
assert events == ["startup"]
status = client.get("/health").status_code
assert events == ["startup", "request"]
assert status == 200 and events == ["startup", "request", "shutdown"]
results.append({"case": "TestClient with with", "status": status, "events": events})
async def run_async_case(*, broken_startup):
app, events = make_app(broken_startup=broken_startup)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
) as client:
status = (await client.get("/health")).status_code
assert status == 200 and events == ["request"]
results.append({
"case": "ASGITransport, broken startup" if broken_startup else "ASGITransport",
"status": status,
"events": events,
})
# Cases 3/4: AsyncClient's own context manages its transport, not app lifespan.
asyncio.run(run_async_case(broken_startup=False))
asyncio.run(run_async_case(broken_startup=True))
# Case 5: a deliberately impossible startup stays invisible to status-only checks.
app, events = make_app(broken_startup=True)
client = TestClient(app)
try:
status = client.get("/health").status_code
finally:
client.close()
assert status == 200 and events == ["request"]
results.append({"case": "TestClient without with, broken startup", "status": status, "events": events})
# Case 6: proper lifespan handling rejects the app before the request runs.
app, events = make_app(broken_startup=True)
try:
with TestClient(app) as client:
raise AssertionError("The context should fail before the first request")
except RuntimeError as exc:
assert str(exc) == "startup failed" and events == ["startup"]
results.append({"case": "TestClient with with, broken startup", "raised": str(exc), "events": events})
return {
"environment": {
"python": platform.python_version(),
"httpx": version("httpx"),
"starlette": version("starlette"),
"anyio": version("anyio"),
},
"warnings": [str(w.message) for w in recorded_warnings],
"passed_cases": len(results),
"results": results,
}
if __name__ == "__main__":
print(json.dumps(run_probe(), indent=2))
예상과 실제 결과
여섯 경우 모두 아래 예상과 일치했고 passed_cases는 6이었습니다.
- TestClient, with 없음: 200,
["request"].close()를 호출해도 앱 시작이 소급 실행되지는 않았습니다. - TestClient, with 사용: 진입 직후
["startup"], 요청 뒤["startup", "request"], 블록 종료 뒤["startup", "request", "shutdown"]. - AsyncClient + ASGITransport: 200,
["request"]. AsyncClient 자체를async with로 닫아도 앱 lifespan은 실행되지 않았습니다. - 시작 실패 앱 + ASGITransport: 여전히 200,
["request"]. - 시작 실패 앱 + with 없는 TestClient: 여전히 200,
["request"]. - 시작 실패 앱 + with TestClient: 첫 요청 전에
RuntimeError("startup failed"), 이벤트는["startup"]. 시작에 실패해yield까지 도달하지 않았으므로 이 예제의 종료 표시는 없습니다.
이 결과는 HTTPX의 문서 및 고정 버전의 Starlette 소스와 일치합니다. HTTPX ASGITransport는 HTTP 요청 scope를 구성하고, Starlette TestClient는 컨텍스트에 들어갈 때 lifespan을 시작하고 startup 완료를 기다립니다.
기존 테스트에는 어디부터 적용할까?
- 앱 준비가 필요한 테스트부터 고릅니다. 시작 단계에서 설정 검사·연결 준비·모델 로딩을 하는 앱이라면 단순한 200 검사와 준비 완료 검사를 분리하세요. 실제 상태를 확인하는 readiness 검사가 있다면 그것도 함께 유지합니다.
- 동기 테스트의 TestClient 수명을 명시합니다.
with TestClient(app) as client:안에서 준비 상태와 요청을 확인하고, 블록 밖에서 정리 콜백의 결과를 확인합니다. 이 글에서는 pytest fixture나 실제 DB를 검증하지 않았습니다. - 실패 경로를 남깁니다. 시작이 실패하도록 만든 테스트가 요청 전에 실패하는지 확인합니다. 성공 사례 하나만으로는 lifespan 누락을 알아채기 어렵습니다.
- 변경 범위를 작게 유지합니다. lifespan 검사를 먼저 한 묶음에 추가하고, 의존성 업데이트나 전체 fixture 개편은 분리하세요. 회귀가 생기면 해당 테스트·fixture 변경을 되돌릴 수 있게 커밋을 나누되, 새로 드러난 시작 실패를 숨기는 방식으로 통과시키지는 마세요.
비동기 테스트와 운영 환경은 별도 확인이 필요합니다
TestClient는 별도 이벤트 루프를 사용합니다. 테스트가 만든 비동기 DB 연결을 앱과 공유해야 한다면 동기 TestClient로 바꾸는 것만으로 해결되지 않을 수 있습니다. HTTPX의 비동기 테스트를 유지하면서 lifespan을 별도로 관리하는 경로가 필요합니다. HTTPX 문서는 asgi-lifespan의 LifespanManager를 안내하지만, 이 글에서는 설치하거나 실행하지 않았습니다.
이벤트 목록의 shutdown은 등록한 정리 콜백이 실행됐다는 증거입니다. 실제 DB 연결·소켓·백그라운드 작업이 모두 정리됐다는 보장은 아닙니다. 네트워크 서버, 프록시, 컨테이너 종료 신호, 운영 배포 준비 상태도 이번 인프로세스 실험의 검증 범위 밖입니다.
실무에서 남길 기준은 간단합니다. 라우트의 200, 앱의 시작 완료, 종료 콜백 실행을 각각 확인하세요. 그래야 시작 코드가 아예 실행되지 않은 테스트를 정상 기동 검증으로 오해하지 않습니다.
댓글
댓글 쓰기