앵귤러 중급 강좌 #7 테스트 — TestBed와 컴포넌트 테스트
지난 시간에는 라우트 진입 전에 흐름을 가로채는 Guards와 Resolver를 살펴봤습니다. 이번 시간은 앵귤러 중급 강좌의 마지막 글, 테스트입니다. 앵귤러는 다른 프레임워크들과 달리 처음부터 테스트가 깊이 들어 있습니다. ng new로 프로젝트를 만들면 테스트 러너와 첫 spec 파일이 함께 떨어지고, 모든 CLI generator가 spec 파일을 같이 만들어 줍니다. “테스트는 나중에” 하는 문화 자체가 적은 셈입니다.
이번 글에서는 그 도구의 구성을 살펴본 뒤, Service 단위 테스트부터 컴포넌트 테스트, mock 주입, HttpClient 테스트, 그리고 ComponentHarness까지 한 번에 정리해 보겠습니다.
앵귤러 테스트 도구의 구성 #
앵귤러의 기본 셋업은 Jasmine + Karma입니다. Jasmine은 describe / it / expect 같은 테스트 작성 문법과 spy/mock 유틸리티를 제공하고, Karma는 그 spec들을 실제 브라우저에서 돌려주는 러너 역할을 합니다. ng test를 치면 Karma가 Chrome을 띄우고 Jasmine으로 작성한 테스트들을 그 안에서 실행합니다.
다만 최근 트렌드는 조금씩 바뀌고 있습니다. Angular 16부터 Jest와 Vitest 같은 노드 기반 러너로 옮기는 흐름이 분명히 있습니다. Karma 자체는 deprecate 된 상태고, 새 프로젝트에서 Jest/Vitest를 골라 쓰는 사례가 늘고 있습니다. 다만 테스트 작성 방법(TestBed, fixture, inject)은 그대로입니다. 러너가 무엇이든 이 글의 내용은 그대로 통합니다. 이 글은 기본 셋업인 Jasmine 문법으로 진행합니다.
Service 단위 테스트 — TestBed 없이 #
앵귤러 의존성 없이 동작하는 순수 클래스는 그냥 new로 만들고 검증해도 충분합니다. DI도 화면도 필요 없으면 가장 간단한 방식이 가장 좋습니다.
import { CartService } from './cart.service';
describe('CartService', () => {
it('아이템을 추가하면 합계가 누적된다', () => {
const cart = new CartService();
cart.add({ id: 1, price: 1000 });
cart.add({ id: 2, price: 2500 });
expect(cart.items().length).toBe(2);
expect(cart.total()).toBe(3500);
});
});TestBed를 끌어들이지 않아도 됩니다. signal도 그냥 함수 호출로 값을 꺼내 검증하면 끝입니다. DI나 다른 앵귤러 기능에 의존하지 않는 Service라면 이런 식의 단위 테스트가 가장 빠르고 가볍습니다(CartService 자체는 signal + computed로 합계를 계산하는 평범한 클래스라고 가정합니다).
TestBed 입문 #
다른 Service나 HttpClient를 주입받는 Service를 테스트하려면 이야기가 달라집니다. new로 만들면서 의존성을 직접 끼워 넣을 수도 있지만, 앵귤러의 DI 시스템을 그대로 쓰는 게 훨씬 자연스럽습니다. 그 통로가 바로 TestBed입니다.
TestBed는 테스트 전용 미니 앵귤러 앱을 띄우는 도구라고 생각하시면 됩니다. configureTestingModule로 imports(standalone 컴포넌트나 모듈)와 providers(Service mock 등)를 등록하고, TestBed.inject(...)로 인스턴스를 꺼내 씁니다.
import { TestBed } from '@angular/core/testing';
import { LoggerService } from './logger.service';
describe('LoggerService (TestBed)', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [LoggerService],
});
});
it('주입받은 인스턴스는 싱글톤이다', () => {
const a = TestBed.inject(LoggerService);
const b = TestBed.inject(LoggerService);
expect(a).toBe(b);
});
});providers에 { provide: SomeService, useValue: fakeService } 형태로 mock을 갈아끼우는 것이 TestBed의 가장 큰 무기입니다. 이 패턴은 잠시 뒤 컴포넌트 테스트에서 본격적으로 활용합니다.
providedIn: 'root'로 등록된 Service라면 providers에 명시하지 않아도 TestBed.inject로 꺼내 쓸 수 있습니다. 다만 mock으로 교체하고 싶을 때만 providers에 등록한다고 생각하시면 됩니다.컴포넌트 테스트 (1) 셋업 #
컴포넌트는 화면을 가진 존재라 단위 테스트만으로는 부족합니다. 실제로 한 번 렌더링한 뒤 그 결과를 검증해야 의미가 있습니다. TestBed가 그 환경을 제공합니다.
<p data-testid="value">현재 값: {{ count() }}</p>와 +1 버튼을 가진 간단한 CounterComponent(standalone, signal 기반)를 두고 시작하겠습니다. 테스트는 다음과 같은 흐름으로 진행됩니다.
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';
describe('CounterComponent', () => {
let fixture: ComponentFixture<CounterComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CounterComponent], // standalone은 imports에
});
fixture = TestBed.createComponent(CounterComponent);
fixture.detectChanges(); // 첫 렌더링
});
it('초기 값은 0이다', () => {
const el: HTMLElement = fixture.nativeElement;
expect(el.textContent).toContain('현재 값: 0');
});
});세 가지를 기억하시면 됩니다.
- Standalone 컴포넌트는
imports에 넣습니다. 모듈 시절에는declarations에 넣었지만, 새 코드에서는 더 이상 그 방식을 쓰지 않습니다. TestBed.createComponent(...)가ComponentFixture를 돌려줍니다. 이 fixture가 컴포넌트 인스턴스(fixture.componentInstance)와 DOM(fixture.nativeElement)으로 들어가는 손잡이입니다.fixture.detectChanges()를 호출해야 변경 감지가 한 번 돌면서 템플릿이 그려집니다. signal/@Input변경 후에도 한 번씩 호출해 줘야 화면이 갱신됩니다.
컴포넌트 테스트 (2) DOM 검증 #
이제 그려진 DOM을 검증할 차례입니다. 가장 단순한 방법은 fixture.nativeElement에서 querySelector로 원하는 요소를 찾아 텍스트,속성,클래스를 확인하는 겁니다.
it('+1 버튼을 누르면 값이 1이 된다', () => {
const el: HTMLElement = fixture.nativeElement;
const button = el.querySelector('button')!;
button.click();
fixture.detectChanges();
const value = el.querySelector('[data-testid="value"]')!;
expect(value.textContent).toContain('현재 값: 1');
});검증 시점에 어떤 셀렉터를 쓰느냐는 취향이지만, data-testid 같은 테스트 전용 속성을 두는 패턴을 추천드립니다. CSS 클래스나 텍스트로 찾으면 디자인이 바뀔 때마다 테스트가 깨지기 쉬운 반면, data-testid는 의미가 변하지 않는 한 안정적입니다.
컴포넌트 테스트 (3) 사용자 이벤트 #
위에서 button.click()을 호출한 것처럼, 사용자 이벤트는 그냥 DOM 메서드를 직접 부르거나 dispatchEvent로 흘려보내면 됩니다. 입력 이벤트는 value를 바꾼 뒤 input 이벤트를 직접 발생시키는 게 가장 안전합니다. <input>에 입력한 값으로 인사말을 바꾸는 GreetComponent(name signal 보유)가 있다고 가정해 봅시다.
it('input에 입력하면 인사말이 갱신된다', () => {
TestBed.configureTestingModule({ imports: [GreetComponent] });
const fixture = TestBed.createComponent(GreetComponent);
fixture.detectChanges();
const input: HTMLInputElement =
fixture.nativeElement.querySelector('[data-testid="name"]');
input.value = '커티스';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
const hello = fixture.nativeElement.querySelector('[data-testid="hello"]');
expect(hello.textContent).toContain('안녕하세요, 커티스!');
});value 할당만으로는 앵귤러가 변경을 감지하지 못합니다. dispatchEvent(new Event('input'))로 진짜 input 이벤트를 흘려야 바인딩된 핸들러가 호출되고, detectChanges()로 다시 한 번 그리면 인사말이 업데이트됩니다.
Service 의존성 mock #
실제 컴포넌트는 Service를 주입받아 동작하는 경우가 대부분입니다. 그 Service를 진짜로 쓰면 테스트가 무거워지고 외부 의존(HTTP, 시간 등)이 끼어듭니다. 해법은 기초 강좌 5편에서 잠깐 봤던 패턴 — mock으로 갈아끼우기입니다.
UserListComponent가 UserService를 주입받아 userService.users()를 렌더링하고 버튼에서 userService.refresh()를 호출한다고 가정해 봅시다. 테스트에서는 UserService의 진짜 구현을 가짜 객체로 바꿔치기합니다.
import { TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { UserListComponent } from './user-list.component';
import { UserService } from './user.service';
describe('UserListComponent', () => {
it('Service가 들고 있는 사용자를 렌더링한다', () => {
const fakeUserService = {
users: signal([{ id: 1, name: '커티스' }]).asReadonly(),
refresh: jasmine.createSpy('refresh'),
};
TestBed.configureTestingModule({
imports: [UserListComponent],
providers: [{ provide: UserService, useValue: fakeUserService }],
});
const fixture = TestBed.createComponent(UserListComponent);
fixture.detectChanges();
const items =
fixture.nativeElement.querySelectorAll('[data-testid="user"]');
expect(items.length).toBe(1);
expect(items[0].textContent).toContain('커티스');
});
it('새로고침 버튼은 Service.refresh를 호출한다', () => {
const refresh = jasmine.createSpy('refresh');
TestBed.configureTestingModule({
imports: [UserListComponent],
providers: [
{
provide: UserService,
useValue: { users: signal([]).asReadonly(), refresh },
},
],
});
const fixture = TestBed.createComponent(UserListComponent);
fixture.detectChanges();
fixture.nativeElement.querySelector('button').click();
expect(refresh).toHaveBeenCalledTimes(1);
});
});단순 데이터는 직접 만든 객체로 충분하고, 메서드 호출 검증이 필요할 때는 jasmine.createSpy 또는 메서드를 한 번에 묶어주는 jasmine.createSpyObj<UserService>('UserService', ['refresh', 'add'])로 spy를 만듭니다. spy는 호출 여부, 횟수, 인자까지 검증할 수 있어 강력합니다.
HttpClient 테스트 #
Service가 HttpClient를 사용한다면 어떻게 테스트합니까? 진짜 네트워크를 타게 두면 테스트가 느려지고 불안정합니다. 앵귤러는 이를 위해 **HttpTestingController**를 제공합니다 — 실제 네트워크는 타지 않으면서 요청을 가로채 가짜 응답을 흘려 줄 수 있습니다.
provideHttpClient 대신 테스트 모듈에서는 provideHttpClientTesting을 함께 등록합니다(Angular 18+ 기준).
import { TestBed } from '@angular/core/testing';
import {
provideHttpClient,
HttpClient,
} from '@angular/common/http';
import {
provideHttpClientTesting,
HttpTestingController,
} from '@angular/common/http/testing';
import { UserApiService } from './user-api.service';
describe('UserApiService', () => {
let service: UserApiService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
UserApiService,
provideHttpClient(),
provideHttpClientTesting(),
],
});
service = TestBed.inject(UserApiService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // 처리 안 된 요청이 남아 있으면 실패
});
it('GET /users 요청을 보내고 응답을 그대로 흘려준다', () => {
const fake = [{ id: 1, name: '커티스' }];
service.getUsers().subscribe((users) => {
expect(users).toEqual(fake);
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('GET');
req.flush(fake); // 가짜 응답 흘려보내기
});
});흐름은 이렇습니다.
- Service 메서드를 호출하고 결과를
subscribe합니다(아직 응답이 없어 콜백은 대기 중). httpMock.expectOne(url)로 그 요청이 실제로 나갔는지 검증합니다.req.flush(가짜 데이터)로 가짜 응답을 흘려보내면subscribe콜백이 그제야 실행되며 데이터를 검증합니다.afterEach에서httpMock.verify()로 처리되지 않은 요청이 없는지 확인합니다.
에러 응답도 비슷하게 시뮬레이션할 수 있습니다 — req.flush('boom', { status: 500, statusText: 'Server Error' })로 흘려보내면 기초 강좌 7편에서 본 catchError + of([]) 폴백 패턴이 실제로 동작하는지 검증할 수 있습니다.
ComponentHarness — Material을 쓴다면 #
복잡한 UI 컴포넌트(Material의 mat-select, mat-dialog처럼 내부 DOM이 큰 경우)를 querySelector로 다루기 시작하면 테스트 코드가 금방 지저분해집니다. 앵귤러는 이를 위해 Component Test Harness라는 추상 계층을 제공합니다.
const fixture = TestBed.createComponent(SaveFormComponent);
const loader = TestbedHarnessEnvironment.loader(fixture);
const saveButton = await loader.getHarness(
MatButtonHarness.with({ text: '저장' })
);
await saveButton.click();
expect(fixture.componentInstance.onSave).toHaveBeenCalled();Harness는 “이 컴포넌트의 사용자가 할 수 있는 일"만 노출하는 작은 API 계층입니다. 내부 DOM 구조가 바뀌어도 harness API만 그대로면 테스트가 안 깨집니다. Angular Material을 쓰는 프로젝트에서는 사실상 표준이고, 직접 만든 디자인 시스템 컴포넌트에도 같은 패턴을 적용할 수 있습니다.
querySelector로 빠르게 시작하시고, 같은 컴포넌트의 같은 동작을 테스트마다 반복해서 셀렉팅하고 있다면 그때 harness 도입을 고려하세요. 도구가 가치 있어지는 시점은 보통 “이거 세 번째 짜고 있네” 싶을 때입니다.마무리 — 중급 강좌를 마치며 #
이번 글에서는 앵귤러의 테스트 도구를 한 번에 훑었습니다.
- Jasmine + Karma가 기본, Jest/Vitest로 옮겨가는 흐름은 있지만 작성 방법은 동일
- 앵귤러 의존성이 없는 Service는 그냥
new로 만들어서 검증 - **
TestBed**로 DI 컨텍스트를 세우고 mock을providers로 갈아끼우기 - 컴포넌트 테스트:
createComponent→detectChanges→nativeElement검증, 이벤트는dispatchEvent - **
HttpTestingController**로 실제 네트워크 없이 HTTP 흐름 검증 - Material/CDK 환경에서는
ComponentHarness로 한 단계 더 견고한 테스트
여기까지가 앵귤러 중급 강좌의 마지막 글입니다. 1편에서 Reactive Forms로 시작해 RxJS 심화, Lifecycle와 Change Detection, HTTP Interceptor, Lazy Loading과 Route Guards, SSR과 하이드레이션, 그리고 오늘의 테스트까지 — 기초 강좌가 “도구의 사용법"이었다면, 중급은 “그 도구로 실제 제품을 만드는 패턴"의 영역이었습니다. 이제 여러분은 앵귤러로 작은 데모가 아니라 실무에서 굴러가는 앱을 한 사이클 굴려볼 수 있는 기본기를 갖추신 셈입니다.
다음 단계는 **“앵귤러 고급 강좌”**입니다. 중급에서 의도적으로 건드리지 않은, 한 단계 더 깊은 주제들을 차근차근 풀어볼 예정입니다.
- Change Detection 심화 — Zone.js의 정체, OnPush 전략의 동작 원리, Zoneless 앵귤러로 가는 흐름
- Signals 깊이 —
effect,computed의 미세한 동작, signal 기반 컴포넌트 입력(input(),model()), Resource API - Standalone API 깊이 —
provide*함수들의 설계, 환경 인젝터, DI 트리를 직접 다루기 - RxJS 깊이 — Higher-order operator의 차이(
switchMapvsmergeMapvsconcatMapvsexhaustMap), 커스텀 연산자, 멀티캐스트와 backpressure - NgRx와 상태 관리 — Store, Effects, Entity, Component Store, Signal Store까지 — 큰 앱에서 상태를 어떻게 다루는가
- SSR 심화와 하이드레이션 전략 — 부분 하이드레이션, deferrable views와의 결합, 성능 튜닝
- 성능과 번들 최적화 — 빌드 분석, deferrable views의 실전 활용, Core Web Vitals 다듬기
기초,중급을 차근차근 따라와 주신 여러분, 정말 수고 많으셨습니다. 어떤 강좌든 끝까지 가는 것 자체가 가장 어려운 일인데, 그 어려운 걸 해내신 겁니다. 고급 강좌에서 더 깊은 이야기로 다시 만나뵙겠습니다. 그때까지 직접 작은 앵귤러 앱 하나를 처음부터 끝까지 만들어 보시는 것을 강력히 권합니다 — 강좌 일곱 편을 다 읽는 것보다, 직접 만든 200줄짜리 앱 하나가 훨씬 큰 자산이 됩니다.