실전 vitest · pytest 인프라
실전 vitest · pytest 인프라
테스트 인프라는 한 번에 갖춰지지 않습니다. 한 프로젝트도 출발 시점엔 테스트가 없었고, 회귀가 여러 번 운영을 깨뜨리고 나서야 한 영역씩 인프라를 들였습니다. 이 글은 2026-04-26~27 동안 추가된 vitest (admin) · pytest (python-backend) 인프라의 형태와 의도를 기록합니다.
1. 어디에 무엇을
| 서비스 | 라이브러리 | 위치 | 실행 |
|---|---|---|---|
| frontend/web-app | vitest (4.1.5) | vitest.config.ts 루트 + src/**/*.test.ts |
pnpm vitest run |
| frontend/admin | vitest (4.1.5) — 2026-04-27 신설 (2026-05-01: 9 파일 44 건) | 동상 | pnpm test |
| frontend/cms-app | vitest (4.1.5) — 2026-05-01 신설 (3 파일 45 건: cms·metadata·markdown) | 동상 (environment: node) |
pnpm test |
| frontend/food-app | vitest (4.1.5) — 2026-04-25 신설 (6 파일 29 건: sort·food·useFoodStore·sortStore·sourceStore·exportFoods) | 동상 + Tauri mock 패턴 | pnpm test |
| frontend/language-app | vitest (4.1.5) + jsdom — 2026-05-01 신설 (1 파일 15 건: utils·logger console spy) | 동상 (environment: jsdom) |
pnpm test |
| backend/python-backend | pytest (9.x) — 2026-04-27 신설 | pyproject.toml [dependency-groups].dev + tests/ |
uv sync --group dev && uv run pytest tests/ |
playwright e2e-dev 는 별개입니다 (각 frontend 의 playwright.dev.config.ts). vitest 의 exclude 에 **/tests/e2e-dev/** 명시.
2. pytest 셋업의 모양
pyproject.toml:
[dependency-groups]
dev = [
"pytest>=8.3.0",
"pytest-asyncio>=0.24.0",
"pytest-mock>=3.14.0",
"pytest-httpx>=0.30.0",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
addopts = "-v --tb=short"
tests/conftest.py:
import pytest
from unittest.mock import MagicMock
@pytest.fixture
def mock_db(monkeypatch):
db = MagicMock()
db.fetch_all.return_value = []
db.fetch_one_and_commit.return_value = (1,)
monkeypatch.setattr("crawlers.product_scheduler.get_db", lambda _: db)
return db
monkeypatch 의 표적은 호출 측 모듈 의 import 이름입니다. crawlers.product_scheduler 가 from db_connection import get_db 한 결과물을 교체하는 것이지 db_connection.get_db 자체를 교체하지 않습니다. router 도 마찬가지로 routers.web_app.product.get_db 를 patch.
3. vitest 의 hoisting 함정
vitest 의 vi.mock() 은 파일 최상단으로 호이스팅됩니다. 그래서 다음 코드는 ReferenceError 입니다.
const mockQuery = vi.fn(); // 호이스팅된 vi.mock 보다 늦게 평가됨
vi.mock("@/lib/db", () => ({ pool: { query: mockQuery } }));
vi.hoisted() 로 같이 들어올리면 해결됩니다.
const { mockQuery } = vi.hoisted(() => ({ mockQuery: vi.fn() }));
vi.mock("@/lib/db", () => ({ pool: { query: mockQuery } }));
이 패턴이 admin 의 audit.test.ts · points/actions.test.ts 에 쓰였습니다. server action 류는 거의 다 host module 의 export 를 교체해야 하므로 hoisted 가 필수입니다.
4. 무엇을 테스트 대상으로 골랐나
신설 인프라는 다음 우선순위로 채웠습니다.
① 회귀 자동화 (regression-as-test) — 발견했던 BUG 가 다시 들어오지 않게 합니다.
test_product_scheduler.py— BUG #5 (phantomstores테이블) 회귀. SQL 문자열에stores·region_nm포함 여부 assert.test_product_crawler.py— BUG #6~#8 (컬럼 매핑 + NOT NULLregion_cd누락) 회귀. INSERT 컬럼 + 파라미터 튜플 그대로 검사.audit.test.ts— BUG #3 (request미전달 시 actor null) 회귀.next/headers cookies()fallback 동작 검증.
② 의도 명세 (specification-as-test) — 응답 키·검증 규칙처럼 외부 약속이 깨지면 안 되는 곳.
test_product_router.py—/api/product/findcode응답 키 (name·region·area).points/actions.test.ts—updateBalance의 amount=0 거부 · reason 30 자 제한.
③ 단위 헬퍼 (unit utility) — 입력→출력이 명확한 순수함수.
common.test.ts—formatPrice·formatDateTime·sanitizeText· 멘션 정규식.i18n/sync.test.ts—ko.json↔en.json키 차집합 0.cms-app/markdown.test.ts—generateSlug·addHeadingAnchors·highlightCode(language alias · 미지원 언어 원본 유지 · HTML 엔티티 복원) ·renderMarkdown(GFM 표 · XSS sanitize) 18 건.language-app/utils.test.ts—cn·truncateText·shuffleArray(Fisher–Yates 비파괴) ·logger(console.log/warn/error/debugspy + DEV 가드) 15 건.
④ 외부 통합 헬퍼 (env mock + global stub) — fetch/Tauri 같은 사이드이펙트 wrapper.
admin/blog-revalidate.test.ts—vi.stubEnv로BLOG_REVALIDATE_URL·SECRET분기 +vi.stubGlobal('fetch', vi.fn())로 200/401/네트워크 실패. 6 건.food-app/exportFoods.test.ts—@tauri-apps/plugin-dialog.save·plugin-fs.writeTextFile·sonner.toast4 mock. 사용자 취소(save → null) · 정상 export · 에러 분기. 5 건.
이 셋·넷만 채워도 89/89 PASS 가 자라는데 채 한나절이 안 걸렸습니다. 2026-05-01 기준 누적 admin 44 + cms-app 45 + food-app 29 + language-app 15 + web-app 26+ = 159+ 건.
5. 무엇을 일부러 안 했나
컨테이너 통합 테스트 — 1번 단계는 mock 으로 충분합니다. 실제 DB 가 필요한 테스트는 testcontainers 도입 후로 미뤘습니다.
e2e UI 시나리오 — playwright e2e-dev 의 영역. vitest/pytest 와 별개로 운영합니다.
APScheduler 동작 자체 — lifespan 무력화 fixture 로 우회. cron 은 dev DB 와 격리되니 별도 검증 없습니다.
6. 어떻게 회귀를 잡는가 — 한 사례
BUG #5 가 처음 발견됐을 때 crawlers/product_scheduler.py 만 fix 됐습니다. 며칠 뒤 crawlers/product_crawler.py 에 같은 버그 (stores 테이블) 가 그대로 남아있는 걸 발견했습니다. 이 시점에 두 가지를 더 했습니다.
① tests/test_product_crawler.py 에 SQL 문자열 검증 추가
assert "stores" in sql
② scripts/sql_column_audit.py 신설
routers/+crawlers/ 의 raw SQL 추출 → information_schema.columns 비교
후자는 또 다른 phantom 테이블 (order_tracking_urls · order_tracking_history) 을 잡아냈습니다. 회귀 자동화 한 줄이 카운터 BUG 발견을 부른 셈입니다.
7. 다음에 손댈 자리
- TestClient + pgvector — vector 검색 라우터의 통합 테스트. LM Studio 가 dev 에서 가동 중일 때만 의미 있어 별도 fixture flag 필요합니다.
- playwright e2e-dev → CI — 현재 호스트 dev compose 의존. CI 는 docker-in-docker 또는 dedicated 서비스 컨테이너 필요합니다.
- 벤치마크 / 부하 테스트 — rate limiter 임계값 자체의 적정성. slowapi 의 token bucket 동작은 단위 테스트보단 부하 테스트가 어울립니다.
- desktop-app 백엔드 JUnit 5 — Spring Boot 의
MessageService·MessageRepository·MessageCleanupScheduler가 아직 단위 테스트 없음.@DataJpaTest+ Testcontainers postgres 로 native query (findRecentThreads) 회귀 차단 가치 큼. - food-app/language-app 컴포넌트 테스트 —
@testing-library/react+ jsdom 도입 시ItemCard·HistoryList같은 단순 표시 컴포넌트부터. 라이프사이클·이벤트 핸들러 회귀 방어. - mutation testing — Stryker · pytest-mutpy. 현재 통과율이 실제 결함 검출률을 반영하는지 측정. 159 건의 mutation score 가 50% 미만이면 무효 단언이 많다는 신호.
하고픈 말
테스트 인프라는 한 번에 끝나지 않습니다. 한 영역이 자리잡으면 다음 영역의 형태가 보입니다. 회귀가 두 번 일어난 자리부터 채우면 짧은 시간에 큰 가치가 생깁니다.
warragon 라운드 6~9 사례 (2026-05-04)
vitest 159 건 시점 이후 라운드 6~9 누적으로 코드 테스트 1,226 (frontend vitest 439 + e2e 334 + java @Test 217 + python pytest 197 + MCP 39) 까지 확장. 인프라 결정이 어떻게 진화했는가:
testcontainers 컴파일 게이트 — 30 ControllerTest 동시 신설 시 CI 부담
라운드 8 의 da2ari-api 30 ControllerTest (R8-A1~A4) 가 AbstractIntegrationTest 를 상속하면 PG 17 + 21 supabase migrations 적용 시간이 ~5분. CI PR 단계에서 30 컨테이너 부트는 비현실. 결정: PR 게이트는 ./gradlew :da2ari-api:compileTestJava 컴파일만, 실 실행은 nightly CI 또는 사용자 환경 위임.
// MockMvc smoke 룰 — 시드 부재 시 200/401/4xx 모두 통과
private void assertRouted(int s) {
assertTrue(s >= 200 && s < 600, "라우팅 비정상 status=" + s);
}
5xx 차단 룰만 PROD 진입 게이트. 200 페이로드 검증은 별 라운드 (R7-B1) 에서 read-only public endpoint 만 추가.
pytest monkeypatch — APScheduler 17 잡 멱등성 시뮬레이션
라운드 6-A3 의 tests/test_scheduler_jobs.py 가 17 잡의 mock DB 호출 + 멱등성 검증을 단일 파일로 처리:
def _mock_db(monkeypatch, module_path: str, fetch_all_default=None):
db = MagicMock()
db.fetch_all.return_value = fetch_all_default or []
db.execute_query.return_value = True
monkeypatch.setattr(f"{module_path}.get_db", lambda *_a, **_kw: db)
return db
def test_price_alerts_2회_호출_db_상호작용_정확히_2배(monkeypatch):
db = _mock_db(monkeypatch, "schedulers.price_alert_checker", fetch_all_default=[])
from schedulers.price_alert_checker import check_price_alerts
check_price_alerts()
first = db.fetch_all.call_count
check_price_alerts()
assert db.fetch_all.call_count == first * 2 # WHERE NOT EXISTS 패턴 회귀 차단
UPSERT / ON CONFLICT / WHERE NOT EXISTS 패턴을 mock 호출 카운트로 검증. 실 DB 없이 멱등성 보장.
sed → tsc 사이클 — 51 file 일괄 마이그레이션 SOP
라운드 8-D-jwks 의 verifyJwt → verifyJwtAsync 마이그레이션 (commit aa91c142):
- sed: 함수명 + await 추가 패턴 일괄 치환
- tsc --noEmit: top-level await 금지 + Promise 타입 미스매치 자동 검출
- fix: tsc 가 잡은 3 파일에서 sync 함수 → async 시그니처 변경 (호출처도 await 전파)
- 재실행 + vitest 재검증
이 사이클이 134 호출처를 실수 없이 마이그레이션하는 표준 SOP. 컴파일러를 1차 회귀 검증으로 활용.
1226 tests 의 실측 grep 명령
라운드 6 부터 누적 카운트는 항상 grep 으로 재현 가능:
# frontend vitest
find frontend/{da2ari,admin,pryzeet,dmddksl}/src -name '*.test.ts*' \
-not -path '*/node_modules/*' | xargs grep -hE '^\s*(it|test)\(' | wc -l
# Java @Test
find backend/java-backend -name '*Test.java' -path '*/src/test/*' \
| xargs grep -hc '@Test' | awk '{s+=$1} END {print s}'
# Python pytest
cd backend/python-backend && uv run pytest --collect-only -q | tail -3
선언 카운트 vs 실측 카운트 차이는 회귀 신호. 라운드 1~5 의 선언 585 vs 라운드 6 실측 1,011 의 차이는 (a) MCP 매뉴얼 분리 (b) 라운드별 신설분 누락 (c) 기존 파일 중복 카운트 보정으로 분석됨.
Next
- testcontainers
- vitest-philosophy
Vitest 공식 · pytest 공식 · pytest-asyncio · pytest-mock 을 참고합니다.