서비스 학습
SOURCE · be/tests/test_health.py · 2026-09-06

살아 있음과 준비됨을
서로 다른 질문으로 고정한다

다섯 개의 테스트가 /healthz와 /readyz의 계약을 붙잡는다. 핵심은 추론 서버 없이도 두 endpoint의 동작을 전부 검증한다는 점이다.

이 파일을 만든 이유

상태 endpoint는 사람이 거의 보지 않는다. 대신 container orchestrator가 몇 초마다 호출하고, 그 결과로 container를 죽이거나 트래픽을 끊는다. 응답 형태가 조용히 바뀌면 서비스가 멀쩡한데도 재시작이 반복되거나, 반대로 고장 난 container에 계속 요청이 들어간다.

이 파일은 그 계약을 코드로 고정한다. 특히 “추론 서버가 죽어도 /healthz는 200이어야 한다”는 규칙은 사람이 기억하기 어렵고 틀리기 쉬워서, 테스트로 남기지 않으면 반드시 깨진다.

전체 코드

from fastapi.testclient import TestClient

from app.core.config import Settings
from app.api.dependencies import get_inference_engine
from app.engines.base import InferenceError, ModelInfo
from app.main import create_app


def make_test_client() -> TestClient:
    settings = Settings(
        service_name="local-moe-backend-test",
        service_version="test-version",
        environment="test",
        log_level="WARNING",
    )
    return TestClient(create_app(settings))


def test_healthcheck_returns_stable_liveness_contract() -> None:
    with make_test_client() as client:
        response = client.get("/healthz")

    assert response.status_code == 200
    assert response.headers["cache-control"] == "no-store"
    assert response.json() == {
        "status": "ok",
        "service": "local-moe-backend-test",
        "version": "test-version",
        "environment": "test",
    }


def test_unknown_route_is_not_disguised_as_healthy() -> None:
    with make_test_client() as client:
        response = client.get("/")

    assert response.status_code == 404


def test_openapi_contains_healthcheck_contract() -> None:
    with make_test_client() as client:
        document = client.get("/openapi.json").json()

    assert "/healthz" in document["paths"]
    schema = document["components"]["schemas"]["HealthResponse"]
    assert set(schema["required"]) == {"status", "service", "version", "environment"}


class ReadyEngine:
    async def check_ready(self) -> ModelInfo:
        return ModelInfo(id="gemma-4-26b-a4b-it")


class UnavailableEngine:
    async def check_ready(self) -> ModelInfo:
        raise InferenceError("unavailable")


def test_readiness_returns_200_when_inference_is_reachable() -> None:
    application = create_app(Settings(environment="test", log_level="WARNING"))
    application.dependency_overrides[get_inference_engine] = lambda: ReadyEngine()

    with TestClient(application) as client:
        response = client.get("/readyz")

    assert response.status_code == 200
    assert response.headers["cache-control"] == "no-store"
    assert response.json() == {
        "status": "ready", "dependency": "inference", "error_code": None,
    }


def test_readiness_returns_503_but_liveness_stays_200() -> None:
    application = create_app(Settings(environment="test", log_level="WARNING"))
    application.dependency_overrides[get_inference_engine] = lambda: UnavailableEngine()

    with TestClient(application) as client:
        readiness_response = client.get("/readyz")
        liveness_response = client.get("/healthz")

    assert readiness_response.status_code == 503
    assert readiness_response.json() == {
        "status": "not_ready",
        "dependency": "inference",
        "error_code": "unavailable",
    }
    assert liveness_response.status_code == 200

import 해설

from fastapi.testclient import TestClient

실제 TCP port를 열지 않고 application을 호출하는 도구다. HTTP 요청을 만들어 ASGI application에 직접 전달하므로 uvicorn을 띄우지 않아도 route·의존성·응답 직렬화가 그대로 실행된다. 빠르고, port 충돌이 없다.

from app.api.dependencies import get_inference_engine

함수 자체를 사전의 열쇠로 쓰기 위해 가져온다. 아래 dependency_overrides에서 “이 함수 대신 저것을 써라”라고 지정할 때 필요하다.

from app.engines.base import InferenceError, ModelInfo

가짜 engine이 진짜와 같은 형태로 성공·실패를 흉내 내기 위한 재료다. 성공은 ModelInfo를 반환하고, 실패는 InferenceError를 던진다.

make_test_client — 왜 설정을 직접 만드는가

def make_test_client() -> TestClient:
    settings = Settings(
        service_name="local-moe-backend-test",
        service_version="test-version",
        environment="test",
        log_level="WARNING",
    )
    return TestClient(create_app(settings))

create_app(settings)에 설정을 인수로 밀어 넣는다. 이것이 application factory를 쓰는 이유다. 환경변수나 .env 파일을 건드리지 않으므로, 개발자의 로컬 환경이 어떻든 테스트 결과가 같다.

값을 일부러 눈에 띄게 지었다. "test-version"이 응답에 그대로 보이면 설정이 실제로 응답까지 흘러갔다는 증거가 된다. "0.1.0"처럼 실제 기본값과 같은 값을 쓰면 주입이 동작하지 않아도 테스트가 통과해 버린다.

테스트 1~3 — liveness 계약

test_healthcheck_returns_stable_liveness_contract

세 가지를 한 번에 본다. status code 200, cache-control: no-store 헤더, 그리고 JSON 전체 일치다. ==로 dict를 통째로 비교하므로 필드가 하나라도 늘거나 줄면 실패한다. assert response.json()["status"] == "ok"처럼 일부만 검사하면 계약이 조용히 커지는 것을 못 잡는다.

no-store가 중요한 이유는 상태 응답이 캐시되면 죽은 서버가 살아 있다고 보고될 수 있기 때문이다.

test_unknown_route_is_not_disguised_as_healthy

GET /가 404인지 확인한다. 이상하게 들리지만 실제로 있는 실수를 막는다. 정적 파일 서빙이나 catch-all route를 잘못 붙이면 존재하지 않는 경로가 200을 돌려주게 되고, 그러면 health check가 어떤 경로를 호출해도 통과해 의미를 잃는다.

test_openapi_contains_healthcheck_contract

응답만 보는 것이 아니라 /openapi.jsonHealthResponse schema가 실렸는지 확인한다. set(schema["required"])순서를 무시하고 필드 집합만 비교한다. 필드 순서는 계약이 아니지만 필드 목록은 계약이기 때문이다.

가짜 engine 두 개 — 추론 서버 없이 시험하기

class ReadyEngine:
    async def check_ready(self) -> ModelInfo:
        return ModelInfo(id="gemma-4-26b-a4b-it")


class UnavailableEngine:
    async def check_ready(self) -> ModelInfo:
        raise InferenceError("unavailable")

InferenceEngine상속하지 않는다. engines/base.py의 계약이 Protocol이기 때문이다. Protocol은 “이 method를 가지고 있으면 그것으로 충분하다”는 구조적 판정이라, 상속 선언 없이도 같은 자리에 쓸 수 있다.

두 class는 check_ready 하나만 구현한다. generate는 없다. /readyz가 그것만 호출하기 때문이다. 테스트용 대역은 실제로 쓰이는 만큼만 만들면 된다.

테스트 4~5 — readiness와 의존성 교체

application = create_app(Settings(environment="test", log_level="WARNING"))
application.dependency_overrides[get_inference_engine] = lambda: ReadyEngine()

with TestClient(application) as client:
    response = client.get("/readyz")
dependency_overrides[get_inference_engine] = lambda: ReadyEngine()

FastAPI가 route를 실행하기 직전에 이 사전을 확인한다. 열쇠가 있으면 원래 함수 대신 등록된 함수를 부른다. route 코드는 전혀 몰라도 되고, request.app.state를 직접 조작할 필요도 없다.

lambda: ReadyEngine()인 이유는 override 값이 호출 가능한 것이어야 하기 때문이다. ReadyEngine()을 바로 넣으면 객체가 들어가 FastAPI가 호출을 시도하다 실패한다.

with TestClient(application) as client

with가 있어야 lifespan이 실행된다. 없으면 route는 동작하지만 startup 구간이 건너뛰어진다. 다만 여기서는 override 덕분에 실제 lifespan이 만든 engine을 쓰지 않으므로, with의 역할은 “실제 서버와 같은 수명 주기를 거치게 한다”는 데 있다.

test_readiness_returns_503_but_liveness_stays_200

이 파일에서 가장 중요한 테스트다. 같은 client로 두 endpoint를 연달아 호출/readyz는 503, /healthz는 200임을 확인한다. 두 신호가 붙어 버리면 추론 서버 재시작 때마다 FastAPI container까지 죽는다.

실제 실행 결과

cd be
.venv/bin/python -m pytest tests/test_health.py -v
tests/test_health.py::test_healthcheck_returns_stable_liveness_contract PASSED
tests/test_health.py::test_unknown_route_is_not_disguised_as_healthy    PASSED
tests/test_health.py::test_openapi_contains_healthcheck_contract        PASSED
tests/test_health.py::test_readiness_returns_200_when_inference_is_reachable PASSED
tests/test_health.py::test_readiness_returns_503_but_liveness_stays_200 PASSED

5 passed
이 파일5 passed상태 endpoint 계약
전체 backend47 passed2026-09-06 기준
추론 서버불필요가짜 engine으로 대체

TurboFieldfareServer가 꺼져 있어도 5개 모두 통과한다. 이것이 의존성 주입으로 얻은 실질적 이득이다. 반대로 말하면 이 테스트들은 실제 Gemma 생성이 동작함을 증명하지 않는다. 그 확인은 smoke.py의 몫이다.

자주 발생하는 오류

증상원인진단
AttributeError: inference_enginewith 없이 TestClient를 쓰고 override도 없음lifespan이 안 돌아 state가 비었다. with를 쓰거나 override를 등록한다
/readyz가 늘 200override에 객체를 넣음 (ReadyEngine())호출 가능한 것이어야 한다. lambda: ReadyEngine()으로 감싼다
JSON 비교 실패schema에 필드를 추가하고 테스트는 그대로계약이 바뀐 것이므로 테스트도 함께 고치는 것이 맞다
다른 테스트 결과가 섞임dependency_overrides를 전역 app에 등록각 테스트가 create_app으로 새 application을 만들어 격리한다

설계 선택과 대안

왜 실제 추론 서버를 띄워 검사하지 않는가?

모델 적재에만 수십 초가 걸리고 8GB 환경에서는 메모리 압력도 크다. 무엇보다 이 테스트가 묻는 것은 “추론이 되는가”가 아니라 “추론이 안 될 때 올바르게 보고하는가”다. 실패 상황을 실제로 만들려면 오히려 서버를 꺼야 한다.

monkeypatchapp.state를 직접 바꾸면?

가능하지만 테스트가 저장 위치를 알게 된다. 나중에 engine을 app.state가 아닌 다른 곳에 두면 테스트가 전부 깨진다. dependency_overridesroute가 실제로 쓰는 통로를 그대로 이용하므로 구현 변경에 덜 민감하다.

pytest.mark.parametrize를 쓰지 않는가?

다섯 테스트가 각각 다른 질문을 하고 실패했을 때 알려야 할 내용도 다르다. 같은 검사를 값만 바꿔 반복하는 경우가 아니므로 이름 있는 함수로 두는 편이 실패 메시지를 읽기 쉽다. 값 반복이 실제로 있는 test_config.py와 비교해 보면 차이가 보인다.

이전 단계와 다음 파일

여기서 검증한 응답 형태의 정의는 schemas/system.py, 구현은 routes/system.py에 있다. 설정이 잘못된 값을 거부하는지 보는 테스트는 test_config.py, 실제 TCP를 쓰는 회귀 테스트는 test_readiness_isolation.py로 이어진다.

← 개요Backend 검증 목록다음 →설정 검증