codingstairs
노트에듀라이프연락
⌕검색⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

연락하기

로그인 없이도 보낼 수 있어요. 답변이 필요하면 이메일을 함께 적어 주세요.

  • 익명 폼으로 의견 남기기 →
  • ✉ warragon112@gmail.com
  • 카카오톡 오픈채팅 ↗

© 2026 codingstairs

  • 노트
  • 에듀
  • 검색
  • 라이프
  • 연락
  • 약관
  • RSS
  • GitHub
노트›quality

실전 vitest · pytest 인프라

2026-04-28 게시· 2026-05-18 갱신·0회 조회

실전 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 (phantom stores 테이블) 회귀. SQL 문자열에 stores · region_nm 포함 여부 assert.
  • test_product_crawler.py — BUG #6~#8 (컬럼 매핑 + NOT NULL region_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/debug spy + 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.toast 4 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):

  1. sed: 함수명 + await 추가 패턴 일괄 치환
  2. tsc --noEmit: top-level await 금지 + Promise 타입 미스매치 자동 검출
  3. fix: tsc 가 잡은 3 파일에서 sync 함수 → async 시그니처 변경 (호출처도 await 전파)
  4. 재실행 + 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 을 참고합니다.

quality 카테고리의 다른 글

카테고리 전체 보기 →
  • E2E — 라우트 매니페스트 자동 생성
  • GitHub Actions
  • 최소 관측 — 로그·메트릭·트레이스
  • Vitest 와 테스트의 결
  • Testcontainers