[test-82/unit] 핵심 도메인 로직 단위 테스트 도입과 PR 검증 게이트 구축 - #214
Conversation
컴포넌트 함수명이 소문자여서 react-hooks/rules-of-hooks 가 15건 발생하고 있었다. 파일명과 임포트하는 쪽은 이미 NbreadDetail 이므로 선언부만 대문자로 맞춘다. 탈퇴 처리의 userData?.id! 는 옵셔널 체이닝 결과에 비널 단언을 붙여 undefined 가 그대로 전달될 수 있었다. 호출 전에 가드를 두어 기존 실패 경로와 동일하게 catch 로 떨어지도록 한다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@typescript-eslint/no-explicit-any 위반 6건을 실제 타입으로 대체한다. - not-found: window 확장 속성을 global.d.ts 의 Window 선언으로 옮김 - Community: mapToPost 인자를 PostRow 로 지정. post 테이블의 nullable 컬럼과 Post 타입의 불일치가 드러나 빈 문자열로 보정 - getSearchFriend: inviteData 를 조회 컬럼 기준 Pick 타입으로 지정 - deletePost, updatePost: 호출부가 넘기는 값에 맞춰 인자 타입 지정 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
develop, main 으로 향하는 PR 에서 타입 체크, 린트, 단위 테스트를 실행한다. 빠르게 실패하는 순서로 배치해 타입 오류를 가장 먼저 끊는다. 릴리즈 노트 워크플로우와 트리거가 겹치지 않도록 별도 파일로 분리했고, E2E 는 실행 시간과 외부 의존이 달라 같은 워크플로우에 합치지 않는다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
테스트 러너가 없어 CI 검증 게이트의 test 단계를 채울 수 없었다. vitest 만 추가한다. 경로 별칭은 Vite 가 기본 지원하는 resolve.tsconfigPaths 로 해결해 vite-tsconfig-paths 플러그인은 두지 않는다. 커버리지 리포터도 수치를 성과로 쓰지 않기로 해 넣지 않는다. 설정은 vitest.config.mts 로 둔다. .ts 로 두면 CommonJS 로 로드되면서 ESM 구문 경고가 발생한다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
같은 계산이 세 곳에 중복돼 있었고 0 나눗셈 가드가 서로 달랐다. 참여 인원이 0일 때 getUserTotalNbreadAmount 는 총 금액, nbreadCard 는 0, home/NbreadCard 는 Infinity 를 반환해 화면에 그대로 노출될 수 있었다. calculateIndividualShare 로 추출하고 가장 방어적이던 동작으로 통일한다. 참여 인원이 0 이하이거나 숫자가 아니면 1명으로 취급하고, 금액이 유한하지 않으면 0을 반환한다. home/NbreadCard 의 Infinity 노출 경로가 사라지고, nbreadCard 는 참여 인원이 0일 때 0 대신 총 금액을 표시하도록 바뀐다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
nbread_records.payment_date 조회와 갱신이 각자 같은 변환식을 들고 있었다. 이 키가 어긋나면 조회는 0건, 갱신은 0행이 되고 예외가 발생하지 않아 납부 체크가 조용히 실패한다. toPaymentDateKey 로 한 곳에 모은다. 변환 규칙은 그대로 두고 위치만 옮긴다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
틀리면 사용자에게 즉시 금전 또는 신뢰 문제가 되는 로직 세 곳을 고정한다. - calculateIndividualShare: 인원 0, 음수, 1명 미만 소수, NaN 과 음수 금액까지 포함해 0 나눗셈이 화면에 새지 않는지 검증한다 - toPaymentDateKey: UTC 환산 뒤 날짜를 자르는 현재 규칙을 회귀 테스트로 고정한다. KST 자정 입력이 전날로 밀리는 경계를 명시하고, 해석할 수 없는 입력은 조용히 잘못된 키를 만들지 않고 예외로 끊는지 확인한다 - getNotificationDestination: 알림 타입 6종, camelCase 와 snake_case 이중 키, data 가 null 배열 빈 객체인 경우, URL 인코딩을 검증한다 입출력만 다른 케이스는 test.for 로 묶어 실패 시 어느 입력이 깨졌는지 리포트에 드러나게 한다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
E2E 워크플로와 Playwright 테스트가 develop 에 반영되어 뒤처진 13커밋을 가져온다. package.json 의 lint 스크립트 충돌은 develop 쪽(--dir src --dir e2e)을 받고 이 브랜치에서 추가한 typecheck, test, test:watch 스크립트를 함께 남긴다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI 의 깨끗한 체크아웃에서 tsc --noEmit 이 SVG 임포트 전부를 TS2307 로 실패했다. next-env.d.ts 가 .gitignore 대상이라 새 체크아웃에는 존재하지 않고, 그 파일이 참조하는 next/image-types/global 이 *.svg 선언을 제공하기 때문이다. 로컬에서는 이전 next dev 실행이 남긴 파일이 있어 통과해 드러나지 않았다. next build 를 게이트에 넣어 파일을 생성하게 하는 대신 선언을 저장소에 둔다. 게이트의 목적이 빠른 피드백이고, 빌드는 Supabase 환경 변수 의존 가능성이 있다. @svgr/webpack 을 쓰므로 SVG 는 React 컴포넌트다. next/image-types/global 의 any 대신 FunctionComponent<SVGProps<SVGSVGElement>> 로 선언한다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
actions/checkout@v4 와 setup-node@v4 가 Node.js 20 을 대상으로 해 런너가 deprecation 경고를 낸다. develop 의 E2E 워크플로가 이미 v7 과 Node 22 로 올라가 있어 같은 버전으로 맞춘다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
게이트가 통과와 실패 양쪽으로 동작하는 것을 PR #214 에서 확인했다. 타입 오류는 Type check 단계에서, 테스트 실패는 Unit test 단계에서 각각 끊기고 두 경우 모두 develop 머지가 차단되는 것을 확인했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
검증 게이트 동작 확인 결과
실행 시간
통과 기준 단계별 소요는 아래와 같습니다.
머지 차단 확인두 실패 모두
적용한 브랜치 보호 규칙
이 과정에서 게이트가 잡아낸 실제 문제게이트를 처음 돌렸을 때 로컬에서는 통과하던 타입 체크가 CI에서 실패했습니다. 원인은
로컬 검증만으로는 이 문제를 계속 놓쳤을 것이고, 게이트를 붙인 첫 실행에서 드러났습니다. 그 외 확인된 것
|
Actions 사이드바에 "CI - Unit Test", "CI E2E test" 로 표기 방식이 서로 달라 일관성이 없었다. 파일명에 이미 ci- 접두사가 있어 이름에서는 뺀다. required status check 이름은 job 이름(unit-verify)이므로 이 변경에 영향받지 않는다. 브랜치 보호 규칙은 그대로 유지된다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
핵심 도메인 로직 3개에 Vitest 단위 테스트 43건을 추가하고, PR마다 타입 체크 → 린트 → 단위 테스트를 실행하는 검증 게이트를 GitHub Actions에 붙였습니다.
게이트를 켤 수 있는 상태를 만들기 위해 기존에 누적된 린트 에러 22건을 함께 해소했습니다.
E2E는 이 PR 범위가 아니며 워크플로우 파일을 분리해 두었습니다.
Closes #213
Why
왜 이 작업을 진행했나요?
금전 계산이 지점마다 다른 답을 내고 있었습니다.
1인당 정산 금액 계산이 세 곳에 중복되어 있었고 0 나눗셈 가드가 서로 달랐습니다.
getUserTotalNbreadAmount.tsMath.floor(amount / Math.max(count, 1))nbread/nbreadCard.tsxMath.floor(amount / count) || 00home/NbreadCard.tsxMath.floor(amount / count)Infinity세 번째는 화면에
∞원으로 노출될 수 있었습니다.납부 상태 정합성이 검증되지 않은 한 줄에 걸려 있었습니다.
nbread_records.payment_date조회와 갱신이 각자new Date(startDate).toISOString().split('T')[0]을 들고 있었습니다. 이 키가 어긋나면 조회는 0건, 갱신은 0행이 되고 예외가 발생하지 않습니다. 사용자가 납부 체크를 눌러도 반영되지 않고 오류도 표시되지 않는 경로입니다.저장소에 테스트 러너가 없었습니다.
test스크립트가 없고.github/workflows에는 릴리즈 노트 자동화만 있어 PR에서 아무 검증도 실행되지 않았습니다.Decision
어떤 구현 방식을 선택했나요?
대상 선정 기준은 "틀리면 사용자에게 즉시 금전 또는 신뢰 문제가 되는 로직"입니다. UI 컴포넌트와 E2E는 제외했습니다.
제외한 후보와 이유를 남깁니다.
userData?.id === nbreadData.leaderId형태로 JSX에 인라인되어 있습니다. 실제 권한은 RLS가 담당하므로 프론트 단위 테스트로 검증되는 것이 사실상 없습니다.insertParticipant정원 만료 판정: 판정식 자체는 의미 있으나 IO 두 번 사이에 끼어 있어 추출 비용이 앞의 두 대상보다 큽니다.sortNotifications,getRelativeTime: 이미 순수 함수이고 테스트가 쉽지만, 틀려도 정렬 순서나 표시 문구만 어긋나 선정 기준에 미달합니다.러너는 Vitest를 골랐습니다. 기존 설정이 없어 신규 도입입니다. 대안으로 Jest를 검토했으나 Next.js + TypeScript 환경에서 별도 트랜스파일 설정이 필요해 제외했습니다.
의존성은
vitest하나만 추가했습니다. 처음에vite-tsconfig-paths를 넣었다가, Vite가resolve.tsconfigPaths로 같은 기능을 기본 제공한다는 경고를 확인하고 제거했습니다. 커버리지 리포터는 커버리지 수치를 성과 지표로 쓰지 않기로 해서 넣지 않았습니다.CI는 E2E와 파일을 분리했습니다. 한 워크플로우에 합치면 느리고 불안정한 쪽이 빠르고 확정적인 쪽을 인질로 잡습니다. E2E가 타임아웃 한 번 나면 타입 오류 0건인 PR도 머지가 막힙니다. 분리 근거는 세 가지입니다.
게이트 순서는 타입 체크 → 린트 → 테스트입니다. 빠르게 실패하는 것을 앞에 둡니다.
npm run build는 넣지 않았습니다. 느리고 Supabase 환경 변수 의존 가능성이 있어 게이트의 목적인 빠른 피드백에 맞지 않습니다.Changes
무엇이 변경되었나요?
Fix
NbreadDetail.tsx의 컴포넌트 함수명이 소문자nbreadDetail이어서react-hooks/rules-of-hooks에러가 15건 발생하고 있었습니다. 파일명과 임포트하는 쪽은 이미NbreadDetail이므로 선언부만 대문자로 맞췄습니다. 호출부 수정은 0건입니다.deleteParticipants(userData?.id!, ...)는 옵셔널 체이닝 결과에 비널 단언을 붙여undefined가 그대로 전달될 수 있었습니다. 호출 전에 가드를 두었습니다.Refactor
calculateIndividualShare추출. 세 곳의 중복을 없애고 가장 방어적이던 동작으로 통일했습니다.toPaymentDateKey추출. 변환 규칙은 그대로 두고 위치만 옮겼습니다.@typescript-eslint/no-explicit-any위반 6건을 실제 타입으로 대체했습니다.not-found.tsx의(window as any)는global.d.ts의Window선언으로 옮기고,Community.tsx와getSearchFriend.ts는 Supabase Row 타입을 적용하고,deletePost/updatePost는 호출부가 넘기는 값에 맞춰 인자 타입을 지정했습니다.Test
calculateIndividualShare.test.ts12건. 인원 0, 음수, 1명 미만 소수,NaN, 금액 0,NaN,Infinity, 음수 금액, 1원 미만 내림.toPaymentDateKey.test.ts7건. KST 자정이 UTC 기준 전날로 밀리는 경계를 명시적으로 고정했고, 해석할 수 없는 입력은 예외로 끊는지 확인합니다.getNotificationDestination.test.ts24건. 알림 타입 6종, camelCase와 snake_case 이중 키,data가null/배열/빈 객체/숫자인 경우, URL 인코딩.test.for로 묶어 실패 시 어느 입력이 깨졌는지 리포트에 드러나게 했습니다.Setting
vitest도입,vitest.config.mts추가.package.json에typecheck,test,test:watch스크립트 추가.CI/CD
.github/workflows/ci-unit-test.yml추가. job 이름은unit-verify입니다.Trade-off
한계와 트레이드오프
toPaymentDateKey의 동작은 고치지 않았습니다. KST 자정 입력이 전날로 밀리는 문제가 남아 있습니다.start_date가 date 컬럼이라 현재는 문제가 되지 않지만, 타임스탬프가 섞이면 결제일이 하루 밀립니다. 이번 PR은 현재 동작을 회귀 테스트로 고정하는 것까지만 하고, 수정은 별도 이슈가 맞다고 판단했습니다.Impact
기존 기능에 미치는 영향
동작이 바뀌는 지점 2개
home/NbreadCard.tsx에서 참여 인원이 0일 때Infinity(∞원)가 아니라 총 금액이 표시됩니다.nbread/nbreadCard.tsx는 같은 조건에서0이 아니라 총 금액이 표시됩니다. 참여 인원 0은 DB 제약상 정상 경로에서 발생하지 않지만, 세 지점의 답이 달랐던 것을 통일했습니다.Community.tsx의mapToPost에서post테이블의 nullable 컬럼(content,user_name,profile_image,user_id)이null일 때 빈 문자열로 렌더됩니다.any를 제거하니 드러난 실제 타입 불일치입니다. 화면상 둘 다 빈 값이라 눈에 보이는 차이는 없습니다.회귀 가능성이 있는 영역
NbreadDetail.tsx컴포넌트 리네임. default export이고 임포트하는 쪽이 이미NbreadDetail을 쓰고 있어 영향이 없다고 판단하지만, 엔빵 상세 페이지 전체가 이 컴포넌트라 렌더 확인이 필요합니다.getNbreadRecords,updateNbreadRecord의 날짜 키 생성. 변환식은 동일하지만 납부 체크 경로 전체가 여기에 의존합니다.배포 시 확인할 사항
develop에unit-verifyrequired status check를 등록하면 다른 기여자의 머지에도 적용됩니다.Edge Cases
Edge Case 및 실패 시나리오
0, 음수,NaN,0.5(정수로 버리면 0이 되는 소수)인 경우를 모두 1명으로 취급합니다.NaN또는Infinity면 0을 반환합니다.RangeError를 던집니다. 조용히 잘못된 키를 만드는 것보다 끊는 편이 안전하다고 판단했습니다.data가 배열이거나 값이 빈 문자열이면 키가 없는 것으로 취급합니다. camelCase 키가 비어 있으면 snake_case 키로 넘어갑니다.include를src/**/*.test.ts로 좁혀e2e/*.spec.ts가 걸리지 않게 했습니다.Review Points
리뷰어가 집중해서 봐야 할 부분
🔴 High
calculateIndividualShare로 통일하면서home/NbreadCard.tsx와nbread/nbreadCard.tsx의 참여 인원 0일 때 표시값이 바뀝니다. 통일 방향(1명으로 취급 = 총 금액 표시)이 맞는지 확인이 필요합니다.🟡 Medium
NbreadDetail.tsx리네임이 엔빵 상세 페이지 렌더에 영향이 없는지. 그리고 탈퇴 처리에 추가한 가드가 기존 실패 경로(catch → 토스트)와 동일하게 동작하는지.🟢 Low
develop에 걸 브랜치 보호 규칙의 범위. 이번에는unit-verifyrequired + 최신화 요구(strict)까지만 적용하고 리뷰 승인 요구나 force push 금지는 넣지 않았습니다.Validation
어떻게 검증했나요?
npm test, 411~566ms)npm run lintexit 0 (Error 0건)npm run typecheckexit 0CI에서의 통과 시간과, 일부러 실패를 만들었을 때 머지가 막히는지는 이 PR에서 직접 확인하고 결과를 코멘트로 남깁니다.
남기는 기록
NbreadDetail.tsx의 탈퇴 실패 처리에서useToast.success('엔빵 나가기에 실패했어요.')로 실패인데success를 호출하는 기존 버그를 발견했습니다. 린트 대상이 아니고 이번 범위 밖이라 그대로 두었습니다. 별도 이슈가 필요합니다.