파이썬 테스트 #7 CI에서 돌리기: 사람이 잊어도 기계는 잊지 않는다
테스트를 아무리 잘 만들어도, 마지막 질문이 하나 남습니다. 이 테스트는 누가 돌릴까요? 사람이 기억해서 돌리는 테스트는 결국 안 돌게 됩니다. 바쁜 날 한 번 건너뛰고, 급한 핫픽스에서 또 건너뛰고, 어느 순간부터는 깨진 채로 방치됩니다. 시리즈 마지막 편에서는 테스트 실행을 사람의 기억에서 떼어내 기계에 맡기는 방법, 즉 CI 연동을 다루겠습니다.
- #1 pytest 시작
- #2 픽스처
- #3 parametrize와 마커
- #4 mock과 monkeypatch
- #5 외부 세계 테스트
- #6 테스트 설계와 커버리지
- #7 CI에서 돌리기 ← 이번 글
GitHub Actions 기본 워크플로 #
저장소에 .github/workflows/test.yml 파일 하나를 추가하면, 푸시와 PR마다 테스트가 자동으로 돕니다.
name: tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- run: uv sync --all-extras
- run: uv run pytest네 단계가 전부입니다. 코드를 받아오고, uv를 설치하고, 의존성을 동기화하고, pytest를 실행합니다. enable-cache: true 한 줄이 uv의 패키지 캐시를 GitHub의 캐시 저장소에 보관해서, 두 번째 실행부터는 의존성 설치가 몇 초 안에 끝납니다. 이제 PR을 올리면 테스트 결과가 초록 체크 또는 빨간 X로 표시되고, 저장소 설정에서 branch protection을 켜면 테스트가 깨진 PR은 머지 자체가 막힙니다. 여기까지 오면 “테스트를 돌리는 일"이 사람의 의지에서 완전히 분리됩니다.
파이썬 버전 매트릭스 #
라이브러리를 만들거나 여러 버전을 지원해야 한다면, 같은 테스트를 버전별로 돌립니다.
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
- run: uv sync --all-extras
- run: uv run pyteststrategy.matrix에 적은 버전 수만큼 잡이 병렬로 생깁니다. 3.13에서만 깨지는 코드를 로컬에서 잡기는 어렵지만, 매트릭스는 매번 세 버전을 전부 확인해 줍니다. 자기 서비스 하나만 운영한다면 운영 환경과 같은 버전 하나로 충분합니다.
커버리지 리포트를 PR에 붙이기 #
#6에서 만든 커버리지 측정을 CI에 연결하겠습니다. XML 리포트를 만들어 Codecov 같은 서비스에 올리면, PR마다 커버리지 변화가 코멘트로 달립니다.
- run: uv run pytest --cov=app --cov-report=xml
- uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}“이번 PR로 커버리지가 2% 떨어졌습니다” 같은 코멘트가 자동으로 달리면, 리뷰어가 따로 지적하지 않아도 작성자가 먼저 봅니다. 숫자 자체보다 변화의 방향이 보인다는 점이 가치입니다. #6에서 정리한 대로 커버리지는 목표가 아니라 신호이고, CI는 그 신호를 모두가 보는 곳에 띄워 줍니다.
pre-commit으로 더 이른 피드백 #
CI는 푸시 후에야 결과가 나옵니다. 커밋 직전에 잡을 수 있는 문제는 더 일찍 잡는 편이 좋습니다. pre-commit 훅에 ruff를 걸어두면 포매팅과 린트 문제가 커밋 단계에서 걸러집니다.
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.4
hooks:
- id: ruff
args: [--fix]
- id: ruff-formatuv tool install pre-commit으로 설치하고 저장소에서 pre-commit install을 한 번 실행하면, 이후 모든 커밋에서 ruff가 자동으로 돕니다. 다만 pre-commit에 전체 pytest를 거는 것은 권하지 않습니다. 커밋이 수십 초씩 걸리면 결국 --no-verify로 우회하게 되고, 우회가 습관이 되면 훅 전체가 무력화됩니다. 커밋 훅은 1〜2초 안에 끝나는 검사만, 전체 테스트는 CI에 맡기는 분업이 오래갑니다.
느린 테스트 분리 #
테스트가 늘어나면 CI 시간이 늘어나고, CI가 느리면 개발 리듬이 무너집니다. #3에서 다룬 마커가 여기서 힘을 발휘합니다.
import pytest
@pytest.mark.slow
def test_full_data_pipeline():
...로컬에서는 빠른 테스트만, CI에서는 전부 돌리는 식으로 나눕니다.
# 로컬: 빠른 것만
uv run pytest -m "not slow"
# CI: 전부
uv run pytest더 무거운 테스트, 예를 들어 실제 외부 API를 부르는 통합 테스트는 PR마다 돌리지 않고 schedule 트리거로 하루 한 번 도는 별도 워크플로에 두는 방법도 있습니다. 빠른 피드백 루프와 깊은 검증을 분리하는 것이 핵심입니다.
실패한 테스트만 다시 돌리기 #
로컬에서 테스트를 고치는 중이라면, 매번 전체를 돌릴 필요가 없습니다.
# last failed: 직전에 실패한 테스트만
uv run pytest --lfpytest는 .pytest_cache 디렉터리에 직전 실행 결과를 기록해 두고, --lf는 그 기록을 읽어 실패한 테스트만 골라 돌립니다. 실패한 것을 먼저 돌리고 나머지를 뒤에 돌리는 --ff도 같은 캐시를 사용합니다. 수백 개 테스트 중 깨진 세 개만 반복해서 돌리며 고칠 수 있으니, 수정 루프가 훨씬 짧아집니다. 다 고친 뒤 전체를 한 번 돌려 마무리하면 됩니다.
CI에서 테스트가 통과하면 그다음은 배포입니다. 같은 워크플로 뒤에 Docker 빌드와 배포 단계를 이어 붙이는 구성은 모던 파이썬 실전 #6에서 다뤘으니, 테스트와 배포를 한 파이프라인으로 묶고 싶다면 이어서 읽어 보시기 바랍니다.
시리즈를 마치며 #
일곱 편을 한 줄씩 돌아보겠습니다.
- #1 pytest 시작:
assert하나로 시작하는 테스트, pytest의 기본 동작을 익혔습니다. - #2 픽스처: 준비와 정리를 테스트 밖으로 빼내는 fixture를 다뤘습니다.
- #3 parametrize와 마커: 같은 로직을 여러 입력으로, 테스트를 그룹으로 관리하는 법을 봤습니다.
- #4 mock과 monkeypatch: 제어할 수 없는 것을 가짜로 바꿔 격리하는 법을 익혔습니다.
- #5 외부 세계 테스트: 시간, 파일, 네트워크, DB처럼 바깥에 닿는 코드를 테스트했습니다.
- #6 테스트 설계와 커버리지: 무엇을 테스트할지 고르는 기준과 커버리지를 읽는 법을 정리했습니다.
- #7 CI 연동: 그 모든 테스트가 사람 없이도 돌게 만들었습니다.
테스트는 결국 미래의 나를 위한 안전망입니다. 석 달 뒤의 나는 지금 이 코드의 의도를 기억하지 못합니다. 그때 코드를 고치다가 무언가를 깨뜨리면, 오늘 적어둔 테스트가 빨간 글자로 알려줍니다. CI는 그 안전망이 항상 펼쳐져 있도록 보장하는 장치입니다. 사람은 잊어도 기계는 잊지 않습니다. 여기까지 함께한 테스트 습관이 앞으로 작성하실 모든 파이썬 코드의 바닥을 받쳐주기를 바랍니다.