다크 모드
Chapter 14. 실전 Vitest와 Testing Library & MSW 통합 테스트
"1장의 jQuery 스파게티 코드에서 시작해 13장까지 진화해 온 하나의 React 19.3 게시판 코드베이스. 이제
@testing-library/react와 MSW v2로 화면 전체 시나리오를 자동으로 검증하는 테스트 방어막을 치고 프로덕션 배포를 완성하자."
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다. describe/it/expect/vi.fn()과 npx vitest run 사용법은 5장 5.7에서 처음 다뤘습니다.
| 개념 | 한 줄 요약 | 설명 위치 |
|---|---|---|
| 단위 / 격리 / 통합 테스트 피라미드 | 훅·스토어 단독 검증 / Context 주입 컴포넌트 검증 / 화면 전체 시나리오 검증 | 14.1, 14.7 |
@testing-library/react (render, screen, waitFor) | 사용자 관점(텍스트, 역할, 라벨)에서 컴포넌트를 렌더링하고 요소를 찾는 도구 | 14.2, 14.4 🆕 박스 |
@testing-library/user-event (userEvent.setup()) | 실제 브라우저의 클릭·타이핑·포커스 이벤트 시퀀스를 정밀하게 재현 | 14.4 🆕 박스 |
MSW v2 handlers.ts & setupServer | 게시판 REST API 4종(조회·등록·좋아요·삭제)을 네트워크 단에서 완벽 모킹 | 14.3 [코드 14-1] |
Context DI 격리 테스트 (CommentProvider) | 백엔드나 상위 앱 없이 가짜 Context만 주입해 깊은 자식 컴포넌트 단독 검증 | 14.5 [코드 14-3] |
| 일부러 깨뜨려 실패 메시지 읽기 (RED → GREEN) | 코드를 고의로 망가뜨려 테스트가 실제로 회귀를 잡아내는지 확인하는 훈련 | 14.6 |
TIP
🏁 이 장의 시작 파일frontend/src/BoardApp.tsx가 snapshots/BoardApp.ch12.tsx(또는 최종본)와 같은 상태에서 시작합니다. 이 장은 BoardApp.tsx를 고치지 않습니다. 이 장에서 손대는 파일: frontend/tests/mocks/handlers.ts, frontend/tests/integration/BoardApp.test.tsx, frontend/tests/unit/CommentItem.test.tsx
14.1 우리가 걸어온 단일 코드의 진화 여정
1장에서 마주했던 코드는 사내 인트라넷의 낡은 **legacy-board.html**이었습니다:
- Ch 01~01.5: XSS와 DOM 수동 조작의 한계를 체감하고
BoardApp.tsx로 선언적 전환, JSX·useState·useRef기본기 확립 - Ch 02~03: Fiber 가상 DOM과 불변성 원리(2.5장 미니 렌더러 직접 구현), 훅의 규칙과 Stale Closure 트랩 정복
- Ch 04~05: Fat Component에서
usePagination·usePostFilter·useFormValidation도려내기, URL 쿼리 동기화, 첫 자동 단위 테스트 - Ch 06~08: Context API로 계층형 댓글 DI 격리, Spring Boot DTO와 1:1 타입 계약, 만능 제네릭
BoardTable<T> - Ch 09~10: Zustand 1분 TTL 캐싱, React 19.3
useOptimistic+ 스냅샷 롤백(롤백 단위 테스트),persist초안 보존 - Ch 11~12:
<ProtectedRoute>인가 가드와 Open Redirect 방어,postRepository·Single-Flight 토큰 갱신(인터셉터 테스트)·<ErrorBoundary> - Ch 13:
@tanstack/react-virtual10,000개 댓글 가상 스크롤,<Activity mode="hidden">, 메모리 누수 클린업
이제 남은 마지막 퍼즐은 **화면 전체를 실제 사용자처럼 조작하며 검증하는 통합 테스트(Integration Test)**입니다.
14.2 React 19.3 테스트 환경 구성 해부
실습 프로젝트에는 이미 아래 테스트 패키지들이 설치 및 설정되어 있습니다.
1) 핵심 패키지 조합
bash
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom msw| 패키지 | 역할 |
|---|---|
vitest | Vite 설정(별칭 @/, TS·JSX 변환)을 그대로 재사용하는 초고속 테스트 러너 |
@testing-library/react | React 19 컴포넌트를 가상 DOM에 마운트(render)하고 화면 요소(screen)를 조회 |
@testing-library/user-event | fireEvent보다 실제 사용자 브라우저 행동(포커스 → 키다운 → 인풋 → 키업)에 가까운 입력 시뮬레이터 |
msw (Mock Service Worker v2) | Node.js 네트워크 계층에서 axios/fetch 요청을 가로채 가짜 Spring Boot 응답을 반환 |
2) vite.config.ts의 테스트 설정
typescript
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }
},
test: {
environment: 'jsdom',
globals: true
}
});14.3 MSW v2로 Spring Boot 백엔드 가상화 (handlers.ts)
10장과 12장에서는 테스트 안에서 필요한 API 1개만 즉석으로 모킹했습니다. 화면 전체를 띄우는 통합 테스트는 목록 조회·등록·좋아요·삭제 4가지 API가 유기적으로 맞물려야 하므로, tests/mocks/handlers.ts에 게시판 전용 가짜 백엔드 핸들러 묶음을 구축합니다.
🎬 비유로 이해하는 MSW: 영화 촬영장의 특수효과 스턴트맨
프론트엔드 테스트를 돌릴 때마다 진짜 Spring Boot 서버와 DB를 띄우는 것은, 액션 영화 한 장면 찍자고 진짜 빌딩을 폭파하고 헬기를 추락시키는 것과 같습니다. 백엔드 서버가 점검 중이거나 DB 데이터가 바뀌면 내 컴포넌트는 멀쩡한데도 테스트가 실패합니다.
MSW는 영화 촬영장의 완벽한 특수효과 스턴트맨입니다.httpClient(axios)가 밖으로 쏘는 HTTP 요청을 네트워크 경계에서 가로채서, 진짜 백엔드인 척 똑같이 분장하고 정해진 JSON 응답을 돌려줍니다. 컴포넌트와 Zustand 스토어는 자기가 진짜 Spring Boot 서버와 통신하는 줄 알고 신나게 화면을 그립니다! 비행기 안 오프라인 상태에서도 0.5초 만에 전체 통합 테스트가 통과하는 비결입니다.
[코드 14-1] frontend/tests/mocks/handlers.ts
typescript
// frontend/tests/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
import type { Post, CreatePostRequest } from '@/types/board';
const initialPosts: Post[] = [
{
id: 1,
title: '사내 기술 아키텍처 가이드',
author: '최시니어',
content: 'React 19.3과 Spring Boot 3 표준 아키텍처 문서입니다.',
category: 'DEV',
createdAt: '2026-10-27',
viewCount: 15,
likeCount: 5
},
{
id: 2,
title: 'React 19 점진적 리팩터링 후기',
author: '김개발',
content: 'jQuery 게시판을 전환하며 얻은 교훈을 공유합니다.',
category: 'DEV',
createdAt: '2026-10-27',
viewCount: 8,
likeCount: 2
}
];
// 테스트 중 등록/삭제/좋아요로 변형될 인메모리 DB 배열
let mockPosts: Post[] = initialPosts.map((p) => ({ ...p }));
export function resetMockPosts() {
mockPosts = initialPosts.map((p) => ({ ...p }));
}
export const handlers = [
// 1. GET /api/posts 목록 조회 가로채기
http.get('/api/posts', () => {
return HttpResponse.json({
items: mockPosts,
total: mockPosts.length,
page: 1,
size: 10,
totalPages: 1
});
}),
// 2. POST /api/posts 새 글 등록 가로채기
http.post('/api/posts', async ({ request }) => {
const body = (await request.json()) as CreatePostRequest;
const newPost: Post = {
id: mockPosts.length + 1,
title: body.title,
author: body.author,
content: body.content,
category: body.category || 'DEV',
createdAt: '2026-10-28',
viewCount: 0,
likeCount: 0
};
mockPosts = [newPost, ...mockPosts];
return HttpResponse.json(newPost, { status: 201 });
}),
// 3. POST /api/posts/:id/like 좋아요 증가 가로채기
http.post('/api/posts/:id/like', ({ params }) => {
const id = Number(params.id);
const target = mockPosts.find((p) => p.id === id);
if (!target) {
return new HttpResponse(null, { status: 404 });
}
target.likeCount += 1;
return HttpResponse.json(target);
}),
// 4. DELETE /api/posts/:id 게시글 삭제 가로채기
http.delete('/api/posts/:id', ({ params }) => {
const id = Number(params.id);
mockPosts = mockPosts.filter((p) => p.id !== id);
return new HttpResponse(null, { status: 204 });
})
];14.4 [실습 1] BoardApp.test.tsx 통합 테스트 시나리오 3종 구현
이제 MemoryRouter와 함께 BoardApp을 통째로 렌더링하고, 다음 3가지 핵심 시나리오를 자동 검증합니다:
- 시나리오 1 (초기 조회 렌더링): 마운트 시 MSW 서버에서 게시글 2건을 받아와 제네릭 테이블에 그리는가?
- 시나리오 2 (새 글 등록 플로우): 사용자가
[✏️ 새 질문 등록]버튼을 눌러 모달을 열고, 제목·작성자·본문을 타이핑해 제출하면 목록 최상단에 새 글이 즉시 추가되는가? - 시나리오 3 (좋아요 500 장애 시 낙관적 롤백): 좋아요 클릭 후 서버에서
500 Internal Server Error가 떨어질 때 안내창이 뜨고 숫자가 원래 값(5)으로 정확히 복구되는가?
🆕 처음 보는 문법: Testing Library의 getBy... vs findBy... vs queryBy...
screen.getByText(...)/screen.getByRole(...): 호출하는 그 즉시 동기적으로 DOM에 요소가 있어야 합니다. 없으면 바로 에러를 던집니다.await screen.findByText(...): 비동기 API 응답 후에 나타나는 요소를 찾을 때 씁니다! 내부적으로waitFor가 포함되어 있어 요소가 나타날 때까지(기본 최대 1000ms) 기다려 줍니다.screen.queryByText(...): 요소가 없어도 에러를 던지지 않고null을 반환합니다. 모달이 닫혀서 **"화면에 없어야 함(expect(...).toBeNull())"**을 검증할 때 씁니다.
[코드 14-2] 화면 통합 테스트 3종 풀코드 (frontend/tests/integration/BoardApp.test.tsx)
tsx
// frontend/tests/integration/BoardApp.test.tsx
import React from 'react';
import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { handlers, resetMockPosts } from '../mocks/handlers';
import { usePostStore } from '@/stores/usePostStore';
import { useDraftStore } from '@/stores/useDraftStore';
import BoardApp from '@/BoardApp';
// 1. MSW 가짜 서버 가동
const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
beforeEach(() => {
// 각 테스트가 서로 간섭하지 않도록 인메모리 DB와 Zustand 스토어 초기화!
resetMockPosts();
usePostStore.setState({
posts: [],
currentPost: null,
totalCount: 0,
isLoading: false,
lastFetchedTime: null,
lastQueryKey: ''
});
useDraftStore.getState().clearDraft();
vi.spyOn(window, 'alert').mockImplementation(() => {});
});
afterEach(() => {
server.resetHandlers();
vi.restoreAllMocks();
});
afterAll(() => server.close());
describe('BoardApp & Zustand 통합 시나리오 테스트 (3종)', () => {
it('시나리오 1: 마운트 시 MSW 목 서버에서 게시글 2건을 가져와 테이블에 렌더링해야 한다', async () => {
render(
<MemoryRouter>
<BoardApp />
</MemoryRouter>
);
// 비동기 API 응답 후 첫 번째 글과 두 번째 글이 화면에 나타나는지 대기 및 검증
expect(await screen.findByText('사내 기술 아키텍처 가이드')).toBeDefined();
expect(screen.getByText('React 19 점진적 리팩터링 후기')).toBeDefined();
const rows = screen.getAllByRole('row');
// 헤더 행(1개) + 데이터 행(2개) = 총 3개 행
expect(rows.length).toBe(3);
});
it('시나리오 2: 새 글 작성 모달에 유효한 값을 입력하고 제출하면 목록 최상단에 새 글이 반영되어야 한다', async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<BoardApp />
</MemoryRouter>
);
// 초기 목록 로드 완료 대기
await screen.findByText('사내 기술 아키텍처 가이드');
// 1. [✏️ 새 질문 등록] 버튼 클릭하여 모달 열기
const openModalBtn = screen.getByRole('button', { name: /새 질문 등록/i });
await user.click(openModalBtn);
// 2. 모달 내부 입력창에 사용자 타이핑 시뮬레이션
const titleInput = screen.getByPlaceholderText(/제목을 입력하세요/i);
const authorInput = screen.getByPlaceholderText(/작성자/i);
const contentInput = screen.getByPlaceholderText(/질문 내용을 상세히/i);
await user.type(titleInput, 'Vitest 통합 테스트 완료 글');
await user.type(authorInput, '이아키텍트');
await user.type(contentInput, '본문 최소 10자 이상 유효성 검사를 통과하는 내용입니다.');
// 3. [게시글 등록] 제출 버튼 클릭
const submitBtn = screen.getByRole('button', { name: /게시글 등록/i });
await user.click(submitBtn);
// 4. 목록 첫 번째 데이터 행(rows[1])에 새 글 제목이 나타나는지 검증!
await waitFor(() => {
const rows = screen.getAllByRole('row');
expect(within(rows[1]).getByText('Vitest 통합 테스트 완료 글')).toBeDefined();
expect(within(rows[1]).getByText('이아키텍트')).toBeDefined();
});
});
it('시나리오 3: 좋아요 클릭 후 서버 500 장애 발생 시 이전 좋아요 수(5)로 즉시 롤백되어야 한다', async () => {
const user = userEvent.setup();
// 이 테스트에서만 좋아요 API가 500 에러를 반환하도록 덮어쓰기
server.use(
http.post('/api/posts/:id/like', () => {
return new HttpResponse(null, { status: 500 });
})
);
render(
<MemoryRouter>
<BoardApp />
</MemoryRouter>
);
await screen.findByText('사내 기술 아키텍처 가이드');
// 첫 번째 게시글의 좋아요 버튼('❤️ 5') 찾기 및 클릭
const likeBtn = screen.getByRole('button', { name: /❤️\s*5/i });
await user.click(likeBtn);
// 500 실패 후 롤백되어 다시 '❤️ 5'로 돌아오고 alert가 호출되었는지 검증
await waitFor(() => {
expect(window.alert).toHaveBeenCalled();
expect(screen.getByRole('button', { name: /❤️\s*5/i })).toBeDefined();
expect(usePostStore.getState().posts[0].likeCount).toBe(5);
});
});
});💡 [확인하기]: 터미널에서
npx vitest run BoardApp을 실행하여✓ tests/integration/BoardApp.test.tsx (3 tests)가 초고속으로 통과하는지 확인하십시오.
14.5 [실습 2] CommentItem 격리 테스트: 6장의 Context DI 활용
6장에서 재귀 댓글 컴포넌트(CommentItem.tsx)가 Props Drilling 대신 CommentProvider를 통해 서비스를 주입받도록 설계해 두었습니다. 그 덕분에 실제 BoardApp이나 네트워크 없이 가짜(Mock) CommentContext만 감싸서 깊은 대댓글의 렌더링과 삭제 호출을 단독으로 검증할 수 있습니다.
[코드 14-3] 재귀 댓글 Context 격리 테스트 (frontend/tests/unit/CommentItem.test.tsx)
tsx
// frontend/tests/unit/CommentItem.test.tsx
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CommentItem, type CommentNode } from '@/components/CommentItem';
import { CommentProvider } from '@/context/CommentContext';
const mockTree: CommentNode = {
id: 1,
author: '최시니어',
content: '루트 댓글입니다.',
createdAt: '2026-10-29 10:00',
children: [
{
id: 2,
author: '김개발',
content: '2단계 대댓글입니다.',
createdAt: '2026-10-29 10:05',
children: []
}
]
};
describe('CommentItem 재귀 렌더링 & Context DI 격리 테스트', () => {
it('자식 대댓글까지 재귀적으로 렌더링하고, 깊은 자식의 [삭제] 클릭 시 주입된 deleteComment가 해당 ID로 호출된다', async () => {
const user = userEvent.setup();
const mockDeleteComment = vi.fn().mockResolvedValue(undefined);
const mockAddReply = vi.fn().mockResolvedValue(undefined);
render(
<CommentProvider value={{ deleteComment: mockDeleteComment, addReply: mockAddReply }}>
<CommentItem comment={mockTree} />
</CommentProvider>
);
// 1. 루트 댓글과 자식 대댓글이 모두 화면에 그려졌는지 확인
expect(screen.getByText('루트 댓글입니다.')).toBeDefined();
expect(screen.getByText('2단계 대댓글입니다.')).toBeDefined();
// 2. 두 번째 [삭제] 버튼(= 대댓글 id: 2의 삭제 버튼) 클릭
const deleteButtons = screen.getAllByRole('button', { name: '삭제' });
await user.click(deleteButtons[1]);
// 3. 중간 컴포넌트 이벤트 릴레이 없이 Context의 deleteComment(2)가 직결 호출되었는지 검증!
expect(mockDeleteComment).toHaveBeenCalledWith(2);
});
});14.6 일부러 깨뜨려 실패 메시지 읽기 (RED → GREEN 훈련)
테스트가 초록불(GREEN)일 때만 보면 "이 테스트가 정말 우리 코드를 지켜 주고 있는지" 확신할 수 없습니다. 일부러 비즈니스 규칙을 깨뜨려 **RED(실패)**를 눈으로 확인하고 복구해 봅시다.
src/components/PostCreateModal.tsx에서 제목 최소 길이 검사를if (title.trim().length < 2)에서 **if (title.trim().length < 50)**으로 바꾸고 저장합니다.npx vitest run BoardApp을 실행합니다:text❯ tests/integration/BoardApp.test.tsx (3 tests | 1 failed) × BoardApp & Zustand 통합 시나리오 테스트 (3종) > 시나리오 2: 새 글 작성 모달에 유효한 값을 입력하고 제출하면 목록 최상단에 새 글이 반영되어야 한다 → Unable to find an element with the text: Vitest 통합 테스트 완료 글- 해석: 18자짜리 제목(
'Vitest 통합 테스트 완료 글')이 잘못 바뀐 50자 제한에 막혀 등록 API가 호출되지 않았고, 테이블 첫 번째 행에 새 글이 나타나지 않아 시나리오 2가 정확히 경보를 울렸습니다!
- 해석: 18자짜리 제목(
- 다시
< 2로 되돌리고npx vitest run BoardApp을 실행해3 passed를 확인합니다.
14.7 이 책에서 완성한 테스트 피라미드 총정리
우리는 테스트를 마지막 장에 몰아서 짜지 않고, 아키텍처 계층을 쌓을 때마다 그 계층에 맞는 테스트를 함께 작성해 왔습니다:
| 장 | 테스트 파일 | 테스트 계층 | 검증 대상 | 네트워크 모킹 방식 |
|---|---|---|---|---|
| 5장 | tests/unit/usePagination.test.ts | 단위 (Custom Hook) | 페이지 수 계산, 이전/다음 경계값 방어 | 없음 (순수 상태 로직) |
| 10장 | tests/unit/usePostStore.test.ts | 단위 (Zustand Store) | 좋아요 낙관적 +1, 서버 값 보정, 실패 시 스냅샷 롤백 | MSW server.use (단일 API) |
| 12장 | tests/unit/httpClient.test.ts | 단위 (HTTP 인프라) | 동시 401 시 Single-Flight 1회 갱신, 초안 보존 | MSW server.use (401 주입) |
| 14장 | tests/unit/CommentItem.test.tsx | 격리 (컴포넌트 + DI) | 재귀 트리 렌더링 및 깊은 자식의 Context 호출 | 없음 (CommentProvider 주입) |
| 14장 | tests/integration/BoardApp.test.tsx | 통합 (화면 + 스토어 + 라우터) | 목록 조회, 모달 글 등록, 500 에러 롤백 전체 플로우 | MSW handlers.ts 전체 세트 |
14.8 [직접 해보기] 실습 미션 💪
🎯 미션: 게시글 삭제(DELETE /api/posts/:id) 통합 테스트 시나리오 추가하기
BoardApp.test.tsx에 네 번째 시나리오를 추가해 보십시오.
사용자가 첫 번째 게시글('사내 기술 아키텍처 가이드') 행의 [삭제] 버튼을 누르고 확인창(window.confirm이 true를 반환하도록 모킹)을 수락하면, 테이블에서 해당 게시글이 사라지고 데이터 행이 1개로 줄어드는지 검증하십시오.
💡 미션 정답 코드 확인하기
tsx
// frontend/tests/integration/BoardApp.test.tsx 의 describe 블록 안에 추가
it('시나리오 4: 게시글의 [삭제] 버튼을 누르고 확인하면 목록에서 해당 글이 제거되어야 한다', async () => {
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(
<MemoryRouter>
<BoardApp />
</MemoryRouter>
);
await screen.findByText('사내 기술 아키텍처 가이드');
// 첫 번째 게시글의 [삭제] 버튼 클릭
const deleteButtons = screen.getAllByRole('button', { name: '삭제' });
await user.click(deleteButtons[0]);
// 삭제된 게시글 제목이 화면에서 사라졌는지(queryByText === null) 검증!
await waitFor(() => {
expect(screen.queryByText('사내 기술 아키텍처 가이드')).toBeNull();
expect(screen.getByText('React 19 점진적 리팩터링 후기')).toBeDefined();
});
});14.9 이번 장 핵심 요약 및 에필로그 🚀
핵심 3줄
- MSW
handlers.ts로 백엔드 API를 네트워크 단에서 가상화하면, Spring Boot 서버를 띄우지 않고도BoardApp전체 사용자 시나리오를 0.5초 만에 검증할 수 있습니다. - 테스트마다
resetMockPosts()와usePostStore.setState(...),server.resetHandlers()로 상태를 초기화하여 테스트 간 실행 순서 의존성을 완전히 제거합니다. - Context API(
CommentProvider)로 의존성을 주입받도록 설계한 컴포넌트는 상위 앱 없이도 가짜 함수를 주입해 손쉽게 격리 테스트할 수 있습니다.
바뀐 파일
| 파일 | 무엇이 바뀌었나 |
|---|---|
tests/mocks/handlers.ts | 게시판 API 4종(조회·등록·좋아요·삭제) MSW 핸들러 및 resetMockPosts |
tests/integration/BoardApp.test.tsx | 조회·모달 등록·500 롤백 통합 테스트 시나리오 3종 |
tests/unit/CommentItem.test.tsx | CommentProvider 기반 재귀 댓글 격리 테스트 |
자주 하는 실수
| 증상 | 원인 | 해결 |
|---|---|---|
| 첫 번째 테스트는 통과하는데 두 번째 테스트에서 이전 글이 섞여 나옴 | Zustand 전역 스토어가 메모리에 이전 테스트의 캐시를 그대로 들고 있음 | beforeEach에서 usePostStore.setState(...)와 resetMockPosts()로 초기화 |
Unable to find an element 에러로 비동기 목록 조회 테스트가 실패함 | 비동기 렌더링 전에 동기 함수인 screen.getByText를 바로 호출함 | 비동기 요소 대기는 await screen.findByText(...) 또는 await waitFor(...) 사용 |
[MSW] Error: intercepted a request without a matching request handler | onUnhandledRequest: 'error' 설정 상태에서 핸들러에 없는 URL을 호출함 | handlers.ts 또는 server.use에 해당 API 경로 핸들러 추가 |
셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.
afterEach(() => server.resetHandlers())를 빼먹으면 시나리오 3(좋아요 500 에러 주입) 뒤에 다른 테스트를 추가했을 때 어떤 일이 벌어지나요?- 통합 테스트의
beforeEach에서usePostStore.setState({ lastFetchedTime: null, ... })로 스토어를 초기화하지 않으면 왜 MSW 핸들러가 호출되지 않을 수 있나요? - 요소가 화면에서 사라졌음을 검증할 때
getByText대신queryByText를 써야 하는 이유는 무엇인가요?
셀프 체크 정답
- 시나리오 3에서
server.use(...)로 덮어쓴 "무조건 500 에러 반환" 핸들러가 다음 테스트까지 계속 살아남아, 정상 성공을 기대하는 후속 테스트가 엉뚱하게 실패합니다. - 9장에서 만든 1분 TTL 캐싱 때문에 앞선 테스트에서 기록된
lastFetchedTime과lastQueryKey가 스토어에 남아 있으면,fetchPosts()가 네트워크 요청을 건너뛰고 이전 테스트의 캐시를 그대로 반환하기 때문입니다. getByText는 요소를 찾지 못하면 즉시 예외(Error)를 던져 테스트를 터뜨리지만,queryByText는 요소가 없을 때null을 반환하므로expect(screen.queryByText('...')).toBeNull()로 부재를 깔끔하게 검증할 수 있습니다.
🎓 에필로그: 하나의 코드베이스로 완주한 아키텍처 여정
여러분은 1장의 레거시 jQuery 게시판 하나를 끝까지 붙잡았습니다.
새 문법이 나올 때마다 동떨어진 장난감 예제로 갈아타지 않고, 같은 게시판 코드를 14장에 걸쳐 리팩터링하며 각 패턴이 왜 필요한지, 어떤 문제를 해결하는지를 직접 손으로 증명했습니다.
이제 남은 일은 이 아키텍처를 여러분의 실제 사내 업무 화면에 적용하는 것입니다:
- 프로덕션 Docker/Nginx 배포는 Appendix A
- 사내 표준 UI 라이브러리(Ant Design / shadcn/ui Tailwind) 결합은 Appendix B
- 실습 및 실무 중 만나는 12가지 단골 에러 해결은 Appendix C
- 기존 jQuery 레거시 페이지 안에 오늘 당장 React 19 위젯을 심는 점진적 전환은 Appendix D가 안내합니다.