↓ 본문으로 건너뛰기

Python Django Pytest

pytest-testmon: 변경된 코드에 영향받는 테스트만 실행하기

pytest-testmon은 코드 변경이 영향을 주는 테스트만 실행하는 pytest 플러그인입니다. Python용 테스트 영향 분석이죠. coverage.py로 각 테스트가 실행하는 코드 블록을 기록해 두었다가, 다음번에는 블록이 바뀐 테스트와 지난번에 실패한 테스트만 돌립니다. 테스트 2,248개짜리 느린 Django 스위트에 이걸 넣은 이유(작은 기능 요청 하나가 스위트 전체를 기다려서는 안 되니까), 돌릴 테스트를 고르는 방식, testmon이 보지 못하는 부분을 챙기는 래퍼 스크립트까지 담은 단계별 방법, 이게 없으면 Python 3.14 이상에서 테스트를 소리 없이 건너뛰게 되는 coverage.py 설정, 코딩 에이전트에게 주는 도움, 그리고 CI에는 왜 더 거친 버전을 넣었는지 적었습니다.

pytest-testmon: 변경된 코드에 영향받는 테스트만 실행하기

pytest-testmon은 변경 사항이 영향을 줄 수 있는 테스트만 실행하는 pytest 플러그인입니다. coverage.py로 각 테스트가 어떤 코드 줄을 실행하는지 지켜보고, 테스트가 거쳐 간 코드 블록마다 체크섬을 저장해 둡니다. 다음 실행에서는 그 체크섬을 디스크의 코드와 비교해서, 블록이 바뀐 테스트와 지난번에 실패한 테스트만 돌립니다. pytest --lf가 하던 일까지 겸하는 셈이죠. 이런 기법을 통틀어 테스트 영향 분석(test impact analysis)이라고 하는데, testmon은 Python에서 이걸 무료 오픈소스로 쓰는 방법입니다. git과는 아무 상관이 없습니다. 커밋을 했든 안 했든, 지난 실행에서 본 것과 비교할 뿐입니다.

이번 주에 제 마켓플레이스 앱의 Python 테스트에 이걸 넣었습니다. 속도 자체가 목적은 아니었습니다.

넣은 이유
#

기능 요청은 대개 작습니다. 화면 하나, 엔드포인트 하나, 다르게 동작해야 하는 규칙 하나. 그런데 테스트 스위트를 통째로 돌리다 보니 요청마다 실제보다 커졌습니다. 스위트는 Django 테스트 2,248개로, 프로세스 넷이면 3분 반쯤, 하나면 11분이 걸립니다. 그런데 수정할 때마다 이걸 전부 돌렸습니다. 그 거의 전부가 요청과는 아무 상관 없는 것을 확인하는데도요. 상관없는 테스트가 실패하거나 그냥 실행이 오래 걸리면 기능은 기다려야 했습니다. 기능 요청과 무관한 사정에 발이 묶인 건데, 발목을 잡은 게 다름 아닌 제 테스트 스위트였죠.

그렇다고 다른 길이 더 나은 것도 아니었습니다. 전체 실행을 건너뛰고, 변경을 다루는 테스트 파일이 어느 건지 짐작하고, 가끔은 틀리는 겁니다. 빠르긴 해도 정확하지는 않습니다. 그리고 테스트 없이 내보낸 변경은 같은 기능 요청을 두 번 처리하게 만듭니다.

제가 원하는 건 대충하지 않으면서 민첩하게 움직이는 겁니다. 요청을 받고, 고치고, 동작하는 걸 확인하고, 내보내고, 다음 요청으로 넘어가는 것. 그러려면 제가 기다리는 테스트가 변경이 닿을 수 있는 테스트와 정확히 같아야 합니다. 더도 덜도 말고요. testmon은 그 집합을 짐작이 아니라 기록으로 줍니다. 전체 스위트는 여전히 CI에서 돕니다. 거기서는 제가 다음에 할 작업을 붙잡지 않습니다.

돌릴 테스트를 고르는 방식
#

testmon은 Python 파일 하나를 여러 블록으로 나눕니다. 모듈 최상위, 각 클래스, 각 함수가 블록이 되고, 중첩 함수의 본문은 바깥 블록에서 잘라 냅니다. 저장하는 건 블록마다의 체크섬이고, 코드 자체는 저장하지 않습니다. 테스트가 도는 동안 coverage.py가 실행된 줄을 기록하면, testmon은 그 줄이 어느 블록에 속하는지 적어 둡니다. 정적 분석도 없고, import를 보고 짐작하지도 않습니다. 테스트는 정확히 자기가 실행한 블록에만 의존합니다.

이런 모듈이 있다고 해 보죠.

# pricing.py
def subtotal(items):
    return sum(item.price * item.quantity for item in items)

def discount(total, code):
    if code == "WELCOME10":
        return total * 0.9
    return total

subtotal()만 호출하는 테스트는 모듈 최상위와 subtotal의 본문에 의존합니다. discount()를 어떻게 고쳐 쓰든 testmon은 그 테스트를 다시 돌리지 않습니다. discount의 줄을 하나도 실행한 적이 없으니까요. 반대로 subtotal 안에서 글자 하나만 바꿔도, 결과가 달라질 수 없는 변경이라 해도 테스트는 다시 돕니다. testmon은 부작용을 두고 머리를 굴리지 않습니다. 실행한 블록이 바뀌었으면 다시 돌 뿐입니다.

코드를 둘러싼 환경도 추적합니다. 기록에는 Python 버전, 설치된 패키지와 그 버전, 그리고 DJANGO_SETTINGS_MODULE 같은 환경 변수가 들어갑니다. Django를 업그레이드하면 Django를 건드린 테스트가 전부 다시 영향을 받는데, Django 앱에서는 그게 곧 전부입니다.

보지 못하는 것
#

testmon이 추적하는 건 테스트가 실행하는 Python입니다. 테스트가 읽는 다른 파일, 그러니까 템플릿, JSON, 텍스트, 그 밖에 디스크에 있는 무엇이든 추적하지 않습니다. 네트워크 너머의 서비스도 추적하지 않습니다. 그리고 테스트가 시작되기 전에 한 번 실행되는 것은 어느 특정 테스트에도 묶을 수 없습니다. Django 프로젝트라면 이런 것들입니다.

  • 마이그레이션. 테스트 데이터베이스를 만들 때 한 번 실행되기 때문에, 어느 테스트의 기록에도 들어가지 않습니다. 아래 래퍼는 새 마이그레이션이 생기면 테스트 데이터베이스를 다시 만듭니다.
  • 설정과 시작 코드. pytest가 시작하는 동안, 어느 한 테스트의 바깥에서 실행됩니다.
  • 템플릿과 데이터 파일. 템플릿은 Python이 아니라서, 고쳐도 그걸 렌더링하는 테스트가 하나도 다시 돌지 않습니다.

제 스위트에는 이런 게 몇 가지 더 있습니다. 어떤 테스트는 모바일 앱의 테스트와 함께 쓰는 픽스처 JSON 파일을 읽습니다. 어떤 테스트는 빌드된 웹 번들을 확인하고, 또 어떤 테스트는 같은 저장소에서 생성하는 정적 사이트의 결과물을 확인합니다. 이 중 무엇을 바꾸든 testmon은 태연하게 돌릴 게 없다고 할 겁니다. 아래 방법에서 testmon을 스크립트로 감싸고, 이런 파일이 바뀌면 알아채서 전부 돌리게 하는 이유가 이겁니다.

Django 프로젝트에 넣는 방법
#

아래 단계는 pytest와 pytest-django를 쓰고, 의존성은 uv로 관리한다고 가정합니다. pip를 쓴다면 uv add --dev를 pip install로 바꾸고 uv run은 빼세요.

1. 설치합니다.

uv add --dev pytest-testmon

2. pytest로 스위트를 돌릴 수 있는지 확인합니다. testmon은 pytest 플러그인이라 python manage.py test에서는 아무 일도 하지 않습니다. pytest-django에는 설정 모듈과 테스트가 쓰는 파일 패턴을 알려 줘야 합니다.

# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = myproject.settings
python_files = tests.py test_*.py *_tests.py
python_classes = Test* *Tests *TestCase

지금까지 Django의 러너로만 스위트를 돌렸다면, testmon을 넣기 전에 pytest로 한 번 돌려 보고 결과가 달라지는 부분부터 고치세요. 그러지 않으면 두 가지를 한꺼번에 디버깅하게 됩니다.

3. 기록은 git에 넣지 않습니다. testmon은 pytest 루트 디렉터리에 .testmondata를 씁니다. SQLite 데이터베이스라서 옆에 -shm과 -wal 파일도 생기고, 아래 스크립트가 스탬프 파일을 하나 더 만듭니다. 패턴 하나로 전부 처리됩니다.

# .gitignore
.testmondata*

4. 스위트 전체를 한 번 기록합니다. testmon은 모든 테스트가 도는 걸 한 번 보기 전까지는 아무것도 고를 수 없습니다.

uv run pytest --testmon

새로 받은 체크아웃에서는 이 첫 실행이 프로세스 하나로 스위트 전체를 돕니다. 제 경우엔 테스트 2,248개에 12분쯤 걸립니다. 그다음부터는 같은 명령이 영향받는 테스트만 돌립니다. 바뀐 게 없으면 아무것도 돌리지 않습니다.

주의할 점이 두 가지 있습니다. testmon에는 pytest의 캐시 플러그인이 필요합니다. 처음에는 -p no:cacheprovider를 붙여 돌렸다가 KeyError: 'lf'로 죽었습니다. 그리고 Python 3.14 이상이라면 기록하기 전에 COVERAGE_CORE=ctrace를 설정하세요. 이유는 뒤에 나오는 “초록불처럼 보였던 버그"에서 설명합니다. 아래 스크립트에서 가장 중요한 한 줄입니다.

5. testmon이 보지 못하는 파일까지 챙기도록 감쌉니다. 매일 안심하고 쓸 수 있게 해 주는 게 이 부분입니다. 스크립트는 지난 실행이 시작된 시점의 스탬프 파일을 남겨 둡니다. testmon이 추적할 수 없는 파일 중 스탬프보다 새로운 게 하나라도 있으면 전부 돌리고 처음부터 다시 기록합니다. 새 마이그레이션이라면 테스트 데이터베이스만 다시 만듭니다. 그 밖에는 testmon에게 고르게 맡깁니다. 줄인 예제입니다.

#!/usr/bin/env bash
# scripts/test-changed.sh: the tests a change can affect, or all when in doubt.
#   scripts/test-changed.sh            # affected tests
#   scripts/test-changed.sh --all      # everything, re-recorded
#   scripts/test-changed.sh -k orders  # anything else goes to pytest
set -euo pipefail
cd "$(dirname "$0")/.."

# coverage's C tracer: from Python 3.14 coverage defaults to sys.monitoring,
# which reports a line only the first time a process runs it, so testmon
# would credit each line to one test. Changing this means --all.
export COVERAGE_CORE=ctrace

STAMP=.testmondata.started
full=false
rebuild=false
if [ "${1:-}" = "--all" ]; then
    full=true; shift
elif [ ! -f .testmondata ] || [ ! -f "$STAMP" ]; then
    echo "No record yet: running everything once."
    full=true
else
    # Changed since the last run started, and not traceable per test:
    # anything that isn't Python, plus settings and conftest.
    newer="$(find myapp myproject fixtures pytest.ini \
        -type f -newer "$STAMP" ! -path '*/migrations/*' \
        \( ! -name '*.py' -o -path 'myproject/settings*' -o -name 'conftest.py' \) \
        ! -path '*/__pycache__/*' | head -5)"
    if [ -n "$newer" ]; then
        echo "Changed, and not traceable per test:"; printf '  %s\n' $newer
        full=true
    fi
    # A new migration needs a fresh test database, not a full run.
    [ -n "$(find myapp -path '*/migrations/*.py' -newer "$STAMP" | head -1)" ] && rebuild=true
fi

# Stamp the start, so an edit made while the tests run counts next time.
started="$(mktemp .testmondata.run.XXXXXX)"
args=(--testmon --reuse-db)
# --create-db alone deletes the database when the run ends; with
# --reuse-db as well, the rebuilt one is kept for next time.
[ "$rebuild" = true ] && args=(--testmon --reuse-db --create-db)
[ "$full" = true ] && args=(--testmon-noselect --reuse-db --create-db)

status=0
uv run pytest "${args[@]}" -q "$@" || status=$?
# 0: passed, or nothing selected. 1: some failed (still recorded).
# 5: nothing collected at all.
case "$status" in 0|1|5) mv "$started" "$STAMP" ;; *) rm -f "$started" ;; esac
[ "$status" = 5 ] && exit 0
exit "$status"

여기서 두 가지는 짚고 넘어갈 만합니다. --reuse-db는 실행 사이에 테스트 데이터베이스를 유지하는 pytest-django의 플래그입니다. 실행이 몇 초로 줄어들면 이게 중요해집니다. 매번 데이터베이스를 새로 만드는 데 테스트 자체보다 오래 걸릴 수도 있거든요. 전체 실행이나 새 마이그레이션 뒤의 실행에서는 데이터베이스가 현재 스키마를 갖도록 --create-db를 더합니다. 이때 --reuse-db도 옆에 남겨 두세요. --create-db만 쓰면 pytest-django가 실행이 끝날 때 데이터베이스를 지워 버려서, 다음 실행이 처음부터 다시 만들어야 합니다. 그리고 testmon이 모든 테스트를 선택에서 빼면 실행은 0으로 끝나니, “바뀐 것 없음"은 통과로 칩니다. 종료 코드 5는 pytest의 “수집된 테스트 없음"인데, 스크립트는 이것도 문제없는 것으로 봅니다.

목록에 없는 것도 눈여겨보세요. pyproject.toml과 락 파일입니다. 첫 버전은 락 파일이 바뀔 때마다 전부 돌렸는데, 그러다 보니 느린 대체 경로가 거의 매일 작동해서 오히려 전보다 나빠졌습니다. testmon은 설치된 패키지 버전을 이미 직접 추적하니, 도와줄 필요가 없습니다.

6. 전용 데이터베이스와 캐시를 줍니다. 같은 머신에서 다른 것도 테스트를 돌린다면, 이를테면 두 번째 터미널이나 코딩 에이전트, manage.py test 같은 것이 있다면, 스크립트가 자기만의 테스트 데이터베이스와 캐시 번호를 쓰게 하세요. 그래야 유지해 둔 데이터베이스가 남의 실행과 마주칠 일이 없습니다.

export DATABASE_URL="postgresql://myapp:myapp@localhost:5432/myapp_changed"
export REDIS_URL="redis://localhost:6379/8"

7. 실행할 때마다 먼저 그 캐시를 비웁니다. 이건 호되게 겪고 배웠습니다. 전체 실행에서는 나지 않던 KeyError로 부분 실행이 실패하기 시작했습니다. 카테고리 라벨 몇 개가 만료 없이 캐시되어 있었는데, 한 실행에서 유지한 테스트 데이터베이스가 그보다 앞선 실행이 데워 둔 캐시와 만난 겁니다. 스크립트 맨 위에서 캐시를 비우니 해결됐습니다.

uv run python -c "import os, redis; redis.Redis.from_url(os.environ['REDIS_URL']).flushdb()"

testmon은 문서에 적힌 대로 일했을 뿐입니다. 매번 다른 부분 집합을 돌리면, 앞서 돈 테스트 덕분에 통과하던 테스트가 드러납니다. 고칠 곳은 testmon이 아니라 테스트 설정입니다.

8. 선택이 틀렸을 때 쓸 플래그를 알아 둡니다.

  • --testmon-noselect는 모든 테스트를 돌리되 실패할 가능성이 높은 것부터 돌립니다. -k, -m, --lf나 테스트의 노드 ID를 함께 넘기면 testmon이 알아서 이 모드로 바꿉니다.
  • --testmon-forceselect는 영향받는 테스트 중 -k나 -m에도 맞는 것만 돌립니다.
  • --testmon-nocollect는 기존 기록을 쓰되 거기에 보태지는 않습니다. 디버거 아래에서나 coverage가 이미 돌고 있을 때는 강제로 이 모드가 됩니다.
  • --no-testmon은 한 번의 실행에서만 testmon을 끕니다. addopts에 넣어 두었을 때 씁니다.
  • --testmon-env=<name>은 한 파일 안에 기록을 따로따로 둡니다. 설정 모듈 두 개로 돌리는 스위트에 씁니다.

프로젝트에 JavaScript 쪽도 있다면, Jest에는 같은 기능이 내장돼 있습니다. jest --changedSince=main은 main 이후 바뀐 것을 import하는 테스트를 돌리는데, coverage가 아니라 import를 따라갑니다. 저는 이걸 앱의 package.json에 npm run test:changed로 넣어서 양쪽이 같은 습관을 갖게 했습니다. 유틸리티 모듈 하나를 고쳤더니 앱의 테스트 파일 121개 중 7개가 선택됐습니다.

얼마나 아껴 주나
#

래퍼를 넣고 다음 절의 버그까지 고친 뒤, 시작 시간을 포함해서 재 봤습니다.

바뀐 것실행한 테스트시간
없음01.0초
홈 화면 배너 뒤의 코드 한 군데 수정178.5초
그 수정 되돌리기178.7초
비교용 스위트 전체(프로세스 4개)2,248약 3½분

평소의 수정이라면 병렬 스위트보다 25배쯤 빠르고, 바뀐 게 없을 때는 200배쯤 빠릅니다. 기록도 더 넓은 범위에서 같은 말을 합니다. 스위트가 실행하는, 테스트가 아닌 Python 파일 307개 가운데 중앙값에 해당하는 파일을 바꾸면 테스트 22개, 테스트 시간으로 8초쯤에 닿습니다. 반대쪽 끝에는 핵심 모듈 몇 개가 있습니다. 거의 모든 테스트가 이 모듈들을 실행하니, 여기를 바꾸면 1,500개 안팎의 테스트에 닿고 testmon은 스위트 대부분을 다시 돌립니다. 그게 맞는 답이지, 실패가 아닙니다. 이 수치들은 파일별 상한입니다. testmon은 블록 단위로 일하니까, 함수 하나를 바꾸면 그 함수를 실행한 테스트만 고릅니다.

비용은 래퍼가 대체 경로로 넘어갈 때 드러납니다. 전체 실행은 첫 실행과 똑같이 프로세스 하나로 기록하는 실행이라 12분쯤 걸리고, 그건 이 래퍼가 대신하는 병렬 스위트보다 느립니다. 그래서 전체 실행을 촉발하는 파일 목록은 정직하게 줄일 수 있는 데까지 짧아야 합니다.

초록불처럼 보였던 버그
#

처음 잰 수치는 이것보다 좋았습니다. 수정 한 번에 테스트 6개, 3.3초. 그런데 틀린 수치였고, 화면 어디에도 그런 말은 없었습니다.

Python 3.14부터 coverage.py는 인터프리터의 sys.monitoring 위에 만든 새 코어를 기본으로 씁니다. 이 코어는 한 프로세스가 어떤 줄을 처음 실행할 때만 그 줄을 보고합니다. testmon은 어느 테스트가 어느 줄을 실행했는지를 coverage에서 받아 기록을 만드니, 각 줄은 우연히 그 줄을 처음 실행한 테스트 하나에만 돌아가고 다른 테스트에는 하나도 돌아가지 않았습니다. 테스트 여덟 개가 함께 거치는 줄을 고치면 testmon은 그중 하나만 고르는 거죠.

이걸 잡아낸 건 이 설정을 그대로 가져다 쓴 제 다른 작은 프로젝트였습니다. 거기에 도입하면서 일부러 한 줄을 망가뜨리고 결과를 비교해 봤는데, 숫자가 맞지 않았습니다. 이 스위트로 돌아와 같은 확인을 해 봤습니다. 홈 화면 배너 코드에서 슬라이드 제목 한 줄을 망가뜨렸더니, 그냥 pytest는 실패한 테스트 8개를 보고했고 testmon은 4개를 골랐습니다. 망가진 나머지 4개는 한 번도 돌지 않았고, 출력 어디에도 그것들이 빠졌다는 말은 없었습니다.

고치는 건 한 줄, export COVERAGE_CORE=ctrace입니다. coverage를 예전의 C 트레이서로 되돌리는 설정이죠. 트레이서가 다르면 기록도 달라지니 다시 기록했습니다. 테스트 2,248개에 12분 3초. 그 뒤로는 같은 식으로 망가뜨리는 수정을 하면, 그 수정으로 깨진 테스트 11개가 전부 돌았습니다.

이 글에서 무엇보다 먼저 전하고 싶은 교훈이 이겁니다. 너무 적게 고르는 테스트 선택기는 성공과 똑같이 생겼습니다. 테스트는 적고, 실행은 빠르고, 전부 초록불이죠. 그러니 선택기를 설정할 때는 여러 테스트가 함께 쓰는 무언가를 일부러 망가뜨리고, 그냥 스위트와 선택기를 둘 다 돌려서 같은 테스트가 실패하는지 확인하세요. 그리고 Python이나 coverage.py의 버전이 바뀔 때마다 다시 확인하세요.

코딩 에이전트가 들어오는 자리
#

이제 그 저장소의 변경을 전부 제 손으로 하지는 않습니다. 작은 변경은 코딩 에이전트가, 제 경우엔 Claude Code가 많이 맡는데, 좋은 에이전트는 수정할 때마다 테스트를 돌립니다. 11분짜리 스위트 앞에서는 에이전트도 제가 겪던 나쁜 선택지 두 개를 똑같이 마주합니다. 기다리거나, 어느 테스트 파일이 중요한지 짐작하거나.

그래서 저장소의 에이전트 지침에 이제 이렇게 적혀 있습니다. 작업 중에 테스트를 돌리는 기본 방법은 래퍼 스크립트이고, 전체 실행은 --all. 에이전트는 수정할 때마다 저와 똑같이 정확한 테스트 집합을 몇 초 만에 받습니다. 6단계에서 스크립트에 전용 데이터베이스와 캐시를 준 것도 이 때문입니다. 바쁜 날에는 같은 머신에서 테스트를 돌리는 세션이 하나가 아니고, 유지해 둔 데이터베이스가 남의 실행과 마주쳐서는 절대 안 되니까요.

testmon에 AI 전용 기능이 따로 필요한 건 하나도 없습니다. 같은 기록, 같은 선택입니다. 다만 변경을 만드는 쪽이 변경을 아주 많이 만들 때 더 중요해질 뿐이죠.

CI에는 왜 더 거친 버전을 넣었나
#

testmon에는 CI 모드가 없습니다. 러너에서도 노트북에서와 똑같이 동작하고, 작성자는 CI 러너끼리 .testmondata를 공유하거나 캐시하는 방식이 자기네 호스팅 서비스인 testmon.net보다 훨씬 덜 효과적이라고 적어 두었습니다. 캐시된 기록은 앞에서 말한 모든 면에서 눈이 먼 채일 테고, 제 CI는 Django의 러너로 프로세스 넷에서 스위트를 돌리니, testmon은 빼 두었습니다.

대신 CI에는 같은 아이디어를 파일 단위로 넣었습니다. 체크 워크플로는 이제 작은 잡 하나로 시작하는데, 이 잡이 push의 diff를 보고 저장소의 어느 쪽을 건드렸는지 정합니다. Python API인지, 모바일 앱인지, 둘 다인지, 둘 다 아닌지. 전에는 문서만 바꾼 push까지 포함해 모든 push에서 Python 잡과 앱 잡을 둘 다 돌렸습니다. 이제 문서만 바꾼 push는 아무것도 돌리지 않고, 앱만 바꾼 push는 Django 스위트를 건너뜁니다. 실제 커밋 다섯 개에 대고 이 잡을 시험 삼아 돌려 봤더니, 문서만 바꾼 커밋은 아무것도 돌리지 않았고, 앱만 바꾼 커밋은 Python 잡을, 백엔드만 바꾼 커밋은 앱 잡을 건너뛰었습니다. 줄인 예제입니다.

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      python: ${{ steps.diff.outputs.python }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - id: diff
        env:
          BEFORE: ${{ github.event.before }}
          HEAD: ${{ github.sha }}
        run: |
          FILES="$(git diff --name-only "$BEFORE" "$HEAD")"
          # Python's inputs include the non-Python files its tests read.
          PY='^(myapp/|myproject/|fixtures/shared\.json$|web/dist/|pyproject\.toml$|uv\.lock$)'
          if printf '%s\n' "$FILES" | grep -qE "$PY"; then
            echo "python=true" >> "$GITHUB_OUTPUT"
          else
            echo "python=false" >> "$GITHUB_OUTPUT"
          fi

  python:
    needs: changes
    if: needs.changes.outputs.python == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: uv run python manage.py test --parallel 4 --noinput

실제 워크플로는 비교할 범위가 없을 때도 전부 돌립니다. 새 브랜치, 강제 push, 수동 실행이 그런 경우입니다.

사각지대는 모양만 다를 뿐 그대로입니다. testmon이 JSON 파일을 못 보듯, 경로 필터도 목록에 적어 주지 않으면 못 봅니다. 그래서 패턴에 공유 픽스처, 빌드된 번들, 생성된 사이트를 명시적으로 적고, 이걸 고치는 사람은 목록을 정직하게 유지해 달라는 주석을 달아 두었습니다. 파일 단위 버전은 더 투박하지만, 실패하더라도 안전한 쪽으로 실패합니다. Python 쪽에서 뭐든 바뀌었으면 스위트 전체가 돕니다.

써 볼 만한가요?
#

몇 분씩 걸리는 스위트라면, 네. 핵심은 처음부터 그 몇 분이 아니었습니다. 변경을 그 변경이 영향을 줄 수 있는 테스트로 판단하니, 작은 요청이 작은 채로 남는다는 거죠. 의존성 하나, 플래그 하나, 환경 변수 하나면 되고, 테스트를 쓰는 방식은 하나도 바뀌지 않습니다. C 트레이서를 설정해 두면, testmon이 저지르는 최악의 일은 모두가 import하는 무언가를 바꾼 뒤에 테스트를 너무 많이 돌리는 정도입니다. 설정하지 않으면 Python 3.14 이상에서는 너무 적게 돌리고도 초록불일 수 있습니다. 일부러 망가뜨려 보는 확인이 중요한 이유가 그겁니다. 진짜 비용은 testmon이 보지 못하는 것들이고, 래퍼 스크립트가 그걸 위해 있습니다. 그렇다 해도 testmon이 있어야 할 자리는 노트북에서 도는 작업 루프입니다. CI는 Python 쪽이 바뀔 때마다 여전히 스위트 전체를 돌리고, 배포 전에 제가 믿는 건 그 실행입니다.