AI 서버 통합 테스트 규칙. 계약 근거는 docs/spec/integration-tests.md.
- Testcontainers pgvector
0.8.5-pg16사용 (SQLite·H2 금지). 검증 대상이 조건부 UPDATE 영향 행 수·FOR UPDATE·<=>·ON CONFLICTSET 절이라 전부 방언 의존적. digest까지 고정한다 (conftest.py의PGVECTOR_IMAGE) — 태그만 고정하면 같은 태그가 다른 이미지를 가리킬 수 있다. 값의 **정본은 backcompose.yaml**이며 운영 pgvector도 0.8.5다. back이 올리면 이쪽도 따라 올린다. - 외부 API는 인터페이스 레벨 Fake(fakes.py), HTTP mock 아님. 호출 횟수 기록
필수 — "호출 안 함"/"정확히 한 번"이 여러 시나리오의 핵심 단언.
이 규칙은 파이프라인이 client를 무엇으로 대체하는가에 대한 것이다. 두 계층의 구분은
integration-tests.md §4.2 가 정본이다.
client 자신의 HTTP 계층은 §4.2 범위 밖이며 test_client_retry.py가
httpx.MockTransport로 검증한다 — 상태 코드→오류 타입 매핑은 인터페이스 Fake로 볼 수 없고, 그 공백이 429를 영구 오류로·LLM 401을 일시 오류로 둔 채 남긴 원인이었다. - 오류 경로도 Fake로 주입한다 —
raise_exc로TransientError/PermanentError를 넣어 상태가 PROCESSING으로 남는지·해당 단계만 FAILED가 되는지 단언한다. 주입 파라미터를 두고 쓰지 않으면 그 경로는 한 번도 실행되지 않는다. - 격리는 TRUNCATE(conftest.py). 동시성 테스트가 여러 커넥션을 쓰므로 트랜잭션 롤백 격리 불가.
- Profile 문자열 리터럴 금지 —
settingsfixture 경유(model-profile.md §2.1). - 동시성은
on_call훅으로 순서 고정,sleep금지. 모델 호출과 저장 사이 창을 결정론적으로 재현. - 데이터 빌더(builders.py)에 본문 버전 인자를 두지 않는다. 수정은
context_id가 다른 두 State로 표현(계약 §4.2). - 외부 실호출을 CI에 넣지 않는다.
app.smoke.gms_roundtrip은_CHECKS를 스텁으로 교체해 집계·종료 코드·값 미노출 규약만 검증하고, 스크립트 실행 경로는 클라이언트 클래스를 스텁으로 갈아 끼워 검증한다. 실제 GMS 왕복은 배포 절차에서 수동 실행한다 — 실호출을 CI에 넣으면 AI API 가용성이 CI 성패에 들어온다. if __name__ == "__main__"아래는runpy로 검증한다. import로는 한 줄도 실행되지 않는다.runpy.run_module(..., run_name="__main__")은 새 네임스페이스에서 모듈을 다시 실행하므로 캐시된 모듈에 건 패치가 보이지 않는다 —monkeypatch.setattr("app.client.embedding_client.EmbeddingClient", ...)처럼 원본 모듈의 속성을 갈아 끼워야 새 네임스페이스의from ... import가 그것을 집는다.- coverage는 line·branch 각각 80% 이상이며
tools/check_coverage_gate.py가 CI에서 판정한다.# pragma: no cover·omit으로 분모를 줄이지 않는다(CONTRIBUTING.md 검증 절).
| 파일 | 계층 | DB |
|---|---|---|
test_unit.py |
오류 분류(상태 코드 표·백오프 수열)·TOP-K·LLM 매핑·Profile 검증·GMS_BASE_URL 형식·스모크 집계와 스크립트 실행·coverage 게이트 판정 |
없음 |
test_repo.py |
조건부 UPDATE rowcount·UPSERT·delete-insert·검색 Query | 실제 |
test_bootstrap.py |
Preset 적재 멱등성·ON CONFLICT SET 절·Profile 주입·python -m 실행 |
실제 |
test_lifespan.py |
기동 조립·Preset 0건 기동 중단·종료 시 풀 반납 | 실제 |
test_api.py |
202·검색 형식·422·401·프로브(/health 불변, /ready 200/503) |
실제 |
test_pipeline.py |
§16 시나리오 전체 | 실제 |
test_bootstrap.py·test_lifespan.py는 요청 경로 밖이라 파이프라인·API 테스트로는 실행되지 않는다(test_api.py는 lifespan을 우회하고app.state에 Fake를 직접 꽂는다). 초기 기준선에서 두 파일이 덮는 영역이 각각 0%·58%였다. lifespan 테스트는 진짜 클라이언트를 조립하는지를 단언하므로 Fake로 바꾸지 않는다 — 생성자는 IO를 하지 않으므로 실호출 금지 규칙과 충돌하지 않는다.
test_client_retry.py도 위 §5 계층 밖이다 — client 호출 단위 방어(failure-recovery.md §3.1) 계층이며 DB·Docker·네트워크가 필요 없다.RetryPolicy의sleep·jitter를 주입해 백오프 수열을 값으로 단언하므로 실제로 잠들지 않는다. 재시도 테스트에 실제 대기를 넣지 않는다.
test_llm_vendors.py도 같은 계층이다 — 판정 벤더 폴백(failure-recovery.md §3.4).httpx.MockTransport로 세 프로바이더의 응답 봉투를 직접 만든다. 인터페이스 레벨 Fake로는 "429를 받고 다른 벤더로 넘어갔는가"를 볼 수 없다 — 그 전환은 HTTP 응답에서 시작한다. 폴백 순서·모델명을 이 파일에 리터럴로 쓰지 않는다. 순서를 바꾸는 것은 설정 변경이고, 테스트가 특정 순서에 묶이면 그 변경이 테스트 실패로 나타난다. 설정 기본값이 무엇인지는test_unit.py가 한 곳에서 단언한다.
test_ci_image_publish_contract.py는 위 §5 계층에 속하지 않는다 —.github/workflows/ai-ci.yml을 계약으로 고정하는 CI 이미지 발행 계약이며 인프라 파트(-20) 소관이다.pytest tests/범위엔 포함되나 DB·Docker가 필요 없다(pytest tests/test_ci_image_publish_contract.py만 따로 돌리면 컨테이너 없이 검증). AI 파트가ai-ci.yml을 바꾸면 이 테스트가 깨지므로 인프라에 요청·조율한다.
pytest tests/ -v # Docker 필요(Testcontainers가 pgvector 기동)