
이커머스 프로젝트에 테스트를 도입한 이야기 — Vitest + MSW로 주문 플로우 검증하기
이커머스 프로젝트에 테스트를 도입한 이야기 — Vitest + MSW로 주문 플로우 검증하기
3년차 프론트엔드 개발자가 운영 중인 커머스 프로젝트(Next.js + Zustand)에 테스트를 도입하며 배운 것들을 정리합니다.
이직을 준비하던 중 한 회사의 과제를 진행하게 되었습니다. 과제는 여러 번 해봤지만, 이번에는 처음으로 **“테스트 코드 작성”**이 요구사항에 포함되어 있었습니다.
테스트는 예전에 개발을 배울 때 Jest로 다뤄본 경험이 전부였고, 이후 실무에서는 아쉽게도 테스트를 제대로 도입해본 적이 없었습니다. 그런데 과제를 진행하며 다시 몰입해 공부하고 테스트를 붙이는 과정이 하나의 챌린지가 되었고, 자연스럽게 이런 생각이 들었습니다.
“이참에 실무에도 테스트를 도입해보자.”
현재 제가 맡고 있는 프로젝트는 운영 중인 이커머스입니다. 작년 8월 권고사직이 진행됐고, 제가 맡았던 자사 프로젝트는 중단되었습니다. 협업하던 커머스 프로젝트만 남게 되었고, 운이 좋았던 건지 나빴던 건지 모르겠지만 권고사직에서 살아남으며 커머스를 맡게 되었습니다.
해당 커머스 프로젝트의 초기 개발 당시 인증 프로세스와 회원 로직을 제가 담당했지만, 이후에는 일손이 부족할 때 간헐적으로 태스크 몇 개를 돕는 정도였습니다. 거의 2년 만에 다시 프로젝트를 깊게 열어보게 되었는데, 막상 들어가 보니 상황은 생각보다 훨씬 거칠었습니다.
코드는 곳곳에 흩어져 있었고, 가독성은 낮았으며 작은 변경에도 사이드 이펙트가 빈번하게 발생했습니다. 프로젝트 전체를 한 번에 정리하기엔 막막했기 때문에, 접근 방식을 바꾸기로 했습니다.
전부를 한 번에 고치기보다, 쪼개서 안전하게 개선하자. 그리고 그 “안전장치”로 테스트를 붙이기로 했습니다.
이 글에서 제가 남기고 싶은 건 “테스트를 어떻게 설정했는지”보다, 어떤 불안이 있었고 어떤 선택으로 그 불안을 줄였는지입니다. 제 기준은 단순했습니다.
- (1) 돈이 오가는 흐름
- (2) 상태 전환이 복잡한 곳
- (3) 네트워크 파이프라인(인터셉터)
이 3곳부터 안전장치를 만든다는 것이었습니다.
불안을 가장 크게 줄이는 곳부터
운영 중인 이커머스 서비스에서 돈이 오가는 로직에 대한 불안감이 항상 있었습니다.
- 장바구니 가격 계산이 맞는지
- 할인 + 배송비 조합이 정확한지
- 토큰 만료 시 주문서 생성이 제대로 차단되는지
이런 것들을 매번 수동으로 확인하고 있었고, 배포할 때마다 “혹시 결제 로직이 깨진 거 아닌가?” 하는 불안이 반복됐습니다. 그래서 ‘커버리지’를 목표로 하기보다, 투입 대비 효과 기준으로 테스트 레이어를 쌓기로 했습니다.
기술 스택
| 구분 | 기술 | | ---------------- | ----------------------------- | | 프레임워크 | Next.js 15 (Pages Router) | | 상태 관리 | Zustand 4 | | HTTP 클라이언트 | Axios (커스텀 인터셉터) | | 테스트 러너 | Vitest | | 네트워크 Mocking | MSW (Mock Service Worker) | | 환경 | jsdom |
Jest 대신 Vitest를 선택한 이유
- Vite 기반이라 설정이 간단하고 실행 속도가 빠름
vi.mock,vi.mocked등 API가 Jest와 거의 동일해서 학습 비용이 낮음- ESM 지원이 네이티브라
import/export에서 삽질할 일이 없음 - 127개 테스트가 2.3초에 실행됨
중요한 건 기능보다도, 도입 자체가 오래 걸려 포기하지 않는 것이었습니다. 그래서 러너는 “가볍고 빨리 붙는” 쪽을 택했습니다.
테스트 전략: 어디에 테스트를 넣을 것인가
모든 컴포넌트에 테스트를 넣는 건 비현실적입니다. 투입 대비 효과가 높은 곳부터 시작했습니다.
┌─────────────────────────────────────────┐
│ MSW 통합 테스트 (14개) │ ← 네트워크 레이어까지 검증
├─────────────────────────────────────────┤
│ 스토어 + 주문 플로우 테스트 (47개) │ ← 비즈니스 로직 검증
├─────────────────────────────────────────┤
│ 유틸리티 단위 테스트 (60개) │ ← 순수 함수 검증
├─────────────────────────────────────────┤
│ API 레이어 테스트 (6개) │ ← 인터셉터 헤더 검증
└─────────────────────────────────────────┘
우선순위 기준
- 유틸리티 함수 — 순수 함수라 의존성 없이 바로 테스트 가능.
currency,dateParse,decodeBase64Url등 - Zustand 스토어 — 장바구니 수량 계산, 인증 상태 전환 등 핵심 비즈니스 로직
- 주문 플로우 — 장바구니 → 가격 계산 → 주문서 생성까지의 전체 흐름
- API 레이어 — axios 인터셉터가 헤더를 올바르게 조합하는지
1단계: Vitest 설정
// vitest.config.ts import path from 'path'; import { defineConfig } from 'vitest/config'; export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, './src') } }, test: { globals: true, environment: 'jsdom', setupFiles: ['./vitest.setup.ts'] } });
Next.js의 @/ 경로 별칭을 그대로 사용하기 위해 resolve.alias를 설정했습니다.
2단계: 순수 함수 단위 테스트
가장 쉬운 것부터 시작했습니다. utils/common.ts에 있는 유틸리티 함수들은 외부 의존성이 없어서 mock 없이 테스트할 수 있습니다.
describe('currency', () => { it('숫자에 천 단위 콤마를 추가한다', () => { expect(currency(1000)).toBe('1,000'); expect(currency(1234567)).toBe('1,234,567'); }); it('0은 "0"을 반환한다', () => { expect(currency(0)).toBe('0'); }); it('음수도 처리한다', () => { expect(currency(-5000)).toBe('-5,000'); }); });
JWT 토큰 만료 검증 같은 시간 의존적 테스트는 vi.useFakeTimers()로 시간을 고정했습니다:
describe('isTokenExpired', () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2025-01-15T12:00:00Z')); }); afterEach(() => { vi.useRealTimers(); }); it('만료된 토큰은 true를 반환한다', () => { const expiredToken = createFakeJwt({ exp: 1736935200 }); // 12:00:00 - 이미 만료 expect(isTokenExpired(expiredToken)).toBe(true); }); it('유효한 토큰은 false를 반환한다', () => { const validToken = createFakeJwt({ exp: 1736942400 }); // 14:00:00 - 2시간 남음 expect(isTokenExpired(validToken)).toBe(false); }); });
배운 점: JWT의 payload는 Base64URL 인코딩입니다. 테스트용 가짜 토큰을 만들 때
btoa가 아니라Buffer.from().toString('base64')후+→-,/→_치환이 필요합니다.
3단계: Zustand 스토어 테스트
Zustand 스토어 테스트의 핵심은 API 의존성을 mock하고, 스토어 액션의 결과를 검증하는 것입니다.
장바구니 스토어
// API를 vi.mock으로 대체 vi.mock('@/api/order', () => ({ getCartCount: vi.fn(), getCartSubset: vi.fn(), deleteCart: vi.fn() })); describe('장바구니 가격 계산', () => { it('상품을 선택하면 가격이 계산된다', async () => { vi.mocked(getCartSubset).mockResolvedValue({ data: { price: { buyAmt: 189000, discountAmt: 18900, totalAmt: 173100, totalDeliveryAmt: 3000 } } } as any); await useCartStore.getState().actions.getPrice('101'); expect(useCartStore.getState().price?.totalAmt).toBe(173100); }); it('할인 금액이 정상적으로 반영된다', async () => { // ...mock 설정 생략 const price = useCartStore.getState().price!; // 총 결제금액 = 구매금액 - 할인 + 배송비 expect(price.totalAmt).toBe(price.buyAmt - price.discountAmt + price.totalDeliveryAmt); }); });
인증 스토어
인증 스토어에서 까다로웠던 부분은 axios.isAxiosError를 mock하는 것이었습니다.
// ESM 환경에서는 named export를 직접 spy할 수 없음 // vi.spyOn(axios, 'isAxiosError') → ❌ 에러 발생 // 해결: vi.mock으로 모듈 전체를 래핑 vi.mock('axios', async (importOriginal) => { const actual = await importOriginal<typeof import('axios')>(); return { ...actual, isAxiosError: vi.fn((error: any) => error?.isAxiosError === true) }; });
it('400 PROVIDER_SERVER_ERROR 시 본인인증 필요 상태로 변경', async () => { const error = { isAxiosError: true, response: { status: 400, data: { code: 'PROVIDER_SERVER_ERROR' } } }; vi.mocked(getOauthOpenId).mockRejectedValue(error); await useAuthStore.getState().loginToken({ memberAuthToken: { accessToken: 'test', refreshToken: 'test' } } as any); expect(useAuthStore.getState().needCertification).toBe(true); });
배운 점: Zustand 스토어가
subscribe에서 자동으로 API를 호출하는 경우가 있습니다. 주문 스토어의subscribe가 상태 변경 시 자동으로postOrderSheetsOrderSheetNoCalculate를 호출해서, 이 함수도 mock하지 않으면unhandled rejection이 발생합니다.
4단계: MSW로 네트워크 레이어까지 테스트하기
vi.mock vs MSW — 무엇이 다른가
여기가 이 글의 핵심입니다.
vi.mock 방식:
코드 → getCartCount() → [가짜 함수] → mock 데이터 반환
❌ axios 인터셉터 실행 안 됨
❌ 헤더 조합 검증 불가
MSW 방식:
코드 → getCartCount() → axios 인터셉터(헤더 추가) → HTTP 요청 생성
→ ⚡ MSW가 네트워크에서 가로챔 → mock 응답 반환
✅ 인터셉터가 실제로 동작
✅ 요청 헤더 캡처 가능
vi.mock은 함수를 통째로 교체하기 때문에 axios가 아예 실행되지 않습니다. 반면 MSW는 함수와 axios는 진짜로 실행되고, 네트워크 출구에서만 가로챕니다. 실제 서버에 요청이 나가지는 않지만, 코드 레벨에서는 실제와 동일하게 동작합니다.
제가 MSW까지 간 이유는 하나였습니다. “주문 플로우가 정상”이라는 확신을 ‘함수 호출 성공’이 아니라 ‘요청이 올바르게 만들어졌다’까지로 끌어올리고 싶었다는 점입니다.
MSW 핸들러 설정
// src/__tests__/mocks/handlers.ts import { http, HttpResponse } from 'msw'; const SHOPBY_API = 'https://api.shopby.test'; export const shopbyHandlers = [ // 장바구니 부분 조회 — 상품 수에 따라 배송비 동적 계산 http.get(`${SHOPBY_API}/cart/subset`, ({ request }) => { const url = new URL(request.url); const cartNo = url.searchParams.get('cartNo'); const itemCount = cartNo!.split(',').length; return HttpResponse.json({ price: { buyAmt: 189000 * itemCount, totalDeliveryAmt: itemCount >= 2 ? 0 : 3000 // 2개 이상 무료배송 } }); }), // 주문서 생성 — 토큰 없으면 401 http.post(`${SHOPBY_API}/order-sheets`, async ({ request }) => { const accessToken = request.headers.get('accesstoken'); if (!accessToken) { return HttpResponse.json({ code: 'A0001', message: '로그인이 필요합니다.' }, { status: 401 }); } return HttpResponse.json({ orderSheetNo: 'OS-2024-MSW-001' }); }) ];
핸들러에 비즈니스 로직을 반영할 수 있는 게 MSW의 강점입니다. 상품 수에 따라 배송비를 다르게 반환하거나, 토큰 유무에 따라 401을 내려주는 것이 가능합니다.
에러 핸들러 런타임 교체
MSW의 server.use()로 테스트 중에 핸들러를 교체할 수 있습니다:
export const errorHandlers = { serverError: http.post(`${SHOPBY_API}/order-sheets`, () => { return HttpResponse.json({ code: 'S0001', message: '서버 오류가 발생했습니다.' }, { status: 500 }); }), badRequest: http.post(`${SHOPBY_API}/cart`, () => { return HttpResponse.json({ code: 'P0001', message: '품절된 상품입니다.' }, { status: 400 }); }) };
it('서버 에러(500) 시 주문서 생성이 실패한다', async () => { server.use(errorHandlers.serverError); // 이 테스트에서만 500 반환 try { await postOrderSheets({ cartNos: [101] } as any); expect.fail('에러가 발생해야 합니다'); } catch (error: any) { expect(error.response.status).toBe(500); expect(error.response.data.message).toBe('서버 오류가 발생했습니다.'); } });
MSW에서만 가능한 테스트: 인터셉터 헤더 검증
이 프로젝트의 axios 인터셉터는 요청마다 company, clientid, platform, accesstoken 헤더를 자동으로 추가합니다. vi.mock으로는 이 동작을 검증할 수
없지만, MSW에서는 실제 요청의 헤더를 캡처할 수 있습니다:
it('Shopby 요청에 올바른 헤더를 추가한다', async () => { let capturedHeaders: Record<string, string> = {}; const { http, HttpResponse } = await import('msw'); server.use( http.get('https://api.shopby.test/cart/count', ({ request }) => { capturedHeaders = { company: request.headers.get('company') || '', clientid: request.headers.get('clientid') || '', platform: request.headers.get('platform') || '', accesstoken: request.headers.get('accesstoken') || '' }; return HttpResponse.json({ count: 0 }); }) ); await getCartCount(); expect(capturedHeaders.company).toBe('Kasina/Request'); expect(capturedHeaders.clientid).toBe('test-client-id'); expect(capturedHeaders.platform).toBe('PC'); expect(capturedHeaders.accesstoken).toBe('test-shopby-token'); });
이 테스트가 통과한다는 것은 인터셉터 → 헤더 조합 → 네트워크 요청까지의 파이프라인이 정상이라는 뜻입니다.
주문 시나리오 통합 테스트 (API 레벨)
로그인부터 주문서 생성까지의 전체 흐름을 하나의 테스트로 검증합니다:
it('end-to-end 주문 플로우', async () => { // 1. 로그인 (카시나 토큰 → 샵바이 토큰) const loginRes = await getOauthOpenId({ openAccessToken: 'kasina-refresh' }); expect(loginRes.data.accessToken).toBeDefined(); localStorage.setItem('accessToken', loginRes.data.accessToken); // 2. 프로필 확인 const profileRes = await getProfile(); expect(profileRes.data.memberGradeName).toBe('VIP'); // 3. 장바구니에 상품 추가 const cartAddRes = await postCart([{ productNo: 1001, optionNo: 2001, orderCnt: 1 }] as any); expect(cartAddRes.data.count).toBe(1); // 4. 가격 계산 (2개 상품 → 무료배송) const priceRes = await getCartSubset({ cartNo: '101,102', divideInvalidProducts: true }); expect(priceRes.data.price.buyAmt).toBe(378000); expect(priceRes.data.price.totalDeliveryAmt).toBe(0); // 5. 배송지 조회 const addressRes = await getProfileShippingAddresses(); expect(addressRes.data[0].receiverName).toBe('홍길동'); // 6. 주문서 생성 const orderRes = await postOrderSheets({ cartNos: [101, 102] } as any); expect(orderRes.data.orderSheetNo).toBe('OS-2024-MSW-001'); });
테스트 결과
✓ src/__tests__/utils/common.test.ts (47 tests) 16ms
✓ src/__tests__/utils/token.test.ts (6 tests) 4ms
✓ src/__tests__/utils/date.test.ts (3 tests) 1ms
✓ src/__tests__/utils/device.test.ts (4 tests) 2ms
✓ src/__tests__/api/axios.test.ts (6 tests) 141ms
✓ src/__tests__/stores/auth.test.ts (19 tests) 9ms
✓ src/__tests__/stores/cart.test.ts (17 tests) 8ms
✓ src/__tests__/stores/order-flow.test.ts (11 tests) 7ms
✓ src/__tests__/integration/order-flow.msw.test.ts (14 tests) 1050ms
Test Files 9 passed (9)
Tests 127 passed (127)
Duration 2.29s
127개 테스트, 2.3초.
디렉토리 구조
src/__tests__/
├── utils/ # 1순위: 순수 함수
│ ├── common.test.ts # 47 tests — currency, dateParse, 등
│ ├── token.test.ts # 6 tests — JWT 만료 검증
│ ├── date.test.ts # 3 tests — 날짜 포맷
│ └── device.test.ts # 4 tests — 앱/웹 판별
├── api/ # 4순위: API 레이어
│ └── axios.test.ts # 6 tests — 인터셉터 헤더
├── stores/ # 2-3순위: 비즈니스 로직
│ ├── auth.test.ts # 19 tests — 로그인/로그아웃/위시리스트
│ ├── cart.test.ts # 17 tests — 장바구니 CRUD
│ └── order-flow.test.ts # 11 tests — 주문 플로우 (vi.mock)
├── integration/ # +α: MSW 네트워크 레벨
│ └── order-flow.msw.test.ts # 14 tests — 주문 플로우 (MSW)
└── mocks/ # MSW 설정
├── handlers.ts # API 핸들러 (Shopby + Kasina)
└── server.ts # MSW 서버
삽질 기록
1. Zustand subscribe의 사이드 이펙트
- 증상: 테스트 실행 중
unhandled rejection발생 - 원인: 주문 스토어의
subscribe가 상태 변경 시 자동으로 API(postOrderSheetsOrderSheetNoCalculate)를 호출 - 해결: 테스트에서 해당 호출까지 mock 처리
- 다음: subscribe 사이드 이펙트는 “트리거 레이어”로 분리하거나, 테스트에서 끌 수 있는 플래그를 두는 방향을 고려
주문 스토어에 subscribe가 있어서 상태가 변경될 때마다 자동으로 postOrderSheetsOrderSheetNoCalculate를 호출합니다. 이 함수를 mock하지 않으면 테스트
실행 시 unhandled rejection이 발생합니다.
// 반드시 mock 필요 vi.mock('@/api/order/temp', () => ({ postOrderSheetsOrderSheetNoCalculate: vi.fn().mockResolvedValue({ data: { paymentInfo: { paymentAmt: 0, productAmt: 0, deliveryAmt: 0 } } }) }));
2. ESM 환경에서 named export spy 불가
- 증상:
vi.spyOn(axios, 'isAxiosError')가 실패 - 원인: ESM named export는 configurable이 아니어서 spy가 불가능한 경우가 있음
- 해결: 모듈 전체를
vi.mock으로 래핑해 대체 - 다음: 외부 라이브러리는 가능한 얇은 래퍼를 두고, spy 포인트를 내부로 가져오는 방식도 후보
// ❌ Cannot spy on "isAxiosError" because it is not a function or is not configurable vi.spyOn(axios, 'isAxiosError'); // ✅ 모듈 전체를 mock으로 래핑 vi.mock('axios', async (importOriginal) => { const actual = await importOriginal<typeof import('axios')>(); return { ...actual, isAxiosError: vi.fn((e: any) => e?.isAxiosError === true) }; });
3. jsdom에서 window 존재 여부
- 증상: SSR 분기 테스트를 만들고 싶었지만 jsdom에서는 항상
window가 존재 - 원인: jsdom 환경 특성상 브라우저 객체가 기본 제공
- 해결: 테스트 가능한 범위를 인정하고 CSR 동작만 검증
- 다음: SSR 분기 로직은 “환경 판별”을 함수로 분리해 순수 함수처럼 테스트 가능하게 만드는 방향을 고려
isApp() 함수는 SSR에서는 header 파라미터로, CSR에서는 navigator.userAgent로 판단합니다. jsdom 환경에서는 window가 항상 존재하므로 SSR 분기를
테스트할 수 없습니다. 테스트 가능한 범위를 인식하고 CSR 동작만 검증하는 것이 현실적입니다.
4. JWT Base64 vs Base64URL
- 증상: 테스트용 토큰이 decode되지 않음
- 원인: JWT payload는 Base64가 아니라 Base64URL(
-,_, padding 제거`) - 해결:
+//치환 +=padding 제거 - 다음:
createFakeJwt()를 테스트 유틸로 고정해 재사용
// ❌ btoa → 표준 Base64 (+ / =) // jwt.decode가 인식 못함 // ✅ Base64URL (- _ padding 제거) Buffer.from(JSON.stringify(payload)).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
마무리
테스트를 도입하면서 느낀 점입니다.
- 전부 다 테스트할 필요는 없습니다 — ROI가 높은 곳(돈이 오가는 곳, 상태 전환이 복잡한 곳)에 집중했습니다.
- vi.mock과 MSW는 역할이 다릅니다 — vi.mock은 빠르고 간단하며, MSW는 인터셉터까지 검증할 수 있습니다.
- 테스트는 문서입니다 — 테스트 코드를 읽으면 “장바구니 2개 이상이면 무료배송” 같은 비즈니스 규칙이 보입니다.
- 삽질이 자산입니다 — Zustand subscribe, ESM spy, Base64URL 같은 이슈는 한 번 겪으면 다음에는 반복하지 않게 됩니다.
127개 테스트가 2.3초에 돌아가는 것을 보면, “테스트가 느려서 못 쓴다”는 핑계는 더 이상 하기 어렵습니다. 무엇보다 가장 큰 변화는 속도가 아니라, 배포할 때의 불안이 줄었다는 것입니다. 이제는 주문 플로우 변경이 들어오면 테스트가 먼저 위험 신호를 줍니다.