책임: 유닛 + 통합/E2E를 결정적으로 돌리는 픽스처·관례. 이음새(worker) 검증을 보장.
해소: 리뷰 D1·D2·D8·A5. 기여 방침은 CONTRIBUTING.md.
★ 핵심 결함(D1): 현 통합테스트가
ASGITransport(app=app)만 써서 lifespan 미기동 → worker 미동작 → 가짜green. 본 스펙이 이를 금지·교정한다.
tests/
├─ conftest.py # 공통 픽스처(임시DB·TARGETS·lifespan)
├─ unit/ # 부품(GT-store, GT-queue, GT-config, GT-1 일부)
├─ integration/ # 합류(GT-2,3,loss,seed,status)
├─ e2e/ # Playwright(GT-E2E, GT-real)
└─ fixtures/
├─ real_page.html
├─ nate_rank_list.html # m.news.nate.com/rank/list 1회 캡처
└─ nate_article.html
통합/E2E는 반드시 lifespan을 띄워 worker가 돌게 한다:
# 권장(무의존): starlette TestClient — 컨텍스트 매니저로 쓰면 lifespan(init_db+worker) 실행됨
from fastapi.testclient import TestClient
with TestClient(app) as c: # ← lifespan 기동(worker 동작)
c.put("/api/v1/collect", json=...)
# (선택) async가 필요하면 asgi-lifespan(LifespanManager) 추가 — 기본은 TestClient로 의존성 추가 없이- 금지: lifespan 없이 collect→stored 단언(워커가 없어 영원히 received).
- 적재 대기: 폴링(
await until stored, timeout=2s)으로 단언(고정 sleep 지양). - ✅ 종료 경합(★ D-worker-safe-cancel, v1.2.1에서 근본 수정됨):
PUT /collect직후 곧바로with TestClient(app)블록을 빠져나가면, 워커가 소비해run_in_executor로 넘긴 sqlite 스레드가 아직 in-flight인 채 lifespan shutdown(워커 task cancel)이 진행될 수 있었다.asyncio.Task.cancel()은 이미 던져진 스레드풀 작업을 멈추지 못하고(스레드는 계속 실행),asyncio.shield()로도 못 막는다(asyncio.Runner.close()의_cancel_all_tasks()가 루프의 모든 태스크를 강제 취소 — shield된 태스크도 예외 없이 포함). 그 상태에서get_db_session_ctx의finally: conn.close()가 같은 커넥션을 다른 스레드가 여전히 쓰는 도중 실행돼(check_same_thread=False) C레벨 레이스로 세그폴트했다(반복 PUT+즉시 종료로 재현 확인, exit 139). 수정:main.py의 두 백그라운드 워커(background_queue_worker·background_retention_worker)가 DB 작업 구간을asyncio.Event(_queue_worker_idle/_retention_worker_idle)로 감싸고, lifespan shutdown은worker_task.cancel()전에 그 이벤트를 기다린다(_wait_worker_idle, 최대 5s) — 워커가 안전한 지점(큐 대기 중)으로 돌아온 뒤에만 취소한다. 재현 스크립트로 800회 연속 무크래시 확인. 이제 테스트는 별도 sleep/폴링 없이PUT직후 바로with TestClient(app)블록을 빠져나가도 안전하다.
- 임시 DB:
settings.DB_URL = sqlite:///<tmp>.db, 테스트 전후.db/.db-wal/.db-shm정리. TARGETS: 테스트용 등록(example.com + 캡처 nate). 실타겟은 별도(아래).
@pytest.mark.live: 실타겟(m.nate.com) 실제 수집. CI 기본 skip, 사람 승인 시-m live로만 실행(specs/08).@pytest.mark.e2e: Playwright 필요.
- 테스트 함수명 또는 도크스트링에 GT-id 명시:
def test_..._GT_seed():또는"""GT-seed: ...""". - 게이트(
scripts/verify.py)가 GT 라벨 존재를 검사(문자열). → 올바른 테스트에 붙여야 추적성이 진짜다.
| GT | 의미 | 붙일 테스트(정본) |
|---|---|---|
| GT-1 | payload 발행(command) | test_api.py::test_command_api_registered_target |
| GT-2 | 수집 수신·멱등 | test_api.py::test_collection_flow_and_query (또는 store upsert 멱등) |
| GT-3 | 2단계(action↔script) 일치·조회 | 2-stage 수집 후 stage별 일치 단언 테스트 |
| GT-loss | write-ahead 무손실 | test_api.py write-ahead 적재 테스트 |
| GT-seed | worker→stored 이음새 | test_gt_seed.py |
| GT-loader/register/correlate | 식별 | test_identity.py 해당 |
| GT-E2E | 브라우저 풀 사이클 | tests/e2e/test_e2e_cycle.py |
GT-3 정본이 없으면 2-stage 일치 테스트를 신설해 붙인다(가짜 라벨 금지).
- 픽스처(jsdom/정적 HTML):
swipe는scroll과 동등 처리로 검증. - 실브라우저(Playwright): 터치 스와이프(
page.touchscreen/마우스 드래그)로 1-50위 로드 확인.
- ❌ lifespan 없이 합류 단언 ❌
assert True/항상통과 ❌ 외부 네트워크 의존(live제외) ❌ 테스트 간 DB 누수.
같은 테스트 함수가 TestClient(/LifespanManager(lifespan 기동 경로) 없이 httpx.ASGITransport(app=app)만
쓰면서 status == "stored" 등가비교를 단언하면 게이트가 FAIL로 적발한다(D1 함정 재발 방지 — 위 규칙을
사람이 매번 기억하지 않아도 됨). assert "stored" in data처럼 키 존재만 확인하는 in 비교는 대상이 아니다.
- 본 하네스가 GT-seed/GT-E2E/GT-real을 실제로 합류 검증하도록 보장(
tests/e2e·tests/integration).