심화 가이드
테스트

컴포넌트 테스트 시나리오

이 문서에서는 자주 사용하는 컴포넌트 테스트 사례를 알아봅니다.

컴포넌트 바인딩

예제 애플리케이션의 Banner 컴포넌트는 HTML 템플릿에 정적인 제목 텍스트를 표시합니다.

코드를 조금 변경해서 컴포넌트의 title 프로퍼티를 바인딩하면 동적인 제목을 표시할 수 있습니다.

banner.ts

import {Component, signal} from '@angular/core';

@Component({
  selector: 'app-banner',
  template: '<h1>{{ title() }}</h1>',
  styles: ['h1 { color: green; font-size: 350%}'],
})
export class Banner {
  title = signal('Test Tour of Heroes');
}

간단한 기능이지만 컴포넌트가 원하는 위치에 올바른 내용을 표시하는지 확인하는 테스트를 추가해 봅시다.

<h1> 조회하기

title 프로퍼티의 문자열 바인딩을 감싸는 <h1> 엘리먼트의 값을 확인하는 테스트를 작성하겠습니다.

표준 HTML querySelector로 이 엘리먼트를 찾아 h1 변수에 할당하도록 beforeEach를 수정합니다.

banner.component.spec.ts

let component: Banner;
let fixture: ComponentFixture<Banner>;
let h1: HTMLElement;

beforeEach(() => {
  fixture = TestBed.createComponent(Banner);
  component = fixture.componentInstance; // 테스트할 Banner 인스턴스
  h1 = fixture.nativeElement.querySelector('h1');
});

createComponent()는 데이터를 바인딩하지 않습니다

첫 번째 테스트에서는 화면에 기본 title이 표시되는지 확인하려고 합니다. 다음과 같이 <h1>을 바로 검사하는 테스트를 작성하기 쉽습니다.

it('should display original title', () => {
  expect(h1.textContent).toContain(component.title());
});

하지만 다음 메시지와 함께 테스트가 실패합니다.

expected '' to contain 'Test Tour of Heroes'.

바인딩은 Angular가 변경 감지를 실행할 때 이루어집니다.

실제 애플리케이션에서는 Angular가 컴포넌트를 생성하거나 사용자가 키를 입력할 때 변경 감지가 자동으로 실행됩니다.

TestBed.createComponent는 변경 감지를 동기적으로 실행하지 않습니다. 수정한 테스트에서 이를 확인할 수 있습니다.

it('no title in the DOM after createComponent()', () => {
  expect(h1.textContent).toEqual('');
});

whenStable()

await fixture.whenStable()로 TestBed가 변경 감지 실행을 기다리게 할 수 있습니다. 그런 다음에야 <h1>에 예상한 제목이 표시됩니다.

it('should display original title', async () => {
  await fixture.whenStable();
  expect(h1.textContent).toContain(component.title());
});

변경 감지를 지연하는 것은 의도된 동작이며 유용합니다. Angular가 데이터 바인딩을 시작하고 라이프사이클 훅을 실행하기 전에 테스트에서 컴포넌트 상태를 확인하고 변경할 기회를 제공하기 때문입니다.

다음 테스트는 fixture.whenStable()을 실행하기 전에 컴포넌트의 title 프로퍼티를 변경합니다.

it('should display a different test title', async () => {
  component.title.set('Test Title');
  await fixture.whenStable();
  expect(h1.textContent).toContain('Test Title');
});

입력 프로퍼티에 시그널 바인딩하기

입력값의 변경을 반영하고 출력 이벤트를 감지하려면 입력 프로퍼티에는 시그널을, 출력 프로퍼티에는 함수를 동적으로 바인딩할 수 있습니다.

import {inputBinding, outputBinding} from '@angular/core';
import {TestBed} from '@angular/core/testing';

const fixture = TestBed.createComponent(ValueDisplay, {
  bindings: [
    inputBinding('value', value),
    outputBinding('valueChange', () =>  (/* ... */) ),
  ],
});

dispatchEvent()로 입력값 변경하기

사용자 입력을 재현하려면 입력 엘리먼트를 찾아 value 프로퍼티를 설정합니다.

하지만 그 사이에 꼭 필요한 단계가 있습니다.

Angular는 입력 엘리먼트의 value 프로퍼티를 직접 설정했다는 것을 알지 못합니다. dispatchEvent()로 엘리먼트의 input 이벤트를 발생시켜야 이 프로퍼티를 읽습니다.

TitleCasePipe를 사용하는 다음 컴포넌트 예제에서 올바른 순서를 확인할 수 있습니다.

it('should convert hero name to Title Case', async () => {
  const hostElement = fixture.nativeElement;
  const nameInput: HTMLInputElement = hostElement.querySelector('input')!;
  const nameDisplay: HTMLElement = hostElement.querySelector('span')!;

  // 사용자가 입력 필드에 새 이름을 입력하는 상황을 재현합니다
  nameInput.value = 'quick BROWN  fOx';

  // Angular가 입력값 변경을 알 수 있도록 DOM 이벤트를 발생시킵니다.
  nameInput.dispatchEvent(new Event('input'));

  // Angular가 title 파이프로 화면의 바인딩을 갱신할 때까지 기다립니다
  await fixture.whenStable();

  expect(nameDisplay.textContent).toBe('Quick Brown  Fox');
});

의존성이 있는 컴포넌트

컴포넌트는 서비스에 의존하는 경우가 많습니다.

Welcome 컴포넌트는 로그인한 사용자에게 환영 메시지를 표시합니다. 주입된 UserAuthentication의 프로퍼티로 사용자가 누구인지 확인합니다.

import {Component, inject, OnInit, signal} from '@angular/core';
import {UserAuthentication} from '../model/user.authentication';

@Component({
  selector: 'app-welcome',
  template: '<h3 class="welcome"><i>{{ welcome() }}</i></h3>',
})
export class Welcome {
  private userAuth = inject(UserAuthentication);
  welcome = signal(
    this.userAuth.isLoggedIn() ? `Welcome, ${this.userAuth.user().name}` : 'Please log in.',
  );
}

Welcome 컴포넌트에는 서비스와 상호작용하는 판단 로직이 있으므로 테스트할 가치가 있습니다.

서비스 테스트 대역 제공하기

테스트할 컴포넌트 에 반드시 실제 서비스를 제공할 필요는 없습니다.

실제 UserAuthentication을 주입하기 어려울 수 있습니다. 실제 서비스는 사용자에게 로그인 정보를 요구하거나 인증 서버에 접근할 수 있습니다. 이런 동작을 가로채기는 어려울 수 있습니다. 다만 테스트 대역을 사용하면 실제 애플리케이션과 테스트의 동작이 달라진다는 점을 고려해서 필요한 경우에만 사용하세요.

주입된 서비스 가져오기

테스트에서는 Welcome 컴포넌트에 주입된 UserAuthentication에 접근해야 합니다.

Angular는 계층적인 의존성 주입 시스템을 사용합니다. TestBed가 생성한 최상위 인젝터부터 컴포넌트 트리 아래까지 여러 수준에 인젝터가 있을 수 있습니다.

주입된 서비스를 가져오는 가장 안전하고 항상 동작하는 방법은 테스트할 컴포넌트 의 인젝터에서 가져오는 것입니다. 컴포넌트 인젝터는 픽스처의 DebugElement 프로퍼티로 제공됩니다.

// 컴포넌트에 실제로 주입된 UserAuthentication
userAuth = fixture.debugElement.injector.get(UserAuthentication);

참고: 대부분 은 이렇게 할 필요가 없습니다. 서비스는 최상위에서 제공하거나 TestBed에서 재정의하는 경우가 많으므로, 아래에서 설명하는 TestBed.inject()로 더 간단하게 가져올 수 있습니다.

TestBed.inject()

픽스처의 DebugElement로 서비스를 가져오는 것보다 기억하기 쉽고 코드도 간결합니다.

이 테스트 모음에서 UserAuthentication의 유일한 프로바이더는 최상위 테스트 모듈에 있으므로, 다음과 같이 TestBed.inject()를 안전하게 사용할 수 있습니다.

userAuth = TestBed.inject(UserAuthentication);

참고: TestBed.inject()로 가져올 수 없는 경우는 컴포넌트 프로바이더 재정의하기를 참고하세요. 언제, 왜 컴포넌트 인젝터에서 서비스를 가져와야 하는지 설명합니다.

최종 설정과 테스트

TestBed.inject()를 사용하는 전체 beforeEach()는 다음과 같습니다.

let fixture: ComponentFixture<Welcome>;
let comp: Welcome;
let userAuth: UserAuthentication; // TestBed가 주입한 서비스
let el: HTMLElement; // 환영 메시지가 있는 DOM 엘리먼트

beforeEach(() => {
  fixture = TestBed.createComponent(Welcome);
  comp = fixture.componentInstance;

  // 최상위 인젝터의 UserAuthentication
  userAuth = TestBed.inject(UserAuthentication);

  // CSS 셀렉터(예: 클래스 이름)로 "welcome" 엘리먼트를 가져옵니다
  el = fixture.nativeElement.querySelector('.welcome');
});

테스트는 다음과 같이 작성합니다.

it('should welcome the user', async () => {
  await fixture.whenStable();
  const content = el.textContent;

  expect(content, '"Welcome ..."').toContain('Welcome');
  expect(content, 'expected name').toContain('Test User');
});

it('should welcome "Bubba"', async () => {
  userAuth.user.set({name: 'Bubba'}); // 환영 메시지가 아직 표시되지 않았습니다
  await fixture.whenStable();

  expect(el.textContent).toContain('Bubba');
});

it('should request login if not logged in', async () => {
  userAuth.isLoggedIn.set(false); // 환영 메시지가 아직 표시되지 않았습니다
  await fixture.whenStable();
  const content = el.textContent;

  expect(content, 'not welcomed').not.toContain('Welcome');
  expect(content, '"log in"').toMatch(/log in/i);
});

첫 번째는 기본 동작을 확인하는 테스트입니다. UserAuthentication이 호출되고 동작하는지 확인합니다.

참고: expect의 두 번째 인자(예: 'expected name')는 선택적으로 지정하는 실패 설명입니다. 검증에 실패하면 Vitest가 실패 메시지에 이 설명을 추가합니다. 검증문이 여러 개 있는 테스트에서는 어떤 문제가 발생했고 어느 검증문이 실패했는지 파악하는 데 도움이 됩니다.

나머지 테스트는 서비스가 서로 다른 값을 반환할 때 컴포넌트 로직을 확인합니다. 두 번째 테스트는 사용자 이름을 변경했을 때의 동작을 검증합니다. 세 번째 테스트는 로그인한 사용자가 없을 때 적절한 메시지를 표시하는지 확인합니다.

비동기 서비스를 사용하는 컴포넌트

이 예제에서 About 컴포넌트 템플릿에는 Twain 컴포넌트가 있습니다. Twain 컴포넌트는 마크 트웨인의 명언을 표시합니다.

<p class="twain">
  <i>{{ quote | async }}</i>
</p>
<button type="button" (click)="getQuote()">Next quote</button>
@if (errorMessage()) {
  <p class="error">{{ errorMessage() }}</p>
}

참고: 컴포넌트의 quote 프로퍼티 값은 AsyncPipe를 통과합니다. 따라서 이 프로퍼티는 Promise나 Observable을 반환합니다.

이 예제에서는 TwainQuotes.getQuote() 메서드를 보면 quote 프로퍼티가 Observable을 반환한다는 것을 알 수 있습니다.

getQuote() {
  this.errorMessage.set('');
  this.quote = this.twainQuotes.getQuote().pipe(
    startWith('...'),
    catchError((err: any) => {
      this.errorMessage.set(err.message || err.toString());
      return of('...'); // 메시지를 임시 값으로 되돌립니다
    }),
  );
}

Twain 컴포넌트는 주입된 TwainQuotes에서 명언을 가져옵니다. 서비스가 첫 번째 명언을 반환하기 전에 표시할 수 있도록 반환된 Observable을 임시 값('...')으로 시작합니다.

catchError는 서비스 오류를 가로채고 오류 메시지를 준비한 뒤, 정상 값 전달 경로로 임시 값을 반환합니다.

이 기능들은 모두 테스트해 보는 것이 좋습니다.

HttpTestingController로 HTTP 요청을 모킹해서 테스트하기

컴포넌트를 테스트할 때는 서비스의 공개 API에만 관심을 두어야 합니다. 일반적으로 테스트 자체가 원격 서버에 요청을 보내서는 안 됩니다. 그런 요청을 재현해야 합니다.

비동기 서비스가 HttpClient로 원격 데이터를 로드한다면 HttpTestingController로 HTTP 수준에서 목 응답을 반환하는 방식을 권장합니다.

HttpBackend 모킹에 대한 자세한 내용은 HTTP 테스트 문서를 참고하세요.

서비스의 스텁 구현으로 테스트하기

HTTP 수준에서 비동기 요청을 모킹할 수 없다면 스파이를 활용할 수 있습니다.

app/twain/twain-quotes.spec.ts의 다음 설정은 그 방법 중 하나입니다.

twain.spec.ts

class TwainQuotesStub implements TwainQuotes {
  private testQuote = 'Test Quote';

  getQuote() {
    return of(this.testQuote);
  }

  // ... API에 맞게 모든 기능을 구현합니다
}

beforeEach(async () => {
  TestBed.configureTestingModule({
    providers: [{provide: TwainQuotes, useClass: TwainQuotesStub}],
  });

  fixture = TestBed.createComponent(Twain);
  component = fixture.componentInstance;
  await fixture.whenStable();
  quoteEl = fixture.nativeElement.querySelector('.twain');
});

스텁 구현이 원래 구현을 어떻게 대체하는지 확인해 보세요.

TestBed.configureTestingModule({
  providers: [{provide: TwainQuotes, useClass: TwainQuotesStub}],
});

이 스텁은 이를 주입하는 모든 컴포넌트나 서비스가 스텁 구현을 받도록 구성되어 있습니다. 따라서 getQuote를 호출하면 항상 테스트용 명언이 담긴 옵저버블을 받습니다.

실제 getQuote() 메서드와 달리 이 스파이는 서버를 거치지 않고 값을 즉시 사용할 수 있는 동기 옵저버블을 반환합니다.

Vitest 가상 타이머로 비동기 테스트하기

setTimeout이나 Promise 같은 비동기 동작을 모킹할 때는 Vitest 가상 타이머로 실행 시점을 제어할 수 있습니다.

it('should display error when TwainQuotes service fails', async () => {
  class TwainQuotesStub implements TwainQuotes {
    getQuote() {
      return defer(() => {
        return new Promise<string>((_, reject) => {
          setTimeout(() => reject('TwainService test failure'));
        });
      });
    }

    // ... API에 맞게 모든 기능을 구현합니다
  }

  TestBed.configureTestingModule({
    providers: [{provide: TwainQuotes, useClass: TwainQuotesStub}],
  });

  vi.useFakeTimers(); // 가상 타이머를 설정합니다
  const fixture = TestBed.createComponent(TwainComponent);

  // 렌더링이 비동기가 아니므로 대기 작업을 처리해야 합니다
  await vi.runAllTimersAsync();

  await expect(fixture.nativeElement.querySelector('.error')!.textContent).toMatch(/test failure/);
  expect(fixture.nativeElement.querySelector('.twain')!.textContent).toBe('...');

  vi.useRealTimers(); // 일반적인 비동기 실행으로 되돌립니다
});

비동기 테스트 더 알아보기

스텁 서비스가 비동기 옵저버블을 반환한다면 대부분의 테스트도 비동기로 작성해야 합니다.

다음 테스트는 실제 환경에서 예상하는 데이터 흐름을 보여줍니다.

class MockTwainQuotes implements TwainQuotes {
  private subject = new Subject<string>();

  getQuote() {
    return this.subject.asObservable();
  }

  emit(val: string) {
    this.subject.next(val);
  }
}

it('should show quote after getQuote (success)', async () => {
  vi.useFakeTimers();

  TestBed.configureTestingModule({
    providers: [{provide: TwainQuotes, useClass: MockTwainQuotes}],
  });

  const fixture = TestBed.createComponent(TwainComponent);
  const twainQuotes = TestBed.inject(TwainQuotes) as MockTwainQuotes;
  await vi.runAllTimersAsync(); // 명언을 받기 전에 렌더링합니다

  const quoteEl = fixture.nativeElement.querySelector('.twain');
  expect(quoteEl.textContent).toBe('...');

  twainQuotes.emit('Twain Quote'); // 명언을 발행합니다
  await vi.runAllTimersAsync(); // 명언을 받은 상태로 렌더링합니다

  expect(quoteEl.textContent).toBe('Twain Quote');
  expect(fixture.nativeElement.querySelector('.error')).toBeNull();

  vi.useRealTimers();
});

처음 렌더링할 때 명언 엘리먼트에 임시 값('...')이 표시되는 것을 확인해 보세요. 아직 첫 번째 명언이 도착하지 않았기 때문입니다.

그런 다음 명언 엘리먼트에 예상한 텍스트가 표시되는지 검증할 수 있습니다.

zone.js와 fakeAsync로 비동기 테스트하기

fakeAsync 헬퍼 함수도 가상 시계를 제공합니다. zone.js로 비동기 API를 패치하는 방식이며, zone.js 기반 애플리케이션의 테스트에서 자주 사용했습니다. 이제는 fakeAsync 사용을 권장하지 않습니다.

팁: 네이티브 비동기 테스트 방식이나 Vitest, Jasmine이 제공하는 가상 타이머(목 시계라고도 합니다)를 우선 사용하세요.

중요: fakeAsync에는 zone.js가 필요합니다. Vitest 테스트 러너에서 사용하려면 Vitest 마이그레이션 가이드에 설명된 대로 zone.js/plugins/vitest-patch 폴리필을 추가하세요.

입력과 출력이 있는 컴포넌트

입력과 출력이 있는 컴포넌트는 보통 호스트 컴포넌트의 뷰 템플릿 안에 있습니다. 호스트는 프로퍼티 바인딩으로 입력 프로퍼티를 지정하고, 이벤트 바인딩으로 출력 프로퍼티가 발생시키는 이벤트를 감지합니다.

이런 바인딩이 예상대로 동작하는지 검증하는 것이 테스트의 목표입니다. 테스트에서는 입력값을 설정하고 출력 이벤트를 감지해야 합니다.

DashboardHero는 이런 역할을 하는 간단한 컴포넌트입니다. Dashboard 컴포넌트가 제공하는 히어로 하나를 표시합니다. 사용자가 히어로를 클릭하면 Dashboard 컴포넌트에 선택한 히어로를 알립니다.

DashboardHero 컴포넌트는 Dashboard 템플릿에 다음과 같이 들어갑니다.

@for (hero of heroes; track hero) {
  <dashboard-hero class="col-1-4" [hero]="hero" (selected)="gotoDetail($event)" />
}

DashboardHero 컴포넌트는 @for 블록 안에 있습니다. 각 컴포넌트의 hero 입력 프로퍼티를 반복 중인 값으로 지정하고 컴포넌트의 selected 이벤트를 감지합니다.

컴포넌트 전체 정의는 다음과 같습니다.

@Component({
  selector: 'dashboard-hero',
  imports: [UpperCasePipe],
  template: `
    <button type="button" (click)="click()" class="hero">
      {{ hero().name | uppercase }}
    </button>
  `,
})
export class DashboardHero {
  readonly hero = input.required<Hero>();
  readonly selected = output<Hero>();

  click() {
    this.selected.emit(this.hero());
  }
}

이렇게 간단한 컴포넌트는 테스트 자체의 이점이 크지 않지만, 테스트 방법은 알아둘 만합니다. 다음 방법 중 하나를 사용할 수 있습니다.

  • Dashboard 컴포넌트에서 사용하는 상태로 테스트합니다.
  • 독립적인 컴포넌트로 테스트합니다.
  • Dashboard를 대신하는 컴포넌트에서 사용하는 상태로 테스트합니다.

지금은 Dashboard가 아니라 DashboardHero를 테스트하는 것이 목표이므로 두 번째와 세 번째 방법을 살펴봅시다.

DashboardHero 컴포넌트를 독립적으로 테스트하기

테스트 파일 설정의 핵심 부분은 다음과 같습니다.

let fixture: ComponentFixture<DashboardHero>;
let comp: DashboardHero;
let heroDe: DebugElement;
let heroEl: HTMLElement;
let expectedHero: Hero;

beforeEach(async () => {
  fixture = TestBed.createComponent(DashboardHero);
  comp = fixture.componentInstance;

  // 히어로의 DebugElement와 엘리먼트를 찾습니다
  heroDe = fixture.debugElement.query(By.css('.hero'));
  heroEl = heroDe.nativeElement;

  // 부모 컴포넌트가 제공하는 히어로를 모킹합니다
  expectedHero = {id: 42, name: 'Test Name'};

  // 부모가 해당 히어로를 입력 프로퍼티에 지정하는 상황을 재현합니다
  fixture.componentRef.setInput('hero', expectedHero);

  // 초기 데이터 바인딩을 기다립니다
  await fixture.whenStable();
});

설정 코드가 테스트용 히어로(expectedHero)를 컴포넌트의 hero 프로퍼티에 할당하는 방식을 확인해 보세요. Dashboard가 반복문 안의 프로퍼티 바인딩으로 값을 지정하는 방식을 재현합니다.

다음 테스트는 히어로 이름이 바인딩을 통해 템플릿에 전달되는지 검증합니다.

it('should display hero name in uppercase', () => {
  const expectedPipedName = expectedHero.name.toUpperCase();
  expect(heroEl.textContent).toContain(expectedPipedName);
});

템플릿에서 히어로 이름에 Angular UpperCasePipe를 적용하므로, 테스트에서도 대문자로 변환한 이름과 엘리먼트 값을 비교해야 합니다.

클릭하기

히어로를 클릭하면 호스트 컴포넌트(이 경우에는 Dashboard)가 감지할 수 있는 selected 이벤트가 발생해야 합니다.

it('should raise selected event when clicked (triggerEventHandler)', () => {
  let selectedHero: Hero | undefined;
  comp.selected.subscribe((hero: Hero) => (selectedHero = hero));

  heroDe.triggerEventHandler('click');
  expect(selectedHero).toBe(expectedHero);
});

컴포넌트의 selected 프로퍼티는 OutputEmitterRef입니다. 이 객체의 subscribe 메서드는 구독자에게 동기적으로 알립니다. 호스트 컴포넌트가 묵시적으로 구독하는 것을 테스트에서는 명시적으로 구독합니다.

컴포넌트가 예상대로 동작한다면 히어로 엘리먼트를 클릭했을 때 selected 프로퍼티가 hero 객체를 발행해야 합니다.

테스트는 selected 구독을 통해 이 이벤트를 감지합니다.

triggerEventHandler

앞의 테스트에서 heroDe는 히어로 <button>를 나타내는 DebugElement입니다.

네이티브 엘리먼트와의 상호작용을 추상화하는 Angular 프로퍼티와 메서드를 제공합니다. 이 테스트는 이벤트 이름 "click"을 전달해서 DebugElement.triggerEventHandler를 실행합니다. "click" 이벤트 바인딩은 이에 반응해서 DashboardHero.click()을 실행합니다.

Angular DebugElement.triggerEventHandler는 이벤트 이름 으로 데이터 바인딩된 모든 이벤트 를 발생시킬 수 있습니다. 두 번째 인자는 핸들러에 전달할 이벤트 객체입니다.

테스트에서는 "click" 이벤트를 발생시켰습니다.

heroDe.triggerEventHandler('click');

이 경우에는 실제 이벤트 핸들러인 컴포넌트의 click() 메서드가 이벤트 객체를 사용하지 않는다는 가정이 맞습니다.

참고: 다른 핸들러에서는 그렇지 않을 수 있습니다. 예를 들어 RouterLink 디렉티브는 클릭할 때 어떤 마우스 버튼을 눌렀는지 나타내는 button 프로퍼티가 있는 객체를 기대합니다. 이벤트 객체를 전달하지 않으면 RouterLink 디렉티브에서 오류가 발생합니다.

엘리먼트 클릭하기

다음과 같이 네이티브 엘리먼트 자체의 click() 메서드를 실행하는 방법도 있습니다. 이 컴포넌트 에는 적절한 방법입니다.

it('should raise selected event when clicked (element.click)', () => {
  let selectedHero: Hero | undefined;
  comp.selected.subscribe((hero: Hero) => (selectedHero = hero));

  heroEl.click();
  expect(selectedHero).toBe(expectedHero);
});

click() 헬퍼

버튼, 링크, 임의의 HTML 엘리먼트를 클릭하는 것은 테스트에서 자주 하는 작업입니다.

다음 click() 함수처럼 클릭을 발생시키는 과정을 헬퍼로 감싸면 일관되고 간단하게 사용할 수 있습니다.

/** RouterLink 이벤트 핸들러를 위해 `DebugElement.triggerEventHandler`에 전달할 버튼 이벤트 */
export const ButtonClickEvents = {
  left: {button: 0},
  right: {button: 2},
};

/** 엘리먼트 클릭을 재현합니다. 기본값은 마우스 왼쪽 버튼 클릭 이벤트입니다. */
export function click(
  el: DebugElement | HTMLElement,
  eventObj: any = ButtonClickEvents.left,
): void {
  if (el instanceof HTMLElement) {
    el.click();
  } else {
    el.triggerEventHandler('click', eventObj);
  }
}

첫 번째 인자는 클릭할 엘리먼트 입니다. 필요하면 두 번째 인자로 커스텀 이벤트 객체를 전달하세요. 기본값은 RouterLink 디렉티브를 비롯한 여러 핸들러가 처리할 수 있는 마우스 왼쪽 버튼 이벤트 객체의 일부입니다.

중요: click() 헬퍼 함수는 Angular 테스트 유틸리티가 아닙니다. 이 문서의 예제 코드 에 정의한 함수입니다. 모든 예제 테스트가 이 함수를 사용합니다. 유용하다면 직접 사용하는 헬퍼 모음에 추가해 보세요.

앞의 테스트를 click 헬퍼로 다시 작성하면 다음과 같습니다.

it('should raise selected event when clicked (click helper with DebugElement)', () => {
  let selectedHero: Hero | undefined;
  comp.selected.subscribe((hero: Hero) => (selectedHero = hero));

  click(heroDe); // DebugElement에 click 헬퍼를 사용합니다

  expect(selectedHero).toBe(expectedHero);
});

테스트 호스트 안의 컴포넌트

앞의 테스트는 호스트인 Dashboard 컴포넌트의 역할을 직접 수행했습니다. 그렇다면 DashboardHero를 호스트 컴포넌트에 실제로 데이터 바인딩했을 때도 제대로 동작할까요?

@Component({
  imports: [DashboardHero],
  template: ` <dashboard-hero [hero]="hero" (selected)="onSelected($event)" />`,
})
class TestHost {
  hero: Hero = {id: 42, name: 'Test Name'};
  selectedHero: Hero | undefined;

  onSelected(hero: Hero) {
    this.selectedHero = hero;
  }
}

테스트 호스트는 테스트용 히어로를 컴포넌트의 hero 입력 프로퍼티로 지정합니다. 또한 컴포넌트의 selected 이벤트를 onSelected 핸들러에 바인딩하고, 이 핸들러는 전달받은 히어로를 selectedHero 프로퍼티에 저장합니다.

이후 테스트에서는 selectedHero를 확인해서 DashboardHero.selected 이벤트가 예상한 히어로를 전달했는지 검증할 수 있습니다.

test-host 테스트의 설정은 독립적인 테스트 설정과 비슷합니다.

beforeEach(async () => {
  // DashboardHero 대신 TestHost를 생성합니다
  fixture = TestBed.createComponent(TestHost);
  testHost = fixture.componentInstance;
  heroEl = fixture.nativeElement.querySelector('.hero');

  await fixture.whenStable();
});

이 테스트 모듈 설정에는 두 가지 중요한 차이가 있습니다.

  • DashboardHero 대신 TestHost 컴포넌트를 생성 합니다.
  • TestHost 컴포넌트가 바인딩으로 DashboardHero.hero를 지정합니다.

createComponent는 DashboardHero가 아닌 TestHost 인스턴스를 담은 fixture를 반환합니다.

TestHost 템플릿에 DashboardHero가 있으므로 TestHost를 생성하면 DashboardHero도 함께 생성됩니다. 히어로 엘리먼트(heroEl)는 이전보다 엘리먼트 트리의 더 깊은 곳에 있지만, 쿼리로 여전히 테스트 DOM에서 찾을 수 있습니다.

테스트 자체는 독립적인 테스트와 거의 같습니다.

it('should display hero name', () => {
  const expectedPipedName = testHost.hero.name.toUpperCase();
  expect(heroEl.textContent).toContain(expectedPipedName);
});

it('should raise selected event when clicked', () => {
  click(heroEl);
  // 선택한 히어로는 데이터 바인딩한 히어로와 같아야 합니다
  expect(testHost.selectedHero).toBe(testHost.hero);
});

선택 이벤트 테스트만 다릅니다. 선택한 DashboardHero의 히어로가 이벤트 바인딩을 거쳐 호스트 컴포넌트까지 실제로 전달되는지 확인합니다.

라우팅을 실행하는 컴포넌트

라우팅을 실행하는 컴포넌트 는 Router에 다른 컴포넌트로 이동하도록 요청하는 컴포넌트입니다. 사용자가 대시보드의 히어로 버튼 을 클릭하면 HeroDetail로 이동할 수 있으므로 Dashboard는 라우팅을 실행하는 컴포넌트 입니다.

Angular는 기본 설정 코드를 줄이고 HttpClient에 의존하는 코드를 더 효과적으로 테스트하는 헬퍼를 제공합니다. 테스트 모듈에서 provideRouter 함수도 직접 사용할 수 있습니다.

beforeEach(async () => {
  TestBed.configureTestingModule({
    providers: [
      provideRouter([{path: '**', component: Dashboard}]),
      provideHttpClientTesting(),
      HeroService,
    ],
  });
  harness = await RouterTestingHarness.create();
  comp = await harness.navigateByUrl('/', Dashboard);
  TestBed.inject(HttpTestingController).expectOne('api/heroes').flush(getTestHeroes());
});

다음 테스트는 화면의 히어로를 클릭하고 예상한 URL로 이동하는지 확인합니다.

it('should tell navigate when hero clicked', async () => {
  // 첫 번째 <dashboard-hero>의 DebugElement를 가져옵니다
  const heroDe = harness.routeDebugElement!.query(By.css('dashboard-hero'));
  heroDe.triggerEventHandler('selected', comp.heroes[0]);

  // 컴포넌트의 첫 번째 히어로 ID로 이동해야 합니다
  const id = comp.heroes[0].id;
  expect(TestBed.inject(Router).url, 'should nav to HeroDetail for first hero').toEqual(
    `/heroes/${id}`,
  );
});

라우팅 대상 컴포넌트

라우팅 대상 컴포넌트 는 Router가 이동하는 목적지입니다. 특히 컴포넌트의 라우팅 규칙에 파라미터가 포함되어 있으면 테스트가 더 까다로울 수 있습니다. HeroDetail은 이런 라우팅 규칙의 목적지인 라우팅 대상 컴포넌트 입니다.

사용자가 Dashboard 의 히어로를 클릭하면 Dashboard는 Router에 heroes/:id로 이동하도록 요청합니다. :id는 수정할 히어로의 id를 값으로 갖는 라우팅 규칙 파라미터입니다.

Router는 이 URL에 대응하는 HeroDetail 라우팅 규칙을 찾습니다. 라우팅 정보로 ActivatedRoute 객체를 생성한 뒤 새 HeroDetail 인스턴스에 주입합니다.

HeroDetail에 주입하는 서비스는 다음과 같습니다.

private heroDetailService = inject(HeroDetailService);
private route = inject(ActivatedRoute);
private router = inject(Router);

HeroDetail 컴포넌트는 HeroDetailService로 해당 히어로를 가져오기 위해 id 파라미터가 필요합니다. 컴포넌트는 Observable인 ActivatedRoute.paramMap 프로퍼티에서 id를 가져와야 합니다.

ActivatedRoute.paramMap의 id 프로퍼티를 단순히 참조할 수는 없습니다. ActivatedRoute.paramMap 옵저버블을 구독 하고 컴포넌트가 동작하는 동안 id가 변경될 수 있다는 점에 대비해야 합니다.

constructor() {
  // `id` 파라미터가 바뀌면 히어로를 가져옵니다
  this.route.paramMap.subscribe((pmap) => this.getHero(pmap.get('id')));
}

서로 다른 라우팅 규칙으로 이동하면 여러 id 파라미터 값에 HeroDetail이 어떻게 반응하는지 테스트할 수 있습니다.

중첩 컴포넌트 테스트

컴포넌트 템플릿에는 다른 컴포넌트가 들어가는 경우가 많으며, 그 컴포넌트의 템플릿에도 또 다른 컴포넌트가 있을 수 있습니다.

컴포넌트 트리는 아주 깊어질 수 있고, 중첩된 컴포넌트가 최상위 컴포넌트 테스트에는 필요하지 않은 경우도 있습니다.

예를 들어 App 컴포넌트는 링크와 RouterLink 디렉티브로 구성된 내비게이션 바를 표시합니다.

<app-banner />
<app-welcome />

<nav>
  <a routerLink="/dashboard">Dashboard</a>
  <a routerLink="/heroes">Heroes</a>
  <a routerLink="/about">About</a>
</nav>

<router-outlet />

이동 동작이 아니라 링크만 검증한다면 Router가 실제로 이동할 필요가 없습니다. Router가 라우팅 대상 컴포넌트 를 삽입할 위치를 표시하는 <router-outlet>도 필요하지 않습니다.

<app-banner>, <app-welcome>으로 표시된 Banner, Welcome 컴포넌트도 관련이 없습니다.

하지만 DOM에 App 컴포넌트를 생성하는 테스트는 이 세 컴포넌트의 인스턴스도 생성합니다. 그대로 생성하려면 TestBed에서도 이를 생성할 수 있도록 설정해야 합니다.

선언하지 않으면 Angular 컴파일러는 App 템플릿의 <app-banner>, <app-welcome>, <router-outlet> 태그를 인식하지 못하고 오류를 발생시킵니다.

실제 컴포넌트를 선언하면 그 안의 중첩 컴포넌트도 선언해야 하고, 트리에 있는 모든 컴포넌트에 주입되는 모든 서비스도 제공해야 합니다.

여기서는 설정을 줄이는 두 가지 방법을 알아봅니다. 한 가지를 사용하거나 둘을 조합해서 주요 컴포넌트 테스트에 집중할 수 있습니다.

불필요한 컴포넌트를 스텁으로 대체하기

첫 번째 방법은 테스트에 거의 필요하지 않거나 전혀 필요하지 않은 컴포넌트와 디렉티브의 스텁을 만들어 선언하는 것입니다.

@Component({selector: 'app-banner', template: ''})
class BannerStub {}

@Component({selector: 'router-outlet', template: ''})
class RouterOutletStub {}

@Component({selector: 'app-welcome', template: ''})
class WelcomeStub {}

스텁의 셀렉터는 실제 컴포넌트와 같습니다. 하지만 템플릿과 클래스는 비어 있습니다.

그런 다음 TestBed.overrideComponent로 컴포넌트의 imports를 재정의해서 스텁을 선언합니다.

let comp: App;
let fixture: ComponentFixture<App>;

beforeEach(() => {
  TestBed.configureTestingModule({
    providers: [provideRouter([]), UserAuthentication],
  }).overrideComponent(App, {
    set: {
      imports: [RouterLink, BannerStub, RouterOutletStub, WelcomeStub],
    },
  });

  fixture = TestBed.createComponent(App);
  comp = fixture.componentInstance;
});

참고: 이 예제의 set 키는 컴포넌트의 기존 imports 전체를 교체합니다. 스텁뿐 아니라 필요한 모든 의존성을 로드해야 합니다. 대신 remove와 add 키로 imports를 선택적으로 제거하고 추가할 수도 있습니다.

NO_ERRORS_SCHEMA

두 번째 방법은 컴포넌트 메타데이터를 재정의하면서 NO_ERRORS_SCHEMA를 추가하는 것입니다.

beforeEach(() => {
  TestBed.configureTestingModule({
    providers: [provideRouter([]), UserAuthentication],
  }).overrideComponent(App, {
    set: {
      imports: [], // 모든 imports를 초기화합니다
      schemas: [NO_ERRORS_SCHEMA],
    },
  });
});

NO_ERRORS_SCHEMA는 Angular 컴파일러가 인식하지 못하는 엘리먼트와 어트리뷰트를 무시하도록 합니다.

TestBed 설정에 대응하는 App 컴포넌트와 RouterLink를 선언했으므로 컴파일러는 <app-root> 엘리먼트와 routerLink 어트리뷰트를 인식합니다.

하지만 <app-banner>, <app-welcome>, <router-outlet>을 만나도 오류를 발생시키지 않습니다. 빈 태그로 렌더링하고, 브라우저는 이를 무시합니다.

이제 스텁 컴포넌트가 필요하지 않습니다.

두 방법 함께 사용하기

이런 방식을 얕은 컴포넌트 테스트(Shallow Component Testing) 라고 합니다. 컴포넌트 템플릿에서 테스트에 필요한 엘리먼트만 남겨 화면 구성을 줄이기 때문입니다.

두 방법 중에서는 NO_ERRORS_SCHEMA가 간단하지만 남용하지 마세요.

NO_ERRORS_SCHEMA를 사용하면 실수로 빠뜨리거나 이름을 잘못 적은 컴포넌트와 어트리뷰트도 컴파일러가 알려주지 못합니다. 컴파일러가 즉시 발견할 수 있는 오류를 찾느라 몇 시간을 허비할 수 있습니다.

스텁 컴포넌트 방식에는 또 다른 장점이 있습니다. 이 예제에서는 스텁이 비어 있지만, 테스트에서 스텁과 상호작용해야 한다면 단순화한 템플릿과 클래스를 제공할 수 있습니다.

실제로는 다음 예제처럼 두 방식을 하나의 설정에서 함께 사용하기도 합니다.

beforeEach(() => {
  TestBed.configureTestingModule({
    providers: [provideRouter([]), UserAuthentication],
  }).overrideComponent(App, {
    remove: {imports: [RouterOutlet, Welcome]},
    set: {schemas: [NO_ERRORS_SCHEMA]},
  });
});

Angular 컴파일러는 <app-banner> 엘리먼트에 BannerStub을 생성하고 routerLink 어트리뷰트가 있는 링크에는 RouterLink를 적용합니다. 하지만 <app-welcome>과 <router-outlet> 태그는 무시합니다.

By.directive와 주입된 디렉티브

설정을 조금 더 추가해서 초기 데이터 바인딩을 실행하고 내비게이션 링크의 참조를 가져옵니다.

beforeEach(async () => {
  await fixture.whenStable();

  // RouterLinkStubDirective가 적용된 DebugElement를 찾습니다
  linkDes = fixture.debugElement.queryAll(By.directive(RouterLink));

  // 적용된 링크 디렉티브 인스턴스를 가져옵니다
  // 각 DebugElement의 인젝터를 사용합니다
  routerLinks = linkDes.map((de) => de.injector.get(RouterLink));
});

세 가지 내용을 눈여겨보세요.

  • By.directive로 디렉티브가 적용된 링크 엘리먼트를 찾습니다.
  • 쿼리는 일치하는 엘리먼트를 감싼 DebugElement를 반환합니다.
  • 각 DebugElement는 해당 엘리먼트에 적용된 디렉티브 인스턴스가 들어 있는 의존성 인젝터를 제공합니다.

검증할 App 컴포넌트의 링크는 다음과 같습니다.

<nav>
  <a routerLink="/dashboard">Dashboard</a>
  <a routerLink="/heroes">Heroes</a>
  <a routerLink="/about">About</a>
</nav>

다음 테스트는 링크가 예상대로 routerLink 디렉티브에 연결되는지 확인합니다.

it('can get RouterLinks from template', () => {
  expect(routerLinks.length, 'should have 3 routerLinks').toBe(3);
  expect(routerLinks[0].href).toBe('/dashboard');
  expect(routerLinks[1].href).toBe('/heroes');
  expect(routerLinks[2].href).toBe('/about');
});

it('can click Heroes link in template', async () => {
  const heroesLinkDe = linkDes[1]; // heroes 링크의 DebugElement

  TestBed.inject(Router).resetConfig([{path: '**', children: []}]);
  heroesLinkDe.triggerEventHandler('click', {button: 0});

  await fixture.whenStable();

  expect(TestBed.inject(Router).url).toBe('/heroes');
});

page 객체 사용하기

HeroDetail 컴포넌트는 제목, 히어로 필드 두 개, 버튼 두 개로 이루어진 간단한 뷰입니다.

하지만 이렇게 간단한 폼도 템플릿은 상당히 복잡할 수 있습니다.

@if (hero) {
  <div>
    <h2>
      <span>{{ hero.name | titlecase }}</span> Details
    </h2>
    <div><span>id: </span>{{ hero.id }}</div>
    <div>
      <label for="name">name: </label>
      <input id="name" [(ngModel)]="hero.name" placeholder="name" />
    </div>
    <button type="button" (click)="save()">Save</button>
    <button type="button" (click)="cancel()">Cancel</button>
  </div>
}

컴포넌트를 테스트하려면 다음 내용이 필요합니다.

  • DOM에 엘리먼트가 나타나도록 히어로를 가져올 때까지 기다리기
  • 제목 텍스트 참조
  • 값을 확인하고 설정할 이름 입력 필드 참조
  • 클릭할 버튼 두 개의 참조

작은 폼이라도 조건에 따른 설정과 CSS 엘리먼트 선택 코드가 복잡하게 얽힐 수 있습니다.

컴포넌트 프로퍼티에 접근하고 값을 설정하는 로직을 Page 클래스로 감싸면 복잡성을 줄일 수 있습니다.

hero-detail.component.spec.ts에서 사용할 Page 클래스는 다음과 같습니다.

class Page {
  // getter 프로퍼티는 호출될 때 DOM을 조회합니다.
  get buttons() {
    return this.queryAll<HTMLButtonElement>('button');
  }
  get saveBtn() {
    return this.buttons[0];
  }
  get cancelBtn() {
    return this.buttons[1];
  }
  get nameDisplay() {
    return this.query<HTMLElement>('span');
  }
  get nameInput() {
    return this.query<HTMLInputElement>('input');
  }

  //// 쿼리 헬퍼 ////
  private query<T>(selector: string): T {
    return harness.routeNativeElement!.querySelector(selector)! as T;
  }

  private queryAll<T>(selector: string): T[] {
    return harness.routeNativeElement!.querySelectorAll(selector) as any as T[];
  }
}

이제 컴포넌트를 조작하고 확인하는 주요 접근 지점이 잘 정리되었고, Page 인스턴스로 접근할 수 있습니다.

createComponent 메서드는 page 객체를 만들고 hero를 가져온 후 필요한 내용을 채웁니다.

async function createComponent(id: number) {
  harness = await RouterTestingHarness.create();
  component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetail);
  page = new Page();

  const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`);
  const hero = getTestHeroes().find((h) => h.id === Number(id));
  request.flush(hero ? [hero] : []);
  await harness.fixture.whenStable();
}

다음 HeroDetail 컴포넌트 테스트에서도 이 방식을 확인해 보세요.

it("should display that hero's name", () => {
  expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});

it('should navigate when click cancel', () => {
  click(page.cancelBtn);
  expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`);
});

it('should save when click save but not navigate immediately', () => {
  click(page.saveBtn);
  expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'}));
  expect(TestBed.inject(Router).url).toEqual('/heroes/41');
});

it('should navigate when click save and save resolves', async () => {
  click(page.saveBtn);
  await harness.fixture.whenStable();
  expect(TestBed.inject(Router).url).toEqual('/heroes/41');
});

it('should convert hero name to Title Case', async () => {
  // DOM에서 이름 입력 엘리먼트와 표시 엘리먼트를 가져옵니다
  const hostElement: HTMLElement = harness.routeNativeElement!;
  const nameInput: HTMLInputElement = hostElement.querySelector('input')!;
  const nameDisplay: HTMLElement = hostElement.querySelector('span')!;

  // 사용자가 입력 필드에 새 이름을 입력하는 상황을 재현합니다
  nameInput.value = 'quick BROWN  fOx';

  // Angular가 입력값 변경을 알 수 있도록 DOM 이벤트를 발생시킵니다.
  nameInput.dispatchEvent(new Event('input'));

  // Angular가 title 파이프로 화면의 바인딩을 갱신할 때까지 기다립니다
  await harness.fixture.whenStable();

  expect(nameDisplay.textContent).toBe('Quick Brown  Fox');
});

컴포넌트 프로바이더 재정의하기

HeroDetail은 자체 HeroDetailService를 제공합니다.

@Component({
  /* ... */
  providers: [HeroDetailService],
})
export class HeroDetail {
  private heroDetailService = inject(HeroDetailService);
  private route = inject(ActivatedRoute);
  private router = inject(Router);
}

TestBed.configureTestingModule의 providers에서는 컴포넌트의 HeroDetailService를 스텁으로 대체할 수 없습니다. 이 프로바이더들은 컴포넌트가 아닌 테스트 모듈 에 적용됩니다. 픽스처 수준 의 의존성 인젝터를 구성하기 때문입니다.

Angular는 컴포넌트를 생성하면서 자체 인젝터도 생성합니다. 이 인젝터는 픽스처 인젝터의 자식 입니다. 컴포넌트의 프로바이더(이 경우에는 HeroDetailService)는 이 자식 인젝터에 등록합니다.

테스트에서는 픽스처 인젝터를 통해 자식 인젝터의 서비스에 접근할 수 없습니다. TestBed.configureTestingModule로 설정할 수도 없습니다.

지금까지 Angular는 실제 HeroDetailService의 새 인스턴스를 생성하고 있었던 것입니다!

참고: HeroDetailService가 원격 서버로 XHR 요청을 직접 보낸다면 이 테스트는 실패하거나 시간이 초과될 수 있습니다. 요청할 원격 서버가 없을 수도 있기 때문입니다.

다행히 HeroDetailService는 원격 데이터 접근을 주입된 HeroService에 위임합니다.

@Service()
export class HeroDetailService {
  private heroService = inject(HeroService);
}

앞의 테스트 설정에서는 실제 HeroService를 서버 요청을 가로채고 가상 응답을 제공하는 TestHeroService로 교체했습니다.

그렇게 할 수 없다면 어떨까요? HeroService를 대체하기 어렵다면 어떨까요? HeroDetailService가 직접 서버에 요청한다면 어떨까요?

TestBed.overrideComponent로 컴포넌트의 providers를 관리하기 쉬운 테스트 대역 으로 교체할 수 있습니다. 설정을 다음과 같이 변경해 봅시다.

beforeEach(async () => {
  await TestBed.configureTestingModule({
    providers: [
      provideRouter([
        {path: 'heroes', component: HeroList},
        {path: 'heroes/:id', component: HeroDetail},
      ]),
      // 이 수준의 HeroDetailService는 관련이 없습니다!
      {provide: HeroDetailService, useValue: {}},
    ],
  }).overrideComponent(HeroDetail, {
    set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},
  });
});

이제는 필요하지 않기 때문에 TestBed.configureTestingModule에서 가짜 HeroService를 제공하지 않는다는 점을 확인해 보세요.

overrideComponent 메서드

overrideComponent 메서드를 자세히 살펴봅시다.

.overrideComponent(HeroDetail, {
  set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},
});

재정의할 컴포넌트 타입(HeroDetail)과 재정의 메타데이터 객체, 이렇게 두 인자를 받습니다. 재정의 메타데이터 객체는 다음과 같은 제네릭으로 정의됩니다.

type MetadataOverride<T> = {
  add?: Partial<T>;
  remove?: Partial<T>;
  set?: Partial<T>;
};

메타데이터 재정의 객체로 메타데이터 프로퍼티의 항목을 추가하거나 제거할 수 있으며, 해당 프로퍼티 전체를 새로 지정할 수도 있습니다. 이 예제에서는 컴포넌트의 providers 메타데이터를 새로 지정합니다.

타입 파라미터 T는 @Component 데코레이터에 전달하는 메타데이터 타입입니다.

selector?: string;
template?: string;
templateUrl?: string;
providers?: any[];
…

스파이 스텁 (HeroDetailServiceSpy) 제공하기

이 예제는 컴포넌트의 providers 배열 전체를 HeroDetailServiceSpy가 들어 있는 새 배열로 교체합니다.

HeroDetailServiceSpy는 실제 HeroDetailService의 스텁으로, 테스트에 필요한 서비스 기능을 모두 대신 구현합니다. 하위 HeroService를 주입하거나 호출하지 않으므로 이를 위한 테스트 대역도 필요하지 않습니다.

관련 HeroDetail 컴포넌트 테스트는 서비스 메서드에 스파이를 적용해서 HeroDetailService의 메서드가 호출되는지 검증합니다. 따라서 스텁의 메서드를 스파이로 구현합니다.

import {vi} from 'vitest';

class HeroDetailServiceSpy {
  testHero: Hero = {...testHero};

  /* 복제한 테스트용 히어로를 발행합니다 */
  getHero = vi.fn(() => asyncData({...this.testHero}));

  /* 변경사항을 병합한 테스트용 히어로의 복제본을 발행합니다 */
  saveHero = vi.fn((hero: Hero) => asyncData(Object.assign(this.testHero, hero)));
}

재정의한 서비스로 테스트하기

이제 테스트에서는 스파이 스텁의 testHero를 조작해서 컴포넌트의 히어로를 직접 제어하고 서비스 메서드가 호출되는지 확인할 수 있습니다.

let hdsSpy: HeroDetailServiceSpy;

beforeEach(async () => {
  harness = await RouterTestingHarness.create();
  component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetail);
  page = new Page();
  // 컴포넌트에 주입된 HeroDetailServiceSpy를 가져옵니다
  hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any;

  harness.detectChanges();
});

it('should have called `getHero`', () => {
  expect(hdsSpy.getHero, 'getHero called once').toHaveBeenCalledTimes(1);
});

it("should display stub hero's name", () => {
  expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);
});

it('should save stub hero change', async () => {
  const origName = hdsSpy.testHero.name;
  const newName = 'New Name';

  page.nameInput.value = newName;

  page.nameInput.dispatchEvent(new Event('input')); // Angular에 알립니다

  expect(component.hero.name, 'component hero has new name').toBe(newName);
  expect(hdsSpy.testHero.name, 'service hero unchanged before save').toBe(origName);

  click(page.saveBtn);
  expect(hdsSpy.saveHero, 'saveHero called once').toHaveBeenCalledTimes(1);

  await harness.fixture.whenStable();
  expect(hdsSpy.testHero.name, 'service hero has new name after save').toBe(newName);
  expect(TestBed.inject(Router).url).toEqual('/heroes');
});

다른 재정의 방법

TestBed.overrideComponent는 같은 컴포넌트나 서로 다른 컴포넌트에 여러 번 실행할 수 있습니다. TestBed는 다른 클래스의 내부 설정을 찾아 교체하는 overrideDirective, overrideModule, overridePipe 메서드도 제공합니다.

여러 옵션과 조합을 직접 살펴보세요.