심화 가이드
테스트

컴포넌트 하네스 만들기

시작하기 전에

팁: 이 문서는 컴포넌트 하네스 개요를 읽었다고 가정합니다. 컴포넌트 하네스를 처음 사용한다면 먼저 해당 문서를 읽어보세요.

테스트 하네스를 만들면 좋은 경우

Angular 팀은 여러 곳에서 사용하고 사용자와 상호작용하는 공유 컴포넌트에 테스트 하네스를 만드는 것을 권장합니다. 위젯 라이브러리나 이와 비슷한 재사용 컴포넌트가 주로 해당합니다. 하네스는 공유 컴포넌트 사용자에게 컴포넌트와 상호작용할 수 있는 안정적으로 지원되는 API를 제공합니다. 하네스로 테스트하면 DOM 구조나 특정 이벤트 리스너처럼 바뀔 수 있는 공유 컴포넌트의 내부 구현에 의존하지 않아도 됩니다.

애플리케이션의 페이지처럼 한 곳에서만 사용하는 컴포넌트는 하네스의 이점이 크지 않습니다. 이런 경우에는 테스트와 컴포넌트를 함께 수정하므로 테스트가 컴포넌트의 내부 구현에 의존해도 무리가 없습니다. 다만 단위 테스트와 E2E 테스트에서 하네스를 함께 사용한다면 여전히 도움이 됩니다.

CDK 설치하기

Component Dev Kit(CDK)는 컴포넌트의 동작을 구현하는 기본 요소 모음입니다. 컴포넌트 하네스를 사용하려면 먼저 npm에서 @angular/cdk를 설치해야 합니다. 터미널에서 Angular CLI로 설치할 수 있습니다.

ng add @angular/cdk

ComponentHarness 상속하기

추상 클래스 ComponentHarness는 모든 컴포넌트 하네스의 기본 클래스입니다. 커스텀 컴포넌트 하네스를 만들려면 ComponentHarness를 상속하고 hostSelector 정적 프로퍼티를 구현하세요.

hostSelector 프로퍼티는 이 하네스 하위 클래스에 대응하는 DOM 엘리먼트를 식별합니다. 대부분의 경우 해당 Component나 Directive의 셀렉터와 같은 값을 사용하면 됩니다. 간단한 팝업 컴포넌트를 예로 살펴봅시다.

@Component({
  selector: 'my-popup',
  template: `
    <button (click)="toggle()">{{ triggerText() }}</button>
    @if (isOpen()) {
      <div class="my-popup-content"><ng-content /></div>
    }
  `,
})
class MyPopup {
  triggerText = input('');

  isOpen = signal(false);

  toggle() {
    this.isOpen.update((value) => !value);
  }
}

이 컴포넌트의 최소한의 하네스는 다음과 같습니다.

class MyPopupHarness extends ComponentHarness {
  static hostSelector = 'my-popup';
}

ComponentHarness 하위 클래스에서 반드시 필요한 것은 hostSelector 프로퍼티뿐이지만, 대부분의 하네스에는 HarnessPredicate 인스턴스를 생성하는 with 정적 메서드도 구현하는 것이 좋습니다. 자세한 내용은 하네스 필터링하기를 참고하세요.

컴포넌트 DOM에서 엘리먼트 찾기

ComponentHarness 하위 클래스의 각 인스턴스는 해당 컴포넌트의 특정 인스턴스를 나타냅니다. 기본 클래스 ComponentHarness의 host() 메서드로 컴포넌트의 호스트 엘리먼트에 접근할 수 있습니다.

ComponentHarness는 컴포넌트 DOM 안에서 엘리먼트를 찾는 locatorFor(), locatorForOptional(), locatorForAll() 메서드도 제공합니다. 이 메서드들은 엘리먼트를 직접 찾는 대신 엘리먼트를 찾는 함수를 만듭니다. 이 방식은 더 이상 유효하지 않은 엘리먼트 참조가 캐싱되는 것을 방지합니다. 예를 들어 @if 블록이 엘리먼트를 숨겼다가 다시 표시하면 새로운 DOM 엘리먼트가 생성됩니다. 함수를 사용하면 테스트가 항상 현재 DOM 상태를 참조할 수 있습니다.

각 locatorFor 메서드의 전체 목록과 자세한 내용은 ComponentHarness API 문서를 참고하세요.

앞에서 살펴본 MyPopupHarness에 트리거 엘리먼트와 내용 엘리먼트를 가져오는 메서드를 추가하면 다음과 같습니다.

class MyPopupHarness extends ComponentHarness {
  static hostSelector = 'my-popup';

  // 트리거 엘리먼트를 가져옵니다
  getTriggerElement = this.locatorFor('button');

  // 내용 엘리먼트를 가져옵니다.
  getContentElement = this.locatorForOptional('.my-popup-content');
}

TestElement 인스턴스 사용하기

TestElement는 단위 테스트, WebDriver 등 여러 테스트 환경에서 동작하도록 만든 추상화입니다. 하네스를 사용할 때는 이 인터페이스로만 DOM과 상호작용해야 합니다. document.querySelector()처럼 DOM 엘리먼트에 접근하는 다른 방법은 모든 테스트 환경에서 동작하지 않습니다.

TestElement는 blur(), click(), getAttribute() 등 DOM과 상호작용하는 여러 메서드를 제공합니다. 전체 메서드는 TestElement API 문서를 참고하세요.

컴포넌트의 호스트 엘리먼트처럼 컴포넌트 사용자가 직접 정의한 엘리먼트가 아니라면 하네스 사용자에게 TestElement 인스턴스를 공개하지 마세요. 내부 엘리먼트의 TestElement 인스턴스를 공개하면 사용자가 컴포넌트 내부 DOM 구조에 의존하게 됩니다.

대신 사용자가 수행할 특정 동작이나 확인할 특정 상태에 초점을 맞춘 메서드를 제공하세요. 예를 들어 앞에서 살펴본 MyPopupHarness에는 toggle, isOpen 같은 메서드를 제공할 수 있습니다.

class MyPopupHarness extends ComponentHarness {
  static hostSelector = 'my-popup';

  protected getTriggerElement = this.locatorFor('button');
  protected getContentElement = this.locatorForOptional('.my-popup-content');

  /** 팝업의 열림 상태를 전환합니다. */
  async toggle() {
    const trigger = await this.getTriggerElement();
    return trigger.click();
  }

  /** 팝업이 열려 있는지 확인합니다. */
  async isOpen() {
    const content = await this.getContentElement();
    return !!content;
  }
}

하위 컴포넌트 하네스 로드하기

큰 컴포넌트는 여러 하위 컴포넌트로 구성되는 경우가 많습니다. 컴포넌트 하네스에도 이 구조를 반영할 수 있습니다. ComponentHarness의 각 locatorFor 메서드는 엘리먼트 대신 하위 하네스를 찾는 시그니처도 제공합니다.

각 locatorFor 메서드의 전체 목록은 ComponentHarness API 문서를 참고하세요.

위에서 살펴본 팝업으로 메뉴를 구성한 예제를 보겠습니다.

@Directive({
  selector: 'my-menu-item',
})
class MyMenuItem {}

@Component({
  selector: 'my-menu',
  template: `
    <my-popup>
      <ng-content />
    </my-popup>
  `,
})
class MyMenu {
  triggerText = input('');

  items = contentChildren(MyMenuItem);
}

그러면 MyMenu 하네스는 MyPopup, MyMenuItem의 하네스를 활용할 수 있습니다.

class MyMenuHarness extends ComponentHarness {
  static hostSelector = 'my-menu';

  protected getPopupHarness = this.locatorFor(MyPopupHarness);

  /** 현재 표시된 메뉴 항목을 가져옵니다. 메뉴가 닫혀 있으면 빈 목록을 반환합니다. */
  getItems = this.locatorForAll(MyMenuItemHarness);

  /** 메뉴의 열림 상태를 전환합니다. */
  async toggle() {
    const popupHarness = await this.getPopupHarness();
    return popupHarness.toggle();
  }
}

class MyMenuItemHarness extends ComponentHarness {
  static hostSelector = 'my-menu-item';
}

HarnessPredicate로 하네스 인스턴스 필터링하기

페이지에 같은 컴포넌트의 인스턴스가 여러 개 있으면 컴포넌트 프로퍼티로 필터링해서 특정 인스턴스를 가져와야 할 수 있습니다. 특정 텍스트가 있는 버튼이나 특정 ID를 가진 메뉴를 찾는 경우가 예입니다. HarnessPredicate 클래스는 ComponentHarness 하위 클래스에 이런 조건을 지정할 수 있습니다. 테스트 작성자가 HarnessPredicate 인스턴스를 직접 만들 수도 있지만, ComponentHarness 하위 클래스에서 자주 사용하는 필터의 조건식을 생성하는 헬퍼 메서드를 제공하면 더 편리합니다.

각 ComponentHarness 하위 클래스에 해당 클래스의 HarnessPredicate를 반환하는 with() 정적 메서드를 만드세요. 그러면 테스트 작성자는 loader.getHarness(MyMenuHarness.with({selector: '#menu1'}))처럼 이해하기 쉬운 코드를 작성할 수 있습니다. with 메서드에는 기본 selector, ancestor 옵션 외에도 해당 하위 클래스에 유용한 옵션을 추가하는 것이 좋습니다.

옵션을 추가해야 하는 하네스는 BaseHarnessFilters 인터페이스를 상속하고 필요한 선택적 프로퍼티를 추가하세요. HarnessPredicate는 stringMatches(), addOption(), add()처럼 옵션 추가를 돕는 메서드를 제공합니다. 자세한 내용은 HarnessPredicate API 문서를 참고하세요.

예를 들어 메뉴를 다룰 때는 트리거 텍스트로 메뉴를 필터링하고, 항목의 텍스트로 메뉴 항목을 필터링하면 유용합니다.

interface MyMenuHarnessFilters extends BaseHarnessFilters {
  /** 메뉴의 트리거 텍스트로 필터링합니다. */
  triggerText?: string | RegExp;
}

interface MyMenuItemHarnessFilters extends BaseHarnessFilters {
  /** 메뉴 항목의 텍스트로 필터링합니다. */
  text?: string | RegExp;
}

class MyMenuHarness extends ComponentHarness {
  static hostSelector = 'my-menu';

  /** 특정 `MyMenuHarness`를 찾는 `HarnessPredicate`를 생성합니다. */
  static with(options: MyMenuHarnessFilters): HarnessPredicate<MyMenuHarness> {
    return new HarnessPredicate(MyMenuHarness, options).addOption(
      'trigger text',
      options.triggerText,
      (harness, text) => HarnessPredicate.stringMatches(harness.getTriggerText(), text),
    );
  }

  protected getPopupHarness = this.locatorFor(MyPopupHarness);

  /** 메뉴 트리거의 텍스트를 가져옵니다. */
  async getTriggerText(): Promise<string> {
    const popupHarness = await this.getPopupHarness();
    return popupHarness.getTriggerText();
  }
}

class MyMenuItemHarness extends ComponentHarness {
  static hostSelector = 'my-menu-item';

  /** 특정 `MyMenuItemHarness`를 찾는 `HarnessPredicate`를 생성합니다. */
  static with(options: MyMenuItemHarnessFilters): HarnessPredicate<MyMenuItemHarness> {
    return new HarnessPredicate(MyMenuItemHarness, options).addOption(
      'text',
      options.text,
      (harness, text) => HarnessPredicate.stringMatches(harness.getText(), text),
    );
  }

  /** 메뉴 항목의 텍스트를 가져옵니다. */
  async getText(): Promise<string> {
    const host = await this.host();
    return host.text();
  }
}

HarnessLoader, LocatorFactory, ComponentHarness의 모든 API에 ComponentHarness 클래스 대신 HarnessPredicate를 전달할 수 있습니다. 따라서 테스트 작성자는 특정 컴포넌트 인스턴스를 대상으로 하네스 인스턴스를 쉽게 생성할 수 있습니다. 하네스 작성자도 같은 HarnessPredicate를 활용해서 하네스 클래스에 더 강력한 API를 제공할 수 있습니다. 앞의 MyMenuHarness에 있는 getItems 메서드를 예로 살펴봅시다. 필터 API를 추가하면 하네스 사용자가 특정 메뉴 항목을 검색할 수 있습니다.

class MyMenuHarness extends ComponentHarness {
  static hostSelector = 'my-menu';

  /** 메뉴 항목 목록을 가져옵니다. 선택적으로 주어진 조건에 따라 필터링합니다. */
  async getItems(filters: MyMenuItemHarnessFilters = {}): Promise<MyMenuItemHarness[]> {
    const getFilteredItems = this.locatorForAll(MyMenuItemHarness.with(filters));
    return getFilteredItems();
  }
  ...
}

콘텐츠 프로젝션을 사용하는 엘리먼트의 HarnessLoader 만들기

일부 컴포넌트는 추가 콘텐츠를 컴포넌트 템플릿 안에 투영합니다. 자세한 내용은 콘텐츠 프로젝션 문서를 참고하세요.

콘텐츠 프로젝션을 사용하는 컴포넌트의 하네스를 만들 때는 <ng-content>가 포함된 엘리먼트를 범위로 하는 HarnessLoader 인스턴스를 추가하세요. 그러면 하네스 사용자는 콘텐츠로 전달한 컴포넌트의 하네스도 로드할 수 있습니다. ComponentHarness는 이런 경우에 HarnessLoader 인스턴스를 만드는 harnessLoaderFor(), harnessLoaderForOptional(), harnessLoaderForAll() 메서드를 제공합니다. 자세한 내용은 HarnessLoader 인터페이스 API 문서를 참고하세요.

예를 들어 앞의 MyPopupHarness가 ContentContainerComponentHarness를 상속하면 컴포넌트의 <ng-content> 안에 있는 하네스를 로드하도록 지원할 수 있습니다.

class MyPopupHarness extends ContentContainerComponentHarness<string> {
  static hostSelector = 'my-popup';
}

컴포넌트 호스트 엘리먼트 바깥에 접근하기

컴포넌트 하네스가 해당 컴포넌트의 호스트 엘리먼트 바깥에 접근해야 할 때도 있습니다. 예를 들어 떠 있는 엘리먼트나 팝업을 표시하는 코드는 DOM 엘리먼트를 문서의 body에 직접 추가하는 경우가 많습니다. Angular CDK의 Overlay 서비스가 이에 해당합니다.

이런 경우를 위해 ComponentHarness는 문서의 최상위 엘리먼트에 대한 LocatorFactory를 가져오는 메서드를 제공합니다. LocatorFactory는 기본 클래스 ComponentHarness와 거의 같은 API를 지원하며, 문서의 최상위 엘리먼트를 기준으로 쿼리할 수 있습니다.

위의 MyPopup 컴포넌트가 자체 템플릿의 엘리먼트 대신 CDK 오버레이로 팝업 내용을 표시한다고 가정해 봅시다. 이 경우 MyPopupHarness는 문서 최상위를 기준으로 하는 로케이터 팩토리를 가져오는 documentRootLocatorFactory() 메서드로 내용 엘리먼트에 접근해야 합니다.

class MyPopupHarness extends ComponentHarness {
  static hostSelector = 'my-popup';

  /** 팝업의 내용 엘리먼트를 최상위로 하는 `HarnessLoader`를 가져옵니다. */
  async getHarnessLoaderForContent(): Promise<HarnessLoader> {
    const rootLocator = this.documentRootLocatorFactory();
    return rootLocator.harnessLoaderFor('my-popup-content');
  }
}

비동기 작업 기다리기

TestElement의 메서드는 Angular 변경 감지를 자동으로 실행하고 NgZone 안의 작업을 기다립니다. 대부분의 경우 하네스 작성자가 비동기 작업을 기다리기 위해 별도로 처리할 것은 없습니다. 하지만 이것만으로 부족한 예외적인 경우도 있습니다.

상황에 따라 Angular 애니메이션 이벤트를 모두 처리하려면 변경 감지를 한 번 더 실행하고 이후 NgZone 안정화까지 기다려야 할 수 있습니다. 이런 경우에는 ComponentHarness의 forceStabilize() 메서드로 두 번째 과정을 실행할 수 있습니다.

NgZone.runOutsideAngular()로 NgZone 바깥에 작업을 예약할 수도 있습니다. NgZone 바깥의 작업은 자동으로 기다리지 않으므로, 명시적으로 기다려야 한다면 해당 하네스의 waitForTasksOutsideAngular() 메서드를 실행하세요.