포스트

[Angular 마스터하기] Day 18 - 테스팅 기초, 안전한 코드 만들기

[Angular 마스터하기] Day 18 - 테스팅 기초, 안전한 코드 만들기

이제와서 시작하는 Angular 마스터하기 - Day 18 “테스트로 안정적인 코드를 작성하세요! ✅”

오늘 배울 내용

  • Angular 테스트의 기본 구조
  • standalone 컴포넌트 테스트
  • signals 상태 테스트
  • 서비스와 HTTP 테스트
  • 테스트를 어디까지 작성할지 판단하는 기준

1. Angular 테스트의 기본 흐름

Angular 테스트는 보통 TestBed로 테스트용 모듈 환경을 만들고, 컴포넌트나 서비스를 주입한 뒤 동작을 검증합니다. 최신 standalone 컴포넌트는 declarations가 아니라 imports에 넣어 테스트하는 점이 중요합니다.

테스트 파일은 보통 *.spec.ts 이름을 사용합니다.

1
2
src/app/counter/counter.component.ts
src/app/counter/counter.component.spec.ts

테스트에서 확인할 것은 “Angular가 잘 동작하는지”가 아니라 “내 코드의 사용자 관찰 결과가 맞는지”입니다. 내부 구현보다 버튼 클릭 후 화면이 바뀌는지, 서비스 호출 후 상태가 바뀌는지처럼 결과 중심으로 작성하면 리팩터링에 강해집니다.


2. standalone 컴포넌트 테스트

아래 컴포넌트는 signal로 카운트를 관리합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <p data-testid="count">현재 값: {{ count() }}</p>
    <button type="button" (click)="increment()">증가</button>
  `
})
export class CounterComponent {
  count = signal(0);

  increment() {
    this.count.update(value => value + 1);
  }
}

컴포넌트를 테스트할 때는 fixture.detectChanges()로 템플릿을 갱신하고, DOM에서 실제 표시 값을 확인합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';

describe('CounterComponent', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [CounterComponent]
    });
  });

  it('should create', () => {
    const fixture = TestBed.createComponent(CounterComponent);
    const component = fixture.componentInstance;
    expect(component).toBeTruthy();
  });

  it('should increment count', () => {
    const fixture = TestBed.createComponent(CounterComponent);
    const component = fixture.componentInstance;

    component.increment();
    fixture.detectChanges();

    expect(component.count()).toBe(1);
    expect(fixture.nativeElement.textContent).toContain('현재 값: 1');
  });
});

3. 사용자 이벤트 테스트

컴포넌트 메서드를 직접 부르는 테스트도 유용하지만, 버튼 클릭처럼 사용자가 실제로 하는 행동을 재현하면 더 믿을 만합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
it('should increment when user clicks button', () => {
  const fixture = TestBed.createComponent(CounterComponent);
  fixture.detectChanges();

  const button: HTMLButtonElement =
    fixture.nativeElement.querySelector('button');

  button.click();
  fixture.detectChanges();

  const countText = fixture.nativeElement.querySelector('[data-testid="count"]');
  expect(countText.textContent).toContain('현재 값: 1');
});

data-testid는 테스트용 선택자를 안정적으로 만들 때 유용합니다. 스타일 클래스나 HTML 구조는 디자인 변경 때문에 자주 바뀔 수 있으므로, 테스트가 UI 리팩터링에 과하게 흔들리지 않도록 선택자를 신중하게 고르세요.


4. 서비스 테스트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { TestBed } from '@angular/core/testing';
import { DataService } from './data.service';

describe('DataService', () => {
  let service: DataService;

  beforeEach(() => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(DataService);
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  it('should add data', () => {
    service.addData('test');
    expect(service.getData()).toContain('test');
  });
});

서비스 테스트는 컴포넌트보다 빠르고 좁은 범위를 검증할 수 있습니다. 계산, 필터링, 상태 변경처럼 화면과 직접 연결되지 않는 로직은 서비스 또는 순수 함수로 분리하면 테스트가 쉬워집니다.


5. HTTP 서비스 테스트

API를 호출하는 서비스는 실제 서버에 요청하지 않고 테스트용 HTTP 컨트롤러로 검증합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { UserService } from './user.service';

describe('UserService', () => {
  let service: UserService;
  let http: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        provideHttpClient(),
        provideHttpClientTesting()
      ]
    });

    service = TestBed.inject(UserService);
    http = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    http.verify();
  });

  it('should load users', () => {
    service.getUsers().subscribe(users => {
      expect(users.length).toBe(2);
      expect(users[0].name).toBe('Kim');
    });

    const request = http.expectOne('/api/users');
    expect(request.request.method).toBe('GET');
    request.flush([
      { id: 1, name: 'Kim' },
      { id: 2, name: 'Lee' }
    ]);
  });
});

6. 테스트 실행

1
2
3
4
5
# 테스트 실행
ng test

# 단일 실행
ng test --watch=false

CI에서는 watch 모드를 끄고 한 번만 실행하는 방식이 일반적입니다. 프로젝트가 Vitest나 Jest 기반으로 구성되어 있다면 명령어는 달라질 수 있지만, 컴포넌트와 서비스의 검증 원칙은 같습니다.


7. 실습: Todo 필터 테스트하기

Day 20의 Todo 앱을 떠올리며 아래 테스트를 작성해보세요.

  1. 완료되지 않은 Todo만 반환하는 activeTodos computed를 만듭니다.
  2. Todo 3개 중 1개를 완료 상태로 둡니다.
  3. activeTodos() 결과가 2개인지 검증합니다.
  4. 완료 상태를 바꾼 뒤 결과가 다시 계산되는지 확인합니다.

이 실습은 signals가 테스트에서 어떻게 즉시 값을 갱신하는지 이해하는 데 좋습니다.


📝 정리

테스트 종류

  • 유닛 테스트: 함수, 서비스, 작은 상태 로직 검증
  • 컴포넌트 테스트: 템플릿과 사용자 이벤트 검증
  • 통합 테스트: 여러 컴포넌트와 서비스의 연결 검증
  • E2E 테스트: 실제 브라우저에서 주요 사용자 흐름 검증

체크리스트

  • standalone 컴포넌트를 imports로 테스트할 수 있나요?
  • signal 값을 변경한 뒤 화면 갱신을 확인할 수 있나요?
  • 버튼 클릭 같은 사용자 이벤트를 테스트할 수 있나요?
  • 서비스 로직과 HTTP 요청을 분리해 검증할 수 있나요?
  • CI에서 한 번만 실행되는 테스트 명령을 구성할 수 있나요?

📚 다음 학습


“테스트는 안정적인 앱의 기반입니다!” ✅

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.