Skip to content

[test-82/unit] 핵심 도메인 로직 단위 테스트 도입과 PR 검증 게이트 구축 - #214

Merged
hm1n merged 14 commits into
developfrom
test-82/unit
Sep 2, 2026
Merged

[test-82/unit] 핵심 도메인 로직 단위 테스트 도입과 PR 검증 게이트 구축#214
hm1n merged 14 commits into
developfrom
test-82/unit

Conversation

@hm1n

@hm1n hm1n commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

핵심 도메인 로직 3개에 Vitest 단위 테스트 43건을 추가하고, PR마다 타입 체크 → 린트 → 단위 테스트를 실행하는 검증 게이트를 GitHub Actions에 붙였습니다.
게이트를 켤 수 있는 상태를 만들기 위해 기존에 누적된 린트 에러 22건을 함께 해소했습니다.
E2E는 이 PR 범위가 아니며 워크플로우 파일을 분리해 두었습니다.

Closes #213


Why

왜 이 작업을 진행했나요?

금전 계산이 지점마다 다른 답을 내고 있었습니다.

1인당 정산 금액 계산이 세 곳에 중복되어 있었고 0 나눗셈 가드가 서로 달랐습니다.

위치 기존 코드 참여 인원 0일 때
getUserTotalNbreadAmount.ts Math.floor(amount / Math.max(count, 1)) 총 금액
nbread/nbreadCard.tsx Math.floor(amount / count) || 0 0
home/NbreadCard.tsx Math.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도 머지가 막힙니다. 분리 근거는 세 가지입니다.

  1. required status check를 따로 등록할 수 있습니다. E2E는 안정화 전까지 required로 걸지 않는 선택이 가능해집니다.
  2. E2E는 Supabase 자격증명이 필요해 fork PR에서 시크릿 접근이 불가합니다. 단위 테스트는 시크릿 없이 동작합니다.
  3. E2E 작업이 다른 워크트리에서 병행 중이라 파일이 다르면 병합 충돌이 없습니다.

게이트 순서는 타입 체크 → 린트 → 테스트입니다. 빠르게 실패하는 것을 앞에 둡니다. 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.tsWindow 선언으로 옮기고, Community.tsxgetSearchFriend.ts는 Supabase Row 타입을 적용하고, deletePost/updatePost는 호출부가 넘기는 값에 맞춰 인자 타입을 지정했습니다.

Test

  • calculateIndividualShare.test.ts 12건. 인원 0, 음수, 1명 미만 소수, NaN, 금액 0, NaN, Infinity, 음수 금액, 1원 미만 내림.
  • toPaymentDateKey.test.ts 7건. KST 자정이 UTC 기준 전날로 밀리는 경계를 명시적으로 고정했고, 해석할 수 없는 입력은 예외로 끊는지 확인합니다.
  • getNotificationDestination.test.ts 24건. 알림 타입 6종, camelCase와 snake_case 이중 키, datanull/배열/빈 객체/숫자인 경우, URL 인코딩.
  • 입출력만 다른 케이스는 test.for로 묶어 실패 시 어느 입력이 깨졌는지 리포트에 드러나게 했습니다.

Setting

  • vitest 도입, vitest.config.mts 추가.
  • package.jsontypecheck, test, test:watch 스크립트 추가.

CI/CD

  • .github/workflows/ci-unit-test.yml 추가. job 이름은 unit-verify입니다.

Trade-off

한계와 트레이드오프

  • 단위 테스트는 순수 함수만 덮습니다. Supabase 호출 경로, RLS, 실제 쿼리 정확성은 검증하지 않습니다. 이 영역은 E2E와 이후 API Route 이관 시점의 계약 테스트가 맡아야 합니다.
  • 추출한 두 함수의 호출부가 실제로 새 함수를 쓰는지는 자동으로 검증되지 않습니다. UI 테스트를 범위에서 제외했기 때문입니다. 호출부 5곳은 표현식 한 줄 교체라 diff로 확인 가능한 수준으로 유지했습니다.
  • toPaymentDateKey의 동작은 고치지 않았습니다. KST 자정 입력이 전날로 밀리는 문제가 남아 있습니다. start_date가 date 컬럼이라 현재는 문제가 되지 않지만, 타임스탬프가 섞이면 결제일이 하루 밀립니다. 이번 PR은 현재 동작을 회귀 테스트로 고정하는 것까지만 하고, 수정은 별도 이슈가 맞다고 판단했습니다.
  • 린트 Warning은 남아 있습니다. exit code에 영향이 없어 게이트를 막지 않습니다. Error만 해소했습니다.

Impact

기존 기능에 미치는 영향

동작이 바뀌는 지점 2개

  1. home/NbreadCard.tsx에서 참여 인원이 0일 때 Infinity(∞원)가 아니라 총 금액이 표시됩니다. nbread/nbreadCard.tsx는 같은 조건에서 0이 아니라 총 금액이 표시됩니다. 참여 인원 0은 DB 제약상 정상 경로에서 발생하지 않지만, 세 지점의 답이 달랐던 것을 통일했습니다.
  2. Community.tsxmapToPost에서 post 테이블의 nullable 컬럼(content, user_name, profile_image, user_id)이 null일 때 빈 문자열로 렌더됩니다. any를 제거하니 드러난 실제 타입 불일치입니다. 화면상 둘 다 빈 값이라 눈에 보이는 차이는 없습니다.

회귀 가능성이 있는 영역

  • NbreadDetail.tsx 컴포넌트 리네임. default export이고 임포트하는 쪽이 이미 NbreadDetail을 쓰고 있어 영향이 없다고 판단하지만, 엔빵 상세 페이지 전체가 이 컴포넌트라 렌더 확인이 필요합니다.
  • getNbreadRecords, updateNbreadRecord의 날짜 키 생성. 변환식은 동일하지만 납부 체크 경로 전체가 여기에 의존합니다.

배포 시 확인할 사항

  • 이 PR은 런타임 동작보다 개발 환경 변경이 큽니다. Vercel 빌드에 영향을 주는 변경은 없습니다.
  • developunit-verify required status check를 등록하면 다른 기여자의 머지에도 적용됩니다.

Edge Cases

Edge Case 및 실패 시나리오

  • 참여 인원이 0, 음수, NaN, 0.5(정수로 버리면 0이 되는 소수)인 경우를 모두 1명으로 취급합니다.
  • 총 금액이 NaN 또는 Infinity면 0을 반환합니다.
  • 날짜 키 변환에서 빈 문자열과 해석 불가 문자열은 RangeError를 던집니다. 조용히 잘못된 키를 만드는 것보다 끊는 편이 안전하다고 판단했습니다.
  • 알림 data가 배열이거나 값이 빈 문자열이면 키가 없는 것으로 취급합니다. camelCase 키가 비어 있으면 snake_case 키로 넘어갑니다.
  • Vitest가 Playwright 스펙을 집어가는 경우: includesrc/**/*.test.ts로 좁혀 e2e/*.spec.ts가 걸리지 않게 했습니다.

Review Points

리뷰어가 집중해서 봐야 할 부분

🔴 High

  • calculateIndividualShare로 통일하면서 home/NbreadCard.tsxnbread/nbreadCard.tsx의 참여 인원 0일 때 표시값이 바뀝니다. 통일 방향(1명으로 취급 = 총 금액 표시)이 맞는지 확인이 필요합니다.

🟡 Medium

  • NbreadDetail.tsx 리네임이 엔빵 상세 페이지 렌더에 영향이 없는지. 그리고 탈퇴 처리에 추가한 가드가 기존 실패 경로(catch → 토스트)와 동일하게 동작하는지.

🟢 Low

  • develop에 걸 브랜치 보호 규칙의 범위. 이번에는 unit-verify required + 최신화 요구(strict)까지만 적용하고 리뷰 승인 요구나 force push 금지는 넣지 않았습니다.

Validation

어떻게 검증했나요?

  • 단위 테스트 — 로컬 43건 통과 (npm test, 411~566ms)
  • E2E 테스트 — 이번 PR 범위 아님
  • 수동 테스트 — 게이트 3단계를 로컬에서 순차 실행해 통과 확인
  • lint — npm run lint exit 0 (Error 0건)
  • typecheck — npm run typecheck exit 0

CI에서의 통과 시간과, 일부러 실패를 만들었을 때 머지가 막히는지는 이 PR에서 직접 확인하고 결과를 코멘트로 남깁니다.


남기는 기록

NbreadDetail.tsx의 탈퇴 실패 처리에서 useToast.success('엔빵 나가기에 실패했어요.')로 실패인데 success를 호출하는 기존 버그를 발견했습니다. 린트 대상이 아니고 이번 범위 밖이라 그대로 두었습니다. 별도 이슈가 필요합니다.

hm1n and others added 7 commits September 1, 2026 14:01
컴포넌트 함수명이 소문자여서 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>
@hm1n hm1n added the 🧪 Test 테스트 코드 작성 및 테스트 환경 label Sep 2, 2026
@hm1n hm1n self-assigned this Sep 2, 2026
@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
nbread Ready Ready Preview Sep 2, 2026 5:36am UTC

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>
@hm1n

hm1n commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

검증 게이트 동작 확인 결과

developunit-verify를 required status check(strict)로 등록한 뒤, 이 PR에서 통과와 실패 양쪽을 확인했습니다.

실행 시간

경우 총 시간 어디서 끊겼는지 머지
통과 43초 전 단계 통과 가능
타입 오류 31초 Type check (5초 만에 실패, Lint·Unit test 건너뜀) 차단
테스트 실패 39초 Unit test (Type check 5초·Lint 8초 통과 후) 차단

통과 기준 단계별 소요는 아래와 같습니다.

Checkout               2초
Setup Node             6초
Install dependencies  18초
Type check             6초
Lint                   7초
Unit test              1초

npm ci가 18초로 가장 큰 비중이라 단계 순서로 절약되는 시간은 12초 수준입니다. 다만 타입 오류일 때 린트와 테스트를 아예 실행하지 않는다는 점은 의도한 대로 동작합니다.

머지 차단 확인

두 실패 모두 gh pr merge가 아래와 같이 거부되었습니다.

X Pull request andbread/Andbread_Frontend#214 is not mergeable:
  the base branch policy prohibits the merge.

mergeStateStatusBLOCKED으로 바뀌는 것을 확인했습니다.

적용한 브랜치 보호 규칙

required status checks : unit-verify
strict                 : true   (최신 develop 동기화 필요)
enforce_admins         : false
required reviews       : 없음
force push / deletion  : 금지

enforce_admins: false이므로 admin은 --admin 플래그로 우회할 수 있습니다. 긴급 상황을 위해 남겨두었습니다. 필요하면 true로 올릴 수 있습니다.

CI E2E test는 required로 등록하지 않았습니다. 실행 시간과 외부 의존이 달라 안정화 데이터가 쌓인 뒤 판단하는 편이 낫다고 보았습니다.


이 과정에서 게이트가 잡아낸 실제 문제

게이트를 처음 돌렸을 때 로컬에서는 통과하던 타입 체크가 CI에서 실패했습니다.

error TS2307: Cannot find module '@/assets/logo/nbreads-not-found.svg'
  or its corresponding type declarations.

원인은 next-env.d.ts.gitignore 대상이어서 CI의 새 체크아웃에는 존재하지 않는 것이었습니다. 이 파일이 참조하는 next/image-types/global*.svg 모듈 선언을 제공하는데, 로컬에는 이전 next dev 실행이 남긴 파일이 있어서 문제가 드러나지 않았습니다.

next build를 게이트에 넣어 파일을 생성하게 하는 대신 src/types/svg.d.ts를 저장소에 추가했습니다. 게이트의 목적이 빠른 피드백이고, 빌드는 Supabase 환경 변수 의존 가능성이 있기 때문입니다. @svgr/webpack을 쓰므로 next/image-types/globalany 대신 FunctionComponent<SVGProps<SVGSVGElement>>로 선언했습니다.

로컬 검증만으로는 이 문제를 계속 놓쳤을 것이고, 게이트를 붙인 첫 실행에서 드러났습니다.

그 외 확인된 것

  • conflict 상태의 PR은 CI가 아예 실행되지 않습니다. pull_request 워크플로우는 merge commit 기준으로 돌기 때문에, 충돌이 있으면 merge commit을 만들 수 없어 실행이 생성되지 않습니다. 이 PR도 처음에는 develop에 E2E 작업이 머지되어 13커밋 뒤처진 상태였고, unit-verify가 실행되지 않았습니다. strict: true와 함께 보면, 뒤처진 PR은 실행 자체가 안 되므로 머지 전에 반드시 최신화가 필요합니다.
  • 액션 버전을 develop의 E2E 워크플로와 맞춰 checkout@v7, setup-node@v7, Node 22로 올렸습니다. v4는 Node 20 대상이라 deprecation 경고가 발생했습니다.

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>
@hm1n
hm1n merged commit 039e3af into develop Sep 2, 2026
4 checks passed
@hm1n
hm1n deleted the test-82/unit branch September 2, 2026 05:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🧪 Test 테스트 코드 작성 및 테스트 환경

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[test-82/unit] 핵심 도메인 로직 단위 테스트 도입과 PR 검증 게이트 구축

1 participant