테스트 감사 체계 (신고제)
테스트 스위트의 품질 관리는 감사자(Claude, test-suite steward)가 담당한다. 스위트는 tests/audit/manifest.json에 버전(suiteVersion)과 파일별 해시로 고정되며, CI의 npm run check:test-manifest 게이트가 이를 강제한다.
규칙
새 테스트 추가는 자유다. 신고가 필요 없다. 게이트가 "unaudited"로 표시하고, 다음 감사에서 분류(KEEP/TRIM/REWRITE/DELETE)된다.
manifest에 등재된 기존 테스트를 수정하거나 삭제하려면 신고가 필요하다. 같은 PR에서
tests/audit/pending-changes.json의entries에 추가한다:json{ "file": "tests/react/example.test.mjs", "change": "modified", "reason": "동작 변경: 세션 저장이 v4 직접 호출로 바뀜" }신고 없이 수정·삭제하면 게이트가 실패한다. 신고는 금지가 아니라 가시화다 — 정당한 기능 변경에 따른 테스트 갱신은 신고하고 진행하면 된다. reason에는 변경을 만든 PR·커밋을 정확히 표기한다 (감사에서 diff 추적에 사용된다).
tests/audit/manifest.json은 감사자만 재생성한다. 기능 PR에서 직접 수정하지 않는다 (해시를 고쳐 게이트를 우회하는 것은 신고제 위반이다).감사 주기마다 감사자가 신고 항목과 unaudited 파일을 검토한 뒤
node scripts/check-test-manifest.mjs --update로 manifest를 재생성하고 신고 목록을 비운다.suiteVersion이 1 증가한다.
테스트 작성 원칙 (감사 기준)
감사에서 살아남는 테스트의 기준이다. 새 테스트는 이 기준으로 작성한다.
- 행동 단위로 검증한다. 사용자가 보는 결과, 실행되는 계약을 검증한다. 소스 코드 문자열을 정규식으로 고정하는 테스트(source-regex)는 만들지 않는다 — 변수명 하나에 깨지고 실제 버그는 잡지 못해 감사에서 삭제 대상이 된다.
- 적용 완료된 마이그레이션 SQL의 문자열을 박제하지 않는다. 적용된 마이그레이션은 불변이므로 회귀 표면이 없다. 서버 계약은 pgTAP(
supabase/tests/database/)으로 실 DB에서 검증하거나, 현재 상태인supabase/schema.sql을 대상으로 검증한다. - CI 게이트와 중복 검사를 만들지 않는다. (예:
@ts-nocheck금지는check:ts-boundary-gate가 이미 강제한다.) - 테스트 통과 개수는 품질 지표가 아니다. 같은 보증이면 적은 테스트가 낫다.
날짜·시간 테스트 원칙
일반 테스트와 실제 E2E는 실행 당시의 날짜·연도·실제 시계를 사용해도 된다. 실행 시각을 입력으로 받게 하거나 동결하는 것을 기본 설계·작성 요건으로 삼지 않는다. 실행 도중 자정이나 연도 경계를 넘어서 발생하는 드문 실패는 감수한다.
수정 대상은 정상적인 실행 날짜에 잘못된 달력 전제가 계속 실패를 만드는 경우다. 예를 들어 특정 연도가 항상 과거라고 가정하거나, 1월 1~2일에도 올해의 과거 날짜가 세 개 있다고 가정하거나, 오래된 고정 fixture가 실제 DB 조회 창·유효기간을 벗어나는 문제를 고친다. Date.now()나 실제 올해를 쓴다는 이유만으로 규칙 위반으로 분류하지 않는다. 이 규칙은 작성·리뷰·감사 기준이며 check:test-manifest는 변경 신고만 검사한다.
적용 방법
- 업무 관계에 맞는 fixture를 만든다. 실제 올해를 표시하는 화면은 fixture와 기대값도 실제 올해를 사용해도 된다. 과거 연도가 필요하면 실제 올해의 이전 연도를 구한다. 연간 집계에 과거 기록 세 개가 필요하면 충분한 날짜가 있는 이전 연도에 준비하고 그 연도를 UI에서 선택한다. 올해 누적 동작이 검사 목적이면 올해 기간에 실제로 가능한 결과를 단언한다.
- 실제 사용자 동작과 원본 데이터를 보존한다. E2E는 사용자의 날짜 선택, 앱이 보낸 원본 요청, DB 저장 날짜, 재조회 결과가 맞는지 검사한다. 통과를 위해 요청 날짜를 덮어쓰거나 브라우저 전역 Date를 고정하거나 앱에 테스트용 시계를 배선하지 않는다. 실제 인증·서버· 네트워크 시계를 정상적으로 사용하며 시각 입력이나 mock으로의 일괄 전환을 요구하지 않는다.
- 기간과 timezone의 의미를 지킨다. seed가 속한 월·연도로 실제 UI를 통해 이동하고 정확한 날짜·기록을 확인한다. 실행일 기준 며칠 전의 기록이 현재 월에 있다고 가정하지 않는다.
YYYY-MM-DD업무 날짜는 해당 업무 timezone에서 구하고 UTC ISO 날짜와 섞지 않는다. Node·브라우저·DB·CLI가 항상 같은 날짜를 본다고 가정하지 않는다. - 단언이 의도한 분기를 확인하게 한다. 필요한 기록 수와 날짜·저장 정합성 단언을 유지한다.
false·null·빈 결과를 확인할 때는 fixture의 존재·유효성을 확인해 만료나 부재라는 다른 이유로 우연히 통과하지 않게 한다. 고정 날짜를 매번 최신으로 바꾸거나 제품 TTL을 늘리거나 단언을 삭제하는 방식으로 잘못된 전제를 감추지 않는다. - 수정과 검증 범위를 좁힌다. 자정 통과 경합은 허용하며 이를 없애기 위한 시계 주입이나 전체 테스트 전환을 하지 않는다. 실제 잘못된 전제가 있는 대상 검사로 수정 효과를 확인한다. 날짜 정리만을 위해 이미 통과한 Full CI를 반복하거나 모든 E2E에 날짜별 실행을 추가하지 않는다.
기존 시각 동작 테스트용 도구
만료 시점·타이머 진행 자체를 검사하는 기존의 좁은 테스트는 필요한 clock 도구를 유지할 수 있다. 아래는 tests/react/workoutDraftArchive.test.mjs의 seedEnvelope 등 기존 helper를 사용하는 참고 예다. 일반 테스트에서 실제 시계를 읽는다는 이유로 이 방식으로 바꾸지 않는다.
test("초안은 저장 후 7일 경계에 만료된다", async (t) => {
const savedAt = Date.parse("2026-08-29T10:00:00.000Z");
t.mock.timers.enable({ apis: ["Date"], now: savedAt });
const store = createMemoryWorkoutDraftStore();
const saved = await seedEnvelope(store, "운동", new Date(savedAt).toISOString());
const expiresAt = savedAt + 7 * 24 * 60 * 60 * 1000;
t.mock.timers.setTime(expiresAt - 1);
assert.deepEqual(await loadWorkoutDraftCache({ userId: USER_ID, store }), saved);
t.mock.timers.setTime(expiresAt);
assert.equal(await loadWorkoutDraftCache({ userId: USER_ID, store }), null);
assert.equal((await loadWorkoutDraftArchive({ userId: USER_ID, store }))[0].reason, "expired");
});이 도구를 쓰는 테스트에서는 context 종료 시 mock을 복원하고 같은 프로세스의 concurrent 테스트와 공유 시계를 바꾸지 않는다. apis: ["Date"]는 Date만 통제하므로 실제 타이머는 흐른다. 시각 문자열에는 Z 또는 UTC offset을 명시한다. 부모의 mock이 DB·브라우저·CLI 자식 프로세스에도 전파된다고 가정하지 않는다. API 기준: Node 22 test runner.
브라우저 E2E
실제 사용자 여정은 현재 시계와 앱이 만드는 날짜를 사용한다. "오늘" 버튼을 누른 경우에도 요청이나 저장 날짜가 잘못됐는지 확인하는 단언은 유지한다. 실행 도중 날짜가 바뀌는 드문 경합을 없애려고 중간 요청을 고치거나 시계를 동결하지 않는다.
기록 seed가 8월 29일이면 UI 달력을 8월로 이동하고 29일 셀을 선택한다. 실행일 기준 2일 전을 seed한 뒤 현재 월의 첫 .done 셀을 클릭하는 방식은 월초·다른 seed 구성에서 성립하지 않으므로 사용하지 않는다. 재조회와 새로고침 뒤에도 같은 기록을 확인한다. 상세 작성 기준은 실행 날짜와 달력 전제 체크리스트를 따른다.
PR 리뷰 확인
- 실제 날짜 사용 자체가 아니라 잘못된 연도·기간·유효기간 전제를 수정했는가?
- fixture와 기대값의 업무 관계·timezone 의미가 맞으며 필요한 기록 수가 유지되는가?
- E2E의 실제 사용자 선택·원본 요청·DB 저장·재조회 검증을 보존했는가?
- 음성 결과가 fixture 만료·부재 같은 다른 이유로 통과하지 않는가?
리뷰 확인은 시간 관련 테스트를 추가·수정하는 PR에 적용한다. 날짜 리터럴이나 Date.now() 문자열의 존재만으로 안전/위험을 판정하지 않는다. 실제 현재시각을 읽는 호출 경로와 단언을 함께 확인한다.
줄바꿈(LF) 정책과 결정성 도구 (이슈 #1280 G02)
줄바꿈
저장소의 텍스트 파일은 색인에도 작업 폴더에도 LF 다. 정책은 루트 .gitattributes (* text=auto eol=lf + 바이너리 목록)가 선언하고, npm run check 첫 단계인 scripts/check-utf8.mjs가 git 이 추적하는 텍스트 파일에서 CRLF 를 실패로 잡는다. tests/react/lineEndingPolicy.test.mjs가 이 셋(선언·색인·검사)을 잠근다. 비결정성 분류·소스 모양 검사 전환 목록·검사 자산 인벤토리는 test-baseline-inventory.md.
2026-09-07 이전에는 이 규칙이 한 PC 의
.git/info/attributes(커밋되지 않는 로컬 파일)에만 있었다. 그래서 Windows 체크아웃은 파일 1,500여 개가 CRLF 로 받아졌고,schema.sql의 "CRLF 없음" 단언이 PC 에 따라 다른 결과를 냈다.그 전에 받은 체크아웃·워크트리는 작업 폴더가 CRLF 인 채로 남는다(git 은 정규화 뒤 내용이 같으면 스스로 고쳐 쓰지 않는다).
npm run check가 CRLF 로 빨갛게 되면 한 번만:bashnode scripts/normalize-line-endings.mjs내용은 그대로 두고 줄바꿈만 LF 로 바꾸며, 색인 stat 을 새로 잡아
git status도 깨끗해진다. 내용까지 바뀐 파일은 스테이징하지 않고 그대로 둔다.--check는 목록만 보여 준다.생성 파일(
supabase/schema.sql)은 생성기가 LF 로 쓴다. 파일 검사와 생성기의 의도가 같다..env.local같은 미추적 로컬 파일의 줄바꿈은 정책 밖이다(검사하지 않는다).
결정성 도구 (tests/support/)
만료·타이머 진행·식별자·브라우저 전역을 좁은 범위에서 제어할 때 사용하는 기존 공용 도구다. 일반 테스트에서 실제 날짜·연도를 사용하는 대신 이 도구를 쓰도록 요구하지 않는다.
| 도구 | 쓰는 곳 | 하는 일 |
|---|---|---|
clock.mjs useFixedClock(t, "2026-08-29T10:00:00.000Z") | 기존의 만료 시점 등 시각 동작 검사 | Date 만 고정(실제 타이머는 흐름). advance(ms)·set(iso). 오프셋 없는 시각 문자열은 거부. context 종료 시 자동 복원 |
clock.mjs useFixedTimers(t, iso) | timeout·재시도·debounce 검증 | Date + setTimeout/Interval 을 잡고 tick(ms) 로 진행 |
ids.mjs createSequentialUuidFactory() | operationId·clientMutationId 같은 식별자 입력 | v4 모양의 순번 UUID(…-000000000001) |
scheduler.mjs createManualScheduler() | sleep·setTimer 를 인자로 받는 코드 | 예약 순서대로 advance(ms)/flush() 로 실행 |
storage.mjs installBrowserGlobals(t) | window·localStorage 가 필요한 테스트 | 테스트 동안만 붙이고 끝나면 복원 — 값이 다음 테스트로 새지 않는다 |
clockShift.mjs (preload) | 잘못된 날짜 전제의 대상 진단 | BARBELIC_TEST_CLOCK_OFFSET_MS 만큼 실제 시계를 옮긴 자식 프로세스에서 대상 테스트를 돌린다. suite 전체 동결이나 일반 E2E의 시계 주입용이 아니다 |
기존 tests/react/dateIndependence.test.mjs는 실제 시계를 ±400일 옮긴 자식 프로세스에서 초안 보관함 TTL 테스트(PR #1278 수리분)를 돌려 같은 결과를 확인한다. 이 좁은 검사를 모든 테스트에 시각 입력·동결이나 날짜별 반복 실행을 요구하는 근거로 확대하지 않는다. 자식 CLI 프로세스가 실제 벽시계로 판정하는 테스트(랜딩 잠금 preflight)는 부모 시계 이동과 본성상 결합이라 이 증명의 대상이 아니다 — 그런 테스트의 만료 픽스처는 절대 과거 시각으로 쓴다.
감사 이력
- v0 (2026-07-30): 1,067개 전수 분류 — KEEP 48파일/432, TRIM 36/384, REWRITE 13/157, DELETE_REDESIGN 3/28(#314에서 소스와 함께 삭제됨), DELETE_LOW_VALUE 13/70. 갭 분석 3축(쓰기 경로·보안/인증·도메인 정확성).
- v1 (2026-07-31, PR #316): critical 갭 메꿈 — 완료 세션 stale-revision 40001 pgTAP, 멱등키 사용자 스코프 2-유저 pgTAP, 전 테이블 RLS/revoke 스윕, 카탈로그 admin 정책, receipt reader revoke. 저가치 12파일 삭제, 신고제 게이트 도입 (suiteVersion 1).
- v2 (2026-07-31): 첫 신고 사이클 처리 — #315/#317 신고 7건 검토(전부 정직, 검증 약화 없음; PR 번호 오표기 3건은 substance 문제 없음), 신규
pendingWorkoutSaves.test.mjsKEEP 판정. 미검증 갭 2건을 감사자가 직접 보강: ① 2-유저 격리(다른 사용자의 pending 행을 전송·삭제하지 않음), ② replayed:true v4 영수증이 실제 create 경로에서 성공으로 수용되어 행이 삭제되는 통합 검증. 머지 과정에서 #319(데스크톱 파일 분할)의 신고 9건(순수 경로 확장, assert 삭제 0줄)과 #320의 CI pin 갱신 신고까지 추가 검토·승인 후 최신 main 기준으로 manifest 재생성 (suiteVersion 3, 104파일). 잔여 backlog: 계정 전환 시 컨트롤러 레벨 pending 목록 클리어 미검증, retry 루프의 영구 거부 행 head-of-line blocking(디스포지션 미분류) — Codex 후속. - v3~v4 (2026-07-31): 시나리오 커버리지 감사(5축, critical 4·important 26) 대응 — 멱등키 수명주기 행동 5건, wodup·lifted 공존 pgTAP 12건(인입 세션 수정의 provenance 보존 계약; 값 미적용 발견 → #326), e2e rm_test 활성 + 노트 상세 #321-skip 게이트. #323(LG409 충돌 전환 + 삭제 FK 수정) 신고 6건 검토·승인 — 삭제 4줄은 전부 계약 이행 조정(약화 없음), e2e 충돌·삭제 왕복 un-skip으로 활성화. manifest 재생성 (suiteVersion 4, 106파일). 잔여: #321·#322·#325·#326 해결 시 게이트 추가.