다크 모드
Chapter 14. 실전 TestBed와 Vitest & MSW 통합 테스트
"1장의 jQuery 스파게티 코드에서 시작해 13장까지 진화해 온 하나의 Angular 22 코드베이스. 이제
TestBed와provideHttpClient(), Vitest, MSW v2(setupServer)를 결합해 화면 전체의 3대 시나리오를 자동으로 검증하는 테스트 방어막을 완성하고 배포를 준비하자."
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.
| 개념 | 한 줄 요약 | 설명 위치 |
|---|---|---|
| 단위 / 격리 / 통합 테스트 | 순수 로직 검증 / 가짜 DI 주입으로 컴포넌트 단독 검증 / 화면+스토어+HTTP 전체 시나리오 검증 | 14.1, 14.5 |
TestBed.configureTestingModule | 테스트용 가상 Angular 애플리케이션 환경에 컴포넌트와 공급자(provideHttpClient)를 조립 | 14.3 🆕 박스 |
MSW v2 (setupServer, http, HttpResponse) | 네트워크 계층에서 실제 HTTP 요청을 가로채 정교한 가짜 백엔드 응답을 반환 | 14.2 [코드 14-1] |
| 통합 테스트 3대 시나리오 | ① 초기 목록 렌더링, ② 유효성 통과 후 새 글 작성 반영, ③ 좋아요 500 에러 시 스냅샷 롤백 | 14.3 [코드 14-2] |
| 계층적 DI 격리 테스트 | CommentService를 스파이(vi.fn())로 바꿔 끼워 재귀 컴포넌트만 단독 검증 | 14.4 [코드 14-3] |
TIP
🏁 이 장의 시작 파일
13장까지 완성한 전체 애플리케이션 위에서 테스트 스위트를 구축합니다.
이 장에서 작성·실행하는 파일:
frontend/tests/mocks/handlers.ts(MSW v2 게시판 REST API 핸들러 묶음)frontend/tests/integration/board-app.component.spec.ts(BoardAppComponent+PostStore+PostRepository3대 시나리오 통합 테스트)frontend/tests/unit/comment-item.component.spec.ts(6장 재귀 댓글 컴포넌트 DI 격리 테스트)
14.1 우리가 걸어온 단일 코드베이스의 진화 여정
1장에서 마주했던 코드는 사내 인트라넷의 낡은 **legacy-board.html**이었습니다:
- Ch 01~01.5:
innerHTML수동 조작과 XSS 취약점을 걷어내고 Angular 22 Standalone 컴포넌트와@if/@for제어 흐름으로 전환 - Ch 02~03: Zone.js 없는 미세 반응형 Signals 원리(2.5장 미니 엔진 직접 구현)와 신호 끊김 방어(
computed,untracked) - Ch 04~06: Fat Component에서 도메인 서비스를 분리하고, 계층적 인젝터(Hierarchical DI)로 대댓글 이벤트 릴레이 제거
- Ch 07~08: Spring Boot 3 DTO와 1:1 타입 계약(
PostResponse,PageResponse<T>,isPageResponse) 및 제네릭BoardTableComponent<T>제작 - Ch 09~10: 캡슐화된 전역
PostStore(1분 TTL 캐싱), '좋아요' 낙관적 업데이트와 스냅샷 롤백,pendingPostIds연타 방어 - Ch 11~12: 함수형
CanActivateFn가드,PostRepository&httpResource(), Single-Flight 401 갱신 인터셉터, 전역ErrorHandler - Ch 13:
@angular/cdk/scrolling가상 스크롤과DestroyRef로 10,000개 댓글 60fps 최적화
이제 마지막 퍼즐 조각은 **"누군가 내일 코드를 수정했을 때 기존 기능이 깨지지 않았음을 1초 안에 증명하는 자동화 통합 테스트"**입니다.
14.2 [실습 1] MSW v2로 Spring Boot 백엔드 완벽 가상화 (handlers.ts)
통합 테스트를 돌릴 때마다 무거운 Spring Boot 서버와 DB를 실제로 띄우면 테스트가 느려지고, DB 데이터가 바뀌면 테스트가 깨집니다. 그렇다고 컴포넌트 안에서 PostStore 자체를 가짜(vi.fn())로 덮어씌우면 스토어와 레포지토리의 버그는 테스트가 전혀 잡아내지 못합니다.
해답은 컴포넌트 → PostStore → PostRepository → HttpClient까지는 100% 실제 프로덕션 코드 그대로 실행하되, 가장 바깥쪽 네트워크 경계선에서 MSW(Mock Service Worker) v2가 요청을 가로채 응답하는 것입니다!
🎬 비유로 이해하는 MSW: 영화 촬영장의 특수효과 스턴트맨
액션 영화 한 장면 찍자고 진짜 빌딩을 폭파할 수는 없습니다. MSW는 진짜 백엔드 서버와 똑같이 분장한 특수효과 스턴트맨입니다.HttpClient가 밖으로 쏘는GET /api/posts요청을 네트워크 단에서 가로채서 7장의PageResponse<PostResponse>규격 그대로 응답을 던져줍니다. 컴포넌트와 스토어는 자기가 진짜 Spring Boot 서버와 통신하는 줄 알고 모든 비즈니스 로직을 실제로 수행합니다!
[코드 14-1] MSW v2 핸들러 묶음 (frontend/tests/mocks/handlers.ts)
typescript
// frontend/tests/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
import {
PostResponse,
CreatePostRequest,
PageResponse
} from '../../src/app/models/post.model';
const INITIAL_POSTS: PostResponse[] = [
{
id: 1,
title: '사내 Angular 22 표준 아키텍처 가이드',
author: '최시니어',
content: 'Signals와 Standalone 기반의 표준 게시판 설계 문서입니다.',
category: 'NOTICE',
createdAt: '2026-11-01',
viewCount: 42,
likeCount: 7
},
{
id: 2,
title: 'jQuery 레거시 점진적 전환 레슨런',
author: '김개발',
content: '14단계에 걸쳐 게시판을 리팩터링하며 얻은 노하우를 공유합니다.',
category: 'DEV',
createdAt: '2026-11-01',
viewCount: 19,
likeCount: 3
}
];
// 각 테스트마다 깨끗한 초기 상태를 보장하기 위한 인메모리 복사본
let mockPosts: PostResponse[] = INITIAL_POSTS.map((p) => ({ ...p }));
export function resetMockPosts(): void {
mockPosts = INITIAL_POSTS.map((p) => ({ ...p }));
}
export const handlers = [
// 1. GET /api/posts: 페이징 목록 조회 가로채기
http.get('/api/posts', () => {
const response: PageResponse<PostResponse> = {
content: mockPosts,
totalElements: mockPosts.length,
totalPages: 1,
pageNumber: 1,
pageSize: 10
};
return HttpResponse.json(response);
}),
// 2. POST /api/posts: 새 게시글 등록 가로채기
http.post('/api/posts', async ({ request }) => {
const body = (await request.json()) as CreatePostRequest;
const created: PostResponse = {
id: mockPosts.length + 1,
title: body.title,
author: body.author,
content: body.content,
category: body.category ?? 'DEV',
createdAt: '2026-11-02',
viewCount: 0,
likeCount: 0
};
mockPosts = [created, ...mockPosts];
return HttpResponse.json(created, { status: 201 });
}),
// 3. POST /api/posts/:id/like: 좋아요 증가 가로채기
http.post('/api/posts/:id/like', ({ params }) => {
const postId = Number(params['id']);
const target = mockPosts.find((p) => p.id === postId);
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 postId = Number(params['id']);
mockPosts = mockPosts.filter((p) => p.id !== postId);
return new HttpResponse(null, { status: 204 });
})
];14.3 [실습 2] TestBed + provideHttpClient() + MSW 3대 시나리오 통합 테스트
이제 BoardAppComponent를 TestBed로 마운트하고, 실제 PostStore, PostRepository, BoardTableComponent, PostCreateModalComponent가 함께 맞물려 돌아가는 3대 핵심 사용자 시나리오를 한 파일에서 모두 검증합니다:
- 시나리오 1 (목록 렌더링): 마운트 시 MSW에서 게시글 2건을 받아와 제네릭 테이블에 렌더링하고 인기 배지(
🔥 인기)를 표시하는가? - 시나리오 2 (새 글 작성): 글 작성 모달을 열어 새 글을 등록하면 목록 최상단에 즉시 새 글이 추가되고 총 건수가 3건으로 늘어나는가?
- 시나리오 3 (좋아요 롤백): 좋아요 버튼 클릭 시 화면 숫자가 즉시
+1(7 → 8) 올라갔다가, 서버가500 Internal Server Error를 반환하면 원래 숫자(7)로 정확히 롤백되는가?
[코드 14-2] 통합 테스트 풀코드 (frontend/tests/integration/board-app.component.spec.ts)
typescript
// frontend/tests/integration/board-app.component.spec.ts
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
vi
} from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { handlers, resetMockPosts } from '../mocks/handlers';
import { BoardAppComponent } from '../../src/app/board-app/board-app.component';
import { PostStore } from '../../src/app/stores/post.store';
import { PostRepository } from '../../src/app/repositories/post.repository';
// 1. MSW 노드 서버 기동
const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
beforeEach(() => {
resetMockPosts();
vi.spyOn(window, 'alert').mockImplementation(() => {});
});
afterEach(() => {
server.resetHandlers();
vi.restoreAllMocks();
});
afterAll(() => server.close());
describe('BoardAppComponent + PostStore + MSW 3대 시나리오 통합 테스트', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BoardAppComponent],
providers: [
provideHttpClient(), // 🌟 실제 HttpClient 공급! (MSW가 네트워크 단에서 가로챔)
PostStore,
PostRepository
]
}).compileComponents();
});
// =========================================================================
// 시나리오 1: 초기 마운트 및 제네릭 테이블 목록 렌더링 검증
// =========================================================================
it('시나리오 1: 마운트 시 MSW에서 게시글 2건을 불러와 제네릭 테이블과 인기 배지를 렌더링한다', async () => {
const fixture = TestBed.createComponent(BoardAppComponent);
fixture.detectChanges(); // ngOnInit() -> store.fetchPosts() 호출
await fixture.whenStable();
fixture.detectChanges(); // 비동기 응답 도착 후 Signals 뷰 갱신 반영
const el = fixture.nativeElement as HTMLElement;
const rows = el.querySelectorAll('tbody tr.table-row');
expect(rows.length).toBe(2);
expect(el.querySelector('.badge')?.textContent).toContain('총 2건');
expect(rows[0].textContent).toContain('사내 Angular 22 표준 아키텍처 가이드');
expect(rows[0].textContent).toContain('🔥 인기'); // likeCount가 7이므로 인기 배지 출력!
});
// =========================================================================
// 시나리오 2: 새 글 작성 모달 제출 및 목록 최상단 즉시 갱신 검증
// =========================================================================
it('시나리오 2: 유효한 새 글을 작성해 제출하면 POST 요청 후 목록 상단에 새 글이 추가된다', async () => {
const fixture = TestBed.createComponent(BoardAppComponent);
const component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
// 1. 글쓰기 모달 열기 및 새 글 제출 핸들러 실행
component.isCreateModalOpen.set(true);
fixture.detectChanges();
await component.handleCreatePost({
category: 'DEV',
title: 'Vitest + MSW 통합 테스트 완벽 통과 글',
author: '이아키텍트',
content: '본문 최소 10자 이상 유효성 검사를 통과하는 테스트 본문입니다.'
});
await fixture.whenStable();
fixture.detectChanges();
const el = fixture.nativeElement as HTMLElement;
const rows = el.querySelectorAll('tbody tr.table-row');
// 2. 기존 2건 -> 3건으로 증가하고 첫 번째 행에 새 글이 표시되는지 검증!
expect(rows.length).toBe(3);
expect(el.querySelector('.badge')?.textContent).toContain('총 3건');
expect(rows[0].textContent).toContain('Vitest + MSW 통합 테스트 완벽 통과 글');
expect(component.isCreateModalOpen()).toBe(false); // 모달 자동 닫힘 확인
});
// =========================================================================
// 시나리오 3: 좋아요 낙관적 업데이트(+1) 후 500 서버 장애 시 스냅샷 롤백 검증
// =========================================================================
it('시나리오 3: 좋아요 클릭 시 즉시 +1 되었다가 서버 500 에러 시 원래 숫자로 롤백된다', async () => {
const fixture = TestBed.createComponent(BoardAppComponent);
const store = TestBed.inject(PostStore);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
// 초기 1번 게시글의 좋아요 수는 7
expect(store.posts()[0].likeCount).toBe(7);
// 🌟 MSW로 강제 500 서버 장애 주입
server.use(
http.post('/api/posts/:id/like', () => {
return new HttpResponse(null, { status: 500 });
})
);
// 좋아요 클릭 트리거 (await 하기 전 동기 구간에서 즉시 7 -> 8 선제 반영 확인!)
const likePromise = fixture.componentInstance.handleLike(1);
fixture.detectChanges();
expect(store.posts()[0].likeCount).toBe(8);
// 비동기 500 응답 도착 대기
await likePromise;
fixture.detectChanges();
// 🌟 실패 즉시 원래 스냅샷 값인 7로 정확히 롤백되었는지 검증!
expect(store.posts()[0].likeCount).toBe(7);
expect(window.alert).toHaveBeenCalled();
});
});🆕 처음 보는 문법: TestBed.createComponent, fixture.detectChanges(), fixture.whenStable()
const fixture = TestBed.createComponent(BoardAppComponent): 가상 DOM 안에 컴포넌트를 생성하고, 컴포넌트 인스턴스(fixture.componentInstance)와 실제 DOM 엘리먼트(fixture.nativeElement)를 조작할 수 있는 테스트 픽스처(Fixture)를 반환합니다.fixture.detectChanges(): Angular에게 변경 감지를 실행해 현재 Signals 값을 DOM에 그리라고 명령합니다. 최초 호출 시ngOnInit()이 실행됩니다.await fixture.whenStable(): 진행 중인 비동기 작업(Promise, HTTP 응답 대기)이 완료될 때까지 기다립니다. 비동기 작업이 끝난 뒤fixture.detectChanges()를 한 번 더 호출하면 갱신된 신호 값이 HTML DOM에 최종 반영됩니다.
14.4 [실습 3] 6장 CommentItemComponent 계층적 DI 격리 테스트
통합 테스트가 숲 전체를 본다면, 격리 단위 테스트는 나무 하나만 떼어내어 정밀하게 검사합니다. 6장에서 우리는 대댓글의 [삭제] 버튼이 중간 부모 컴포넌트를 거치지 않고 inject(CommentService)를 직접 호출하도록 설계했습니다. 이제 TestBed의 providers에 **가짜 스파이 서비스({ provide: CommentService, useValue: mockService })**를 주입해, 깊은 대댓글에서 삭제를 눌렀을 때 정확히 해당 ID로 서비스가 호출되는지 검증합니다.
[코드 14-3] 재귀 댓글 컴포넌트 DI 격리 테스트 (frontend/tests/unit/comment-item.component.spec.ts)
typescript
// frontend/tests/unit/comment-item.component.spec.ts
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, vi } from 'vitest';
import { CommentItemComponent } from '../../src/app/components/comment-item.component';
import { CommentService, CommentNode } from '../../src/app/services/comment.service';
const sampleCommentTree: CommentNode = {
id: 10,
author: '최시니어',
content: '루트 댓글입니다.',
createdAt: '2026-11-01 10:00',
children: [
{
id: 25,
author: '김주니어',
content: '깊은 대댓글입니다.',
createdAt: '2026-11-01 10:05',
children: []
}
]
};
describe('CommentItemComponent 재귀 렌더링 & 계층적 DI 격리 테스트', () => {
it('자식 대댓글까지 재귀적으로 렌더링하고, 대댓글 삭제 클릭 시 CommentService.deleteComment(25)를 직접 호출한다', async () => {
const mockCommentService: Pick<CommentService, 'deleteComment'> = {
deleteComment: vi.fn().mockResolvedValue(undefined)
};
await TestBed.configureTestingModule({
imports: [CommentItemComponent],
providers: [
// 🌟 실제 HttpClient를 쓰는 서비스 대신 Vitest 스파이 객체를 주입!
{ provide: CommentService, useValue: mockCommentService }
]
}).compileComponents();
const fixture = TestBed.createComponent(CommentItemComponent);
// Signal input 설정: fixture.componentRef.setInput() 사용!
fixture.componentRef.setInput('comment', sampleCommentTree);
fixture.detectChanges();
const el = fixture.nativeElement as HTMLElement;
expect(el.textContent).toContain('루트 댓글입니다.');
expect(el.textContent).toContain('깊은 대댓글입니다.');
// 두 번째 삭제 버튼 = 자식 대댓글(id: 25)의 삭제 버튼 클릭
const deleteButtons = el.querySelectorAll('button');
deleteButtons[1].click();
// 중간 output() 릴레이 없이 주입된 서비스가 id: 25로 즉시 호출되었는지 검증!
expect(mockCommentService.deleteComment).toHaveBeenCalledTimes(1);
expect(mockCommentService.deleteComment).toHaveBeenCalledWith(25);
});
});🔬 심화(건너뛰어도 됨): input.required<T>() 신호는 테스트에서 어떻게 값을 넣나요? (setInput)
comment = input.required<CommentNode>()는 읽기 전용 InputSignal이므로 테스트에서 fixture.componentInstance.comment = ...처럼 직접 대입할 수 없습니다.
반드시 fixture.componentRef.setInput('comment', sampleCommentTree) API를 사용해야 Angular가 입력 신호 값을 갱신하고 OnPush 컴포넌트를 Dirty로 표시해 정상적으로 렌더링합니다!
14.5 [직접 해보기] 실습 미션 💪
🎯 미션: 게시글 삭제 시나리오(시나리오 4) 통합 테스트 추가하기
[코드 14-2] board-app.component.spec.ts에 네 번째 테스트를 추가하여, vi.spyOn(window, 'confirm').mockReturnValue(true)로 삭제 확인창을 통과시킨 뒤 await fixture.componentInstance.handleDeletePost(1)을 호출했을 때 테이블 행이 2개에서 1개로 줄어들고 1번 글이 사라지는지 검증해 보세요.
💡 미션 정답 코드 확인하기
typescript
it('시나리오 4: 게시글 삭제를 실행하면 MSW DELETE 호출 후 테이블 행이 1개로 줄어든다', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
const fixture = TestBed.createComponent(BoardAppComponent);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
await fixture.componentInstance.handleDeletePost(1);
await fixture.whenStable();
fixture.detectChanges();
const el = fixture.nativeElement as HTMLElement;
const rows = el.querySelectorAll('tbody tr.table-row');
expect(rows.length).toBe(1);
expect(rows[0].textContent).not.toContain('사내 Angular 22 표준 아키텍처 가이드');
expect(rows[0].textContent).toContain('jQuery 레거시 점진적 전환 레슨런');
});14.6 이번 장 핵심 요약 및 다음 단계 🚀
핵심 3줄
- MSW v2(
setupServer)를 활용하면 백엔드 서버 없이도 컴포넌트 → 스토어 → 레포지토리 →HttpClient전체 파이프라인을 실제 코드 그대로 통합 테스트할 수 있습니다. TestBed에provideHttpClient()를 등록해 목록 렌더링, 새 글 등록, 좋아요 500 롤백의 3대 핵심 시나리오를 자동화했습니다.- 하위 컴포넌트 격리 테스트에서는
{ provide: CommentService, useValue: mockService }와fixture.componentRef.setInput()으로 의존성을 깔끔하게 분리해 검증합니다.
바뀐 파일
| 파일 | 무엇이 바뀌었나 |
|---|---|
tests/mocks/handlers.ts | 게시판 API 4종(조회, 등록, 좋아요, 삭제) MSW 핸들러 및 초기화 함수 작성 |
tests/integration/board-app.component.spec.ts | 3대 핵심 시나리오 화면 통합 테스트 작성 |
tests/unit/comment-item.component.spec.ts | 계층적 DI 모킹 및 setInput 기반 재귀 컴포넌트 격리 테스트 작성 |
자주 하는 실수
| 증상 | 원인 | 해결 |
|---|---|---|
[MSW] Error: intercepted a request without a matching request handler | onUnhandledRequest: 'error' 옵션인데 핸들러에 없는 URL을 호출함 | handlers.ts의 경로와 요청 메서드(GET/POST)가 일치하는지 확인 |
비동기 응답이 왔는데 querySelectorAll('tbody tr')가 0개로 나옴 | await fixture.whenStable() 이후 fixture.detectChanges()를 호출하지 않음 | 비동기 완료 후 fixture.detectChanges()로 변경된 신호를 DOM에 반영 |
NG0950: Input "comment" is required but no value is available | 필수 input.required() 신호 값을 설정하기 전에 fixture.detectChanges()를 먼저 호출함 | 반드시 fixture.componentRef.setInput('comment', ...)를 먼저 호출한 뒤 detectChanges() 실행 |
셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.
PostStore를 가짜(vi.fn())로 모킹하지 않고provideHttpClient()와 MSW를 사용해 통합 테스트를 작성하면 어떤 이점이 있나요?afterEach(() => server.resetHandlers())와beforeEach(() => resetMockPosts())를 빠뜨리면 어떤 문제가 발생하나요?- 테스트에서 컴포넌트의
input.required<T>()신호에 값을 주입할 때 사용하는 메서드는 무엇인가요?
셀프 체크 정답
- 컴포넌트뿐만 아니라
PostStore의 캐싱·롤백 로직,PostRepository의 URL·파라미터 조립,post.model.ts의 런타임 타입 가드까지 실제 프로덕션 코드 전체가 함께 검증됩니다. - 앞선 테스트에서 추가된 게시글이나
server.use()로 주입한 500 에러 핸들러가 다음 테스트에 그대로 남아 테스트 실행 순서에 따라 결과가 달라지는 플레이키(Flaky) 테스트가 됩니다. fixture.componentRef.setInput('입력신호이름', 값)메서드를 사용합니다.
에필로그 및 부록 안내 🚀
축하합니다! 여러분은 1장의 낡은 legacy-board.html jQuery 스파게티 코드에서 출발하여, Angular 22와 TypeScript 6.0 기반의 엔터프라이즈 게시판 아키텍처를 끝까지 완성했습니다.
이제 현업 투입을 위한 마지막 날개를 달 차례입니다:
- Appendix A: 멀티스테이지 Docker(Node 22 LTS + Nginx) 프로덕션 배포
- Appendix B: Angular Material, NG-ZORRO(Ant Design), Tailwind CSS v4 브릿지
- Appendix C: 실무 환경 및 Angular 22 런타임 에러 14종 트러블슈팅 FAQ
- Appendix D:
@angular/elements웹 컴포넌트로 기존 JSP/jQuery 페이지와 공존하기