pytest snapshot 테스트 syrupy, 병렬 실행 주의점

3줄 요약
- 스냅샷을 처음 만들 때도
-n auto를 붙여 시간을 아끼는 것이 습관이 돼 있었습니다. - syrupy 6.1.1과 pytest-xdist 3.8.0 조합에서 20개를 만들면 4워커는 15~16개, 8워커는 최악 2개만 파일에 남았습니다. 그런데 화면에는
20 snapshots generated가 찍히고 종료 코드는 0이었습니다. - 6.1.1에 새로 들어온
--snapshot-file-lock을 붙이면 10회 실행에서 전부 20개가 남았고, 8워커 기준 실행 시간 차이는 0.001초였습니다.
워커 2개까지는 아무 일도 없었습니다. 5회를 연달아 돌려도 매번 20개가 그대로 들어갔고, 그래서 저는 스냅샷 갱신도 병렬로 돌리면 되는 줄 알았습니다.
4워커로 올린 순간부터 숫자가 맞지 않았는데요. 다섯 번 돌려서 15, 16, 15, 16, 16이 나왔습니다. 한 번도 20이 아니었습니다. 8워커에서는 2가 나온 회차도 있었습니다.
이상한 것은 그다음이었습니다. 화면에는 20 snapshots generated가 찍혀 있었고 종료 코드도 0이었습니다. 파일을 열어 세지 않으면 알 방법이 없었습니다.
붙여 본 자리 — 주문 요약 응답 두 건
실험 환경은 Python 3.10.12, Ubuntu 22.04.5 LTS(aarch64), pytest 9.1.1, pytest-xdist 3.8.0, syrupy 6.1.1입니다. syrupy 6.1.1은 PyPI에 2026-09-13T20:25:20Z(KST 09-14 05:25)에 올라왔고, 같은 시각 GitHub 태그가 붙었습니다. 6.1.0은 CI 문제로 건너뛰었다고 릴리스 노트에 적혀 있습니다.
대상은 주문 요약 응답을 만드는 함수 하나입니다. 항목별 금액, 소계, 쿠폰 할인, 부가세, 합계를 담은 딕셔너리를 돌려줍니다. 이런 응답은 필드가 열 개를 넘어가면서부터 어서션을 손으로 쓰기가 괴로워지는데, 스냅샷 테스트가 노리는 자리가 정확히 여기입니다.
첫 실행은 실패로 시작합니다
스냅샷이 없는 상태에서 그냥 돌리면 통과하지 않습니다.
# syrupy는 별도 설정 없이 snapshot 픽스처만 주입하면 동작합니다
pip install syrupy==6.1.1
python -m pytest -q
# → Snapshot 'test_summary_with_coupon' does not exist!
# → 2 snapshots failed. / 2 failed in 0.01s
기준선이 없으면 실패한다는 뜻이라 처음에는 거슬렸는데, 생각해 보면 이쪽이 맞습니다. 기준선이 없는데 조용히 통과하는 도구였다면 CI에서 스냅샷 파일을 지워도 초록불이 떴을 겁니다.
실패 메시지도 쓸 만했습니다. 어느 스냅샷이 없는지 이름을 대고, 받은 값을 + 접두사로 펼쳐 보여 줍니다. 값이 길면 뒤를 접고 -vv를 쓰라고 안내합니다. 기준선이 없을 때와 값이 달라졌을 때가 같은 형식으로 나오기 때문에, 팀원이 실패 로그만 보고도 어느 쪽인지 구분할 수 있습니다.
--snapshot-update를 붙이면 tests/__snapshots__/test_order.ambr 파일이 생깁니다. 967바이트짜리 텍스트 파일이고, 딕셔너리가 키 순서로 정렬돼 사람이 읽을 수 있는 형태로 들어갑니다. 리뷰에서 diff를 볼 수 있다는 점이 스냅샷 테스트의 값어치 절반쯤을 차지합니다.
매 실행 달라지는 필드가 기준선을 못 쓰게 만듭니다
문제는 그다음 실행이었습니다. 코드는 한 글자도 바꾸지 않았는데 두 건 다 실패했습니다.
응답에 order_id(UUID)와 issued_at(현재 시각)이 들어 있었기 때문입니다. 실제 API 응답에는 이런 필드가 거의 항상 있습니다. 요청 ID, 발급 시각, 만료 시각, 서버 호스트명 같은 것들입니다.
syrupy는 두 가지 처리 방법을 줍니다. 그런데 이 둘이 잡아내는 회귀가 다릅니다.
matcher와 exclude가 놓치는 것이 서로 다릅니다
from syrupy.matchers import path_type
from syrupy.filters import props
# 방법 A — 값 대신 타입을 기록합니다. 필드 자체는 스냅샷에 남습니다
def test_matcher(snapshot):
assert build_order_summary(ITEMS) == snapshot(
matcher=path_type({"order_id": (str,), "issued_at": (str,)})
)
# 방법 B — 필드를 스냅샷에서 아예 빼 버립니다
def test_exclude(snapshot):
assert build_order_summary(ITEMS) == snapshot(
exclude=props("order_id", "issued_at")
)
생성된 파일을 열어 보면 차이가 그대로 보입니다. 방법 A의 스냅샷에는 'order_id': str 한 줄이 남고, 방법 B의 스냅샷에는 그 줄이 없습니다.
order_id를 빼 보니 한쪽만 실패했습니다
리팩터링 중에 응답에서 필드 하나를 빠뜨리는 사고를 주입해 봤습니다. order_id 줄을 지우고 두 테스트를 함께 돌린 결과입니다.
| 처리 방법 | 스냅샷에 남는 형태 | 필드 삭제 회귀 | 값 변경 회귀 |
|---|---|---|---|
matcher=path_type(...) |
'order_id': str |
잡음 (실패) | 타입이 같으면 못 잡음 |
exclude=props(...) |
줄 자체가 없음 | 못 잡음 (통과) | 못 잡음 |
1 failed, 1 passed였습니다. 실패한 쪽이 matcher를 쓴 테스트입니다.
응답 스키마가 계약인 API에서는 필드가 사라지는 것이 값이 틀리는 것보다 나쁩니다. 클라이언트가 KeyError로 죽거나, 더 나쁘게는 기본값으로 넘어가 버리기 때문입니다. 그래서 제가 정한 선은 이렇습니다. 응답 본문에 항상 있어야 하는 필드는 matcher로 타입만 고정하고, 애초에 계약이 아닌 디버그 필드만 exclude로 뺍니다.
스냅샷 파일을 지우면 어떻게 되나
미사용 스냅샷 처리는 기본값이 꽤 엄격합니다. 테스트 함수를 하나 지우고 전체를 돌렸더니 남은 테스트는 통과했는데 종료 코드가 1이었습니다.
python -m pytest -q
# 1 snapshot passed. 1 snapshot unused.
# Re-run pytest with --snapshot-update to delete unused snapshots.
# 1 passed in 0.00s ← 그런데 exit code 는 1
python -m pytest -q --snapshot-details
# Unused test_exclude (tests/__snapshots__/test_order.ambr) ← 이름을 알려 줍니다
이 동작은 CI에서 유용합니다. 테스트를 지웠는데 스냅샷 파일을 그대로 커밋하면 파이프라인이 막아 줍니다. 스냅샷 파일은 방치하면 조용히 불어나는 자산이라, 지우는 쪽에 강제력이 있는 편이 낫습니다.
xdist 환경에서 미사용 스냅샷을 감지하는 기능은 5.5.0에 들어갔습니다. 릴리스 노트에 적힌 주의가 인상적이었는데, 그전까지 병렬로 돌리던 팀은 감지가 불완전해서 통과하던 것이 이 버전부터 실패할 수 있다는 안내였습니다. 병렬 실행이 조용히 검사를 건너뛰고 있었다는 뜻입니다. 이 줄을 읽고 나서 갱신 쪽 동작도 의심하게 됐습니다.
부분 실행에서 오탐이 날까 걱정했는데 그렇지 않았습니다. -k로 걸러진 테스트의 스냅샷은 미사용으로 세지 않았고, pytest tests/test_order.py처럼 파일을 지정해 돌려도 다른 파일의 스냅샷은 건드리지 않았습니다. CI를 파일 단위로 쪼개 돌리는 팀이라면 이 점이 중요합니다. 잡을 몇 개로 쪼갤지 재 봤던 글에서 다룬 샤딩 구성에서도 그대로 성립합니다.
워커를 늘리자 스냅샷이 사라졌습니다
여기까지는 순조로웠습니다. 문제는 테스트를 20개로 늘리고 -n을 붙이면서 시작됐습니다.
한 파일 안에 스냅샷 테스트 20개를 만들고, 워커 수를 바꿔 가며 --snapshot-update를 돌린 뒤 .ambr 파일에 실제로 몇 개가 들어갔는지 셌습니다.
워커 수별 실측
| 워커 수 | 1회 | 2회 | 3회 | 4회 | 5회 |
|---|---|---|---|---|---|
| 2 | 20 | 20 | 20 | 20 | 20 |
| 4 | 16 | 15 | 16 | 15 | 16 |
| 8 | 2 | 20 | 17 | 20 | 20 |
기대값은 전부 20입니다. 2워커는 안전했고, 4워커는 다섯 번 모두 유실됐고, 8워커는 회차마다 결과가 달랐습니다. 8워커 1회차의 2는 오타가 아닙니다. 스무 개 중 열여덟 개가 한 번에 날아갔습니다.
원인은 릴리스 노트에 적혀 있습니다. 여러 워커가 같은 .ambr 파일을 각자 다시 쓰기 때문입니다. amber 확장은 한 테스트 파일의 스냅샷을 한 파일에 모아 두고, 각 워커가 세션 끝에 자기가 만든 것을 반영해 파일 전체를 다시 씁니다. 마지막에 쓴 워커의 내용이 그 전 것을 덮습니다.
유실을 아무도 알려 주지 않습니다
가장 곤란한 부분은 숫자가 틀렸다는 사실 자체가 아니라, 그 사실이 어디에도 안 나온다는 점입니다.
20 passed in 0.28s
--------------------------- snapshot report summary ----------------------------
20 snapshots generated.
리포트는 20개를 만들었다고 말합니다. 실제 파일에는 18개가 있습니다. 컨트롤러가 워커들의 보고를 합산해 세기 때문에 "만들었다"는 말 자체는 거짓이 아닙니다. 다만 파일에 남았는지는 세지 않습니다.
유실된 상태로 커밋하면 다음 CI에서 이렇게 터집니다.
# 유실 상태(18/20)를 커밋한 뒤 검증 실행
python -m pytest -q tests/test_bulk.py
# FAILED tests/test_bulk.py::test_case_10 - Snapshot does not exist
# FAILED tests/test_bulk.py::test_case_11 - Snapshot does not exist
# 2 failed, 18 passed / exit code 1
빠진 두 개가 test_case_10, test_case_11로 나란히 붙어 있는 것을 보십시오. 워커 하나가 맡은 구간이 통째로 사라진 모양입니다. xdist가 테스트를 덩어리로 나눠 주기 때문인데, 그래서 유실이 나면 무작위 두 개가 아니라 한 기능 영역이 통째로 빠지기 쉽습니다. 로그인 관련 테스트만 전부 사라지는 식입니다.
--snapshot-file-lock 을 붙인 결과
6.1.1이 추가한 옵션이 바로 이것입니다. amber 파일을 쓸 때 배타 잠금을 걸고 원자적으로 교체합니다. 같은 조건에서 4워커와 8워커를 각각 5회씩 돌렸습니다.
| 워커 수 | 락 없음 | --snapshot-file-lock |
|---|---|---|
| 4 | 15~16 / 20 (5회 전부 유실) | 20 / 20 (5회) |
| 8 | 2~20 / 20 (5회 중 3회 유실) | 20 / 20 (5회) |
열 번 전부 20이었습니다. 옵션을 붙이면 6.1.1이 띄우는 경고도 같이 사라집니다. 경고 문구는 이렇습니다.
PytestWarning: syrupy: --snapshot-update under pytest-xdist without
--snapshot-file-lock can silently corrupt shared amber snapshot files when
workers write concurrently (#1237). Pass --snapshot-file-lock (experimental;
same-machine workers only). This option is expected to become the default in
a future minor release.
시간 비용은 사실상 없었습니다. 8워커·20테스트 기준 3회 평균이 락 없이 0.348초, 락을 걸고 0.349초였습니다. 스위트가 커지면 차이가 벌어질 수 있지만, 잠금은 파일을 쓰는 순간에만 잡히고 그 구간은 테스트 실행 시간에 비해 짧습니다.
다만 문서가 명시한 제약이 둘 있습니다. 같은 머신의 워커에서만 신뢰할 수 있고, 원격 워커나 NFS에서는 보장하지 않습니다. 그리고 아직 실험 기능입니다. 기본 대기 시간은 60초이며 --snapshot-file-lock-timeout으로 바꿉니다.
single_file 확장은 우회책이 아니었습니다
파일을 나눠 쓰면 경합이 없어질 테니 확장을 바꾸면 되지 않나 싶어 시도해 봤습니다. --snapshot-default-extension에 syrupy.extensions.single_file.SingleFileSnapshotExtension을 넘기면 스냅샷 하나가 파일 하나가 됩니다.
딕셔너리에서는 쓸 수 없었습니다. 스무 건 전부 같은 오류로 실패했습니다.
TypeError: Can't serialize 'dict' to 'bytes'. You must convert the data first.
single_file 확장은 바이트나 문자열을 받도록 만들어져 있어서 이미지나 렌더링 결과에 맞습니다. API 응답 딕셔너리를 그대로 넘기려면 직렬화 계층을 직접 붙여야 하고, 그러면 스냅샷 테스트를 쓰는 이유가 줄어듭니다. 경합을 피하려고 자료형을 바꾸는 것은 순서가 거꾸로입니다.
갱신 권한을 어디까지 열 것인가
여기까지가 기계 문제였다면, 남은 하나는 팀 규칙 문제입니다. 스냅샷 테스트의 진짜 위험은 --snapshot-update가 너무 쉽다는 데 있습니다. 빨간불이 떴을 때 원인을 보는 대신 갱신 명령을 치면 전부 초록불이 됩니다.
syrupy 6.1.1에는 이 습관을 막는 옵션이 있습니다. --snapshot-update-new-only입니다. 신규 스냅샷은 기록하지만 기존 것은 건드리지 않습니다.
부가세율을 0.1에서 0.15로 바꾸고 이 옵션으로 돌려 봤더니 20건이 전부 실패했고, .ambr 파일의 md5 해시는 실행 전후가 같았습니다. 기존 기준선이 한 글자도 바뀌지 않았다는 뜻입니다.
쓸 자리가 분명합니다. 테스트를 새로 추가하면서 기존 응답도 같이 손댄 브랜치에서, 신규 스냅샷만 기록되고 기존 것은 빨간불로 남습니다. 리뷰어가 "이 diff는 의도한 변경인가"를 물을 수 있는 상태가 되는 겁니다. 반대로 스키마 변경이 진짜 목적인 브랜치에서는 이 옵션이 방해만 되니, 기본값으로 박아 두기보다 갱신 명령을 두 가지로 나눠 쓰는 편이 낫다고 봅니다.
그리고 6.0.0부터는 스냅샷을 쓰는 테스트에 syrupy_snapshot 마커가 자동으로 붙습니다. 전체 스위트에서 스냅샷 테스트만 골라 갱신할 수 있습니다.
# 스냅샷 테스트만 갱신 — 다른 테스트는 수집조차 하지 않습니다
python -m pytest --snapshot-update -m syrupy_snapshot --snapshot-file-lock
| 판단 항목 | 평가 |
|---|---|
| 도입 난이도 | 낮음. 플러그인 설치 후 snapshot 픽스처만 받으면 되고 설정 파일이 필요 없습니다 |
| 유지보수 비용 | 중간. 비결정적 필드 목록을 팀이 합의해 matcher로 관리해야 하고, 방치하면 스냅샷이 커집니다 |
| 팀 학습곡선 | 낮음. 다만 "빨간불이면 갱신"이 굳지 않게 하는 리뷰 규칙이 별도로 필요합니다 |
| 병렬 환경 리스크 | 6.1.1 미만에서는 높음. 6.1.1 이상에서 락 옵션을 붙이면 해소됩니다 |
우리 팀이 이번 주에 넣을 두 줄
전 스위트를 스냅샷으로 바꿀 생각은 없고요, 응답 스키마가 자주 흔들리는 API 세 개부터 붙일 참입니다. 어서션을 손으로 쓰던 자리가 줄어드는 것만으로도 회귀 테스트 작성 시간이 눈에 띄게 줄었습니다.
붙이기 전에 아래 네 줄만 확인하시면 사고는 피할 수 있습니다.
# pytest.ini — 갱신 명령에 락을 강제로 물립니다 (syrupy 6.1.1 이상)
[pytest]
addopts = --snapshot-file-lock
# CI 워크플로에서는 --snapshot-update 자체를 막고, 갱신은 로컬에서만 하게 둡니다
□ syrupy 버전이 6.1.1 이상인가 — 미만이면 xdist 갱신에 락 옵션 자체가 없습니다
□ 갱신 후 .ambr 의 '# name:' 개수가 테스트 개수와 같은가 (grep -c 로 한 번 세기)
□ 비결정적 필드를 exclude 가 아니라 matcher 로 처리했는가
□ CI 잡에 --snapshot-update 가 들어가 있지 않은가
두 번째 줄이 핵심입니다. 락을 붙였더라도 갱신 직후에 개수를 한 번 세는 습관이 있으면, 다음에 또 다른 경합이 생겨도 커밋 전에 걸립니다. 워커별로 세션 픽스처가 따로 도는 문제를 겪었을 때도 결국 같은 결론이었습니다. 병렬 실행에서는 도구가 보고하는 숫자보다 파일에 남은 결과를 믿는 편이 쌉니다.
댓글
댓글 쓰기