ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 테스트 커버리지 11개 프로젝트에 도입하기 — baseline-lock + 외부 wrapper 제외 전략
    IT 2026. 4. 28. 23:30
    테스트 커버리지 11개 프로젝트에 도입하기 — baseline-lock + 외부 wrapper 제외 전략

    왜 또 이걸 하나

    내 홈 서버에는 개인 프로젝트 11~12개가 돌아간다. 지난 며칠간 차례로:

    • ruff로 린트 경고를 0으로
    • mypy로 타입 에러를 0으로
    • 둘 다 Stop/pre-commit hook에 걸어서 재발 방지

    그다음 자연스럽게 떠오른 질문이 있었다. "테스트는 얼마나 충실히 쓰고 있나?"

    지금까지 나는 프로젝트마다 pytest로 테스트를 쓰고 있었다. 총 500~600건. 각각 초록불이 떠야만 기능 개발을 이어갔다. 그런데 정작 어디까지 실제로 실행됐는지는 한 번도 측정하지 않았다. coverage 도구(pytest-cov, coverage.py)가 전부 설치조차 되어 있지 않았다.

    직관적으로는 "적당히 커버하고 있을 것 같다". 하지만 직관은 틀릴 때가 많다. 그래서 확인해보기로 했다.

    1단계 — 일단 수치를 본다

    전역에 pytest-cov를 설치하고, 11개 프로젝트 각각에 간단한 설정을 넣었다:

    [tool.pytest.ini_options]
    addopts = "--cov --cov-report=term-missing --cov-fail-under=<N>"
    
    [tool.coverage.run]
    source = ["."]
    branch = true
    omit = ["tests/*", ".venv/*"]

    여기서 중요한 선택이 하나 있다. source = ["."]을 명시한 것. 이 한 줄이 없으면 pytest가 import한 파일만 측정된다. 테스트가 건드리지 않은 모듈은 카운트에서 통째로 빠진다. 낙관적인 숫자가 나올 수밖에 없다.

    source를 명시하면 프로젝트 전체가 분모에 들어간다. 정직한 수치가 나온다. 초기 측정 결과:

    프로젝트 성격 baseline
    작은 aiohttp 서버 프로젝트 A (단일 파일) 75%
    작은 aiohttp 서버 프로젝트 B (관측/대시보드) 74%
    aiohttp 프록시 프로젝트 C (로컬 LLM) 60%
    LangGraph 에이전트 프로젝트 D 49%
    이미지 처리 파이프라인 프로젝트 E 43%
    음성 전사 파이프라인 프로젝트 F 34%
    텔레그램 게이트웨이 프로젝트 G 31%
    사진 분석 프로젝트 H (외부 API 의존) 20%
    블로그 자동 발행 프로젝트 I (Playwright) 18%
    RAG 시스템 프로젝트 J (Qdrant/vLLM) 14%

    뒤쪽 프로젝트 몇 개를 보고 이런 생각이 들었다. "이 숫자가 정말 우리 코드의 건강도인가?"

    2단계 — 숫자가 거짓말을 하고 있었다

    프로젝트 I(Playwright 기반 블로그 자동화) 18%를 자세히 들여다보니:

    • publisher.py — 423줄, 커버리지 4%. Playwright로 티스토리를 조작하는 브라우저 자동화 코드.
    • thumbnail_renderer.py — 36줄, 22%. SVG를 PNG로 렌더하는 Playwright 코드.

    이 두 파일의 합이 459줄. 전체 875줄 중 절반을 차지한다. 그리고 이 파일들은 테스트할 방법이 명확하지 않다. mock으로 "Playwright 호출이 일어났다"를 검증해봐야 mock이 호출됐음을 확인하는 것 외에 아무 의미가 없다. 실제 브라우저 자동화 성공 여부는 스모크 테스트로만 보장 가능하다.

    RAG 시스템(프로젝트 J) 14%도 마찬가지였다. Qdrant 클라이언트, vLLM 임베딩, Ollama LLM 래퍼 — 이들의 총합이 전체의 60%를 넘었다.

    결론: 외부 서비스와 직접 맞닿는 파일은 커버리지 측정에서 빼야 한다. mock으로 시늉하는 테스트를 쓰는 건 문서만 늘리고 가치는 없다. 대신 우리가 짠 로직에만 커버리지를 집중시킨다.

    omit 대상 — 판단 기준

    각 프로젝트의 파일을 하나씩 살펴보며 omit 목록을 만들었다:

    제외한다 유지한다
    외부 API 클라이언트 (Immich/Ollama/Qdrant 래퍼) 설정·검증·변환 로직
    Playwright 브라우저 조작 코드 CLI 디스패처·파일 선정 로직
    ffmpeg/weasyprint subprocess 래퍼 템플릿 렌더링 로직 (순수)
    GPU 모델 래퍼 (torch 로딩) 파이프라인 오케스트레이션

    판단 기준 한 줄: "이 파일을 mock으로 테스트해봐야 mock이 호출됐다는 사실 외에 검증할 게 없다면 제외한다."

    11개 프로젝트에 omit을 추가한 뒤 재측정했다:

    프로젝트 외부 포함 외부 제외 (first-party) 변화
    J (RAG) 14% 45% +31
    I (블로그 자동화) 18% 30% +12
    H (사진 분석) 20% 27% +7
    E, D 등 43, 49% 43, 52% +0~3

    숫자가 움직인 건 단순히 계산 방식을 바꿨을 뿐이다. 코드 자체는 한 글자도 수정하지 않았다. 그런데 이 숫자가 훨씬 정직하다. "우리가 짠 로직"이 얼마나 테스트되고 있는지를 보여준다.

    3단계 — 큰 0% 파일 몇 개에 테스트 추가

    외부 wrapper를 걷어내고 나니, 남은 0% 짜리 파일들이 더 선명하게 보였다. 외부 의존 없이 순수 로직인데 테스트가 전혀 없는 파일들. 이런 건 ROI가 극명하다:

    프로젝트 J의 validator.py

    RAG 청크 유효성 검증 모듈. 202줄. 전부 정규식 + 문자열 처리. 외부 의존 0개. 그런데 커버리지 0%였다. 47개의 테스트를 새로 쓰고 91%를 달성했다.

    def test_sync_stub_rejected():
        text = (
            "# 일정 제목\n"
            "*Synced from Google Calendar*\n"
            "[원본 링크](https://calendar.google.com/event/abc)\n"
        )
        r = validate_chunk(text, strict=True)
        assert not r.valid
        assert "sync_stub" in r.reasons

    이런 테스트 47개. 한 번 앉아서 쓸 수 있는 분량이고, 이걸로 프로젝트 J 전체가 45% → 55%가 됐다.

    프로젝트 I의 selector.py

    vault 노트 스캔/필터링/정렬 로직. 53줄, 0%. unittest.mock.patch로 config 경로를 tmp_path에 넣고 가짜 md 파일을 뿌려서 테스트했다. 12개 테스트, 0% → 96%.

    작은 것들

    비슷한 방식으로:

    • 프로젝트 J preprocessor.py: 9% → 90%
    • 프로젝트 J metadata.py: 15% → 100%
    • 프로젝트 D parser.py (ReAct 텍스트 파서): 0% → 100%
    • 프로젝트 H onedrive_sha1_verify.py (순수 함수 파트): 0% → 40%
    • 프로젝트 H xlsx_to_mdtable.py (변환 로직): 0% → 38%

    모든 테스트는 외부 서비스를 전혀 건드리지 않는다. subprocess는 mock, 파일 시스템은 tmp_path, config는 patch.object. 실행은 수 초 내로 끝난다.

    4단계 — baseline-lock 전략

    숫자를 올리기 시작하니 또 다른 고민이 왔다. "목표 기준을 얼마로 잡지?"

    커뮤니티의 전형적인 권장은:

    • Google 내부 가이드: 60% acceptable / 75% commendable / 90% exemplary
    • 오픈소스 관행: 80%가 사실상의 표준
    • Martin Fowler: "80~90%가 합리적, 단 수치 자체에 집착 말 것"

    하지만 내 상황에 그대로 적용하기엔 문제가 있었다. 프로젝트마다 커버리지 현황이 30%부터 75%까지 널뛰는데, 전부 "80% 이상"으로 맞추려면 30%짜리 프로젝트엔 수십 시간 분량의 테스트 작성이 필요하다. 그리고 그 시간이 그만한 가치가 있나를 물어보면, 솔직히 아니다. 이건 개인 프로젝트이고 대부분은 외부 서비스 통합이 본업이다.

    그래서 다른 전략을 택했다. baseline-lock.

    각 프로젝트의 현재 커버리지를 floor로 잡는다. 증가는 장려하되 강제하지 않는다. 그 이하로 떨어지면 차단한다.

    구체적으론 각 pyproject.toml에 이렇게 박아 넣었다:

    addopts = "--cov --cov-fail-under=53"  # 프로젝트별로 2%p 버퍼를 둔 숫자

    이 한 줄이면 pytest가 실행될 때마다 커버리지가 53% 이하면 exit non-zero. 그 이상이면 통과.

    왜 2%p 버퍼인가? 측정 노이즈 때문이다. skip 테스트 하나 추가/제거되면 퍼센트가 1~2 움직일 수 있다. 버퍼 없이 정확한 baseline을 박으면 작은 변동에도 훅이 빨간 불을 켠다. 2%p는 "의미 있는 후퇴"와 "노이즈"를 구분하는 완충이다.

    5단계 — Hook이 지킨다

    커버리지 기준을 pyproject.toml에만 두면 개발자가 까먹을 수 있다. "그냥 pytest가 실패했네, skip하자"는 유혹도 있다. 그래서 ruff/mypy와 동일한 패턴으로 Hook을 붙였다.

    파이프라인은 네 개의 훅 이벤트에 걸린다:

    # ~/scripts/hooks/pipelines/coverage.py
    
    TRACKED_PROJECTS = {...11개...}
    
    def track_edit(file_path, data):
        # 대상 프로젝트 .py 편집 감지 → dirty 세트에 등록
        ...
    
    def track_bash(cmd, data):
        # pytest 실행이 exit 0으로 끝나면 dirty 해제
        # (addopts의 --cov-fail-under가 자연 검증)
        ...
    
    def pre_commit(cmd, data):
        # git commit 직전, dirty 프로젝트가 커맨드에 포함돼 있으면
        # pytest 재실행. threshold 미달이면 commit 차단.
        ...
    
    def check_completion(state):
        # 세션 종료(Stop) 시점, dirty 프로젝트마다 pytest 실행.
        # threshold 미달이면 Stop 차단.
        ...

    동작 흐름은 간단하다: .py를 수정했다 → 해당 프로젝트가 "더러움"으로 찍힌다 → 내가 직접 pytest를 돌려 통과시키거나 그냥 세션을 끝내려 하면 훅이 pytest를 대신 돌려본다 → 커버리지가 baseline 밑이면 빨간 불.

    프로젝트마다 자체 .venv가 있는 경우(GPU/외부 라이브러리 때문에) 훅이 자동으로 알맞은 인터프리터를 고른다:

    VENV_PROJECTS = {"F", "G", "J"}  # 자체 venv 필요한 프로젝트
    
    def _python_for(project):
        if project in VENV_PROJECTS:
            return [str(PROJECTS_ROOT / project / ".venv" / "bin" / "python3")]
        return ["python3"]

    최종 현황

    11개 프로젝트의 first-party 커버리지 baseline:

    프로젝트 baseline fail_under
    A 75% 73
    B 74% 71
    C 60% 58
    J 55% 53
    D 53% 51
    E 43% 41
    F 36% 34
    H 33% 31
    (나머지) 30~32% 28~30

    평균 ~47%. 상위 3개(A, B, C)는 커뮤니티 기준의 "편안한 구간"에 들어와 있다. 중간 4개(J, D, E, F)는 50% 전후에서 안정화. 하위 4개는 30% 전후에 머문다. 하위 4개는 대부분 외부 서비스 통합이 본업인 프로젝트라 이 숫자가 "부끄러운 낮음"이 아니라 "정직한 수치"다.

    왜 여기서 멈추나

    이제부터 숫자를 더 올리려면 투자 대비 수익이 급격히 떨어진다. 남은 미커버 코드는 대부분:

    1. 무거운 mocking이 필요한 오케스트레이션 코드
    2. aiohttp HTTP 라우트 핸들러 (test client 셋업 비용)
    3. 특수한 조건에만 실행되는 에러 처리 분기

    이런 영역에 몇 시간을 투자해서 각 프로젝트를 10%p씩 올리는 것보다, 실제로 쓰는 기능을 개선하거나 새 기능을 만드는 게 훨씬 남는 장사다. 개인 프로젝트의 테스트는 "내가 회귀를 일으키지 않도록 지켜주는" 수단이지 문서화도, 경쟁사 차별점도 아니다.

    대신 이 단계에서 확보한 것이 있다. baseline-lock이 자동으로 걸려 있으므로 이 수치에서 절대 뒤로 가지 못한다. 내가 실수로 테스트를 지우거나, 테스트 없이 새 모듈을 추가하면, 훅이 commit/Stop을 막는다. 이게 핵심이다.

    정리 — 세 가지 교훈

    1. source 명시 없이 pytest-cov를 쓰면 숫자가 거짓말을 한다. "import된 것만 센다"는 기본값은 낙관적이지만 부정직하다. source = ["."]가 필수.

    2. 외부 wrapper는 과감히 omit한다. 커버리지 측정의 목적은 "테스트되지 않은 로직 찾기"지 "mock 호출 세기"가 아니다. Playwright/외부 API 클라이언트 같은 건 측정 자체를 안 하는 게 정직하다.

    3. "80% 일괄 목표"보다 "현재에서 후퇴 금지"가 실제로 지켜진다. 엄격해 보이는 규칙이 실제로는 아무도 안 지켜 무력화되는 것보다, 느슨해 보여도 매번 강제되는 규칙이 훨씬 가치 있다. baseline-lock + Hook 조합이 이걸 제공한다.

    린트에서도, 타입 체크에서도, 이제 커버리지에서도 같은 패턴을 반복했다. "현재 수준을 floor로 잡고, 훅이 조용히 지킨다". 이 루프가 돌기 시작하는 순간이 "완료"의 정의였다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.