とんかつ時々あんどーなつ

〜たとえ低空でも飛行していられるように〜

Angular アプリケーションに Testing Library を導入する

はじめに

これは Angular Advent Calendar 2022 1 日目の記事です。

今回は Angular アプリケーションに Testing Library を導入してみたので紹介します。

Testing Library とは

Testing Library | Testing Library

ユーザーがページ上で要素を見つけるのと同じように DOM node を見つけ操作することを可能にするライブラリです。つまり、実際にユーザーがアプリケーションを使うようにテストをすることができるということです。

また、Testing Library 自体は Angular 専用ではなく多くのフレームワークで使用することができます。

導入方法はパッケージをインストールするだけなので、公式ページを参照することをオススメします。Angular Testing Library | Testing Library

検証用のアプリケーションを用意

今回は Tour of Heroes - download example の完成形となるコードをダウンロードして使用しています。

この記事で対象にしたコンポーネントのコードも示しておきます。

// src/app/dashboard/dashboard.component.ts

import { Component, OnInit } from '@angular/core';
import { Hero } from '../hero';
import { HeroService } from '../hero.service';

@Component({
  selector: 'app-dashboard',
  templateUrl: './dashboard.component.html',
  styleUrls: [ './dashboard.component.css' ]
})
export class DashboardComponent implements OnInit {
  heroes: Hero[] = [];

  constructor(private heroService: HeroService) { }

  ngOnInit(): void {
    this.getHeroes();
  }

  getHeroes(): void {
    this.heroService.getHeroes()
      .subscribe(heroes => this.heroes = heroes.slice(1, 5));
  }
}
<!-- src/app/dashboard/dashboard.component.html -->

<h2>Top Heroes</h2>
<div class="heroes-menu">
  <a *ngFor="let hero of heroes"
      routerLink="/detail/{{hero.id}}">
      {{hero.name}}
  </a>
</div>

<app-hero-search></app-hero-search>

既存テストの確認

// src/app/dashboard/dashboard.component.spec.ts

import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs';

import { HeroSearchComponent } from '../hero-search/hero-search.component';
import { HeroService } from '../hero.service';
import { HEROES } from '../mock-heroes';

import { DashboardComponent } from './dashboard.component';

describe('DashboardComponent', () => {
  let component: DashboardComponent;
  let fixture: ComponentFixture<DashboardComponent>;
  let heroService;
  let getHeroesSpy: jasmine.Spy;

  beforeEach(waitForAsync(() => {
    heroService = jasmine.createSpyObj('HeroService', ['getHeroes']);
    getHeroesSpy = heroService.getHeroes.and.returnValue(of(HEROES));
    TestBed
        .configureTestingModule({
          declarations: [DashboardComponent, HeroSearchComponent],
          imports: [RouterTestingModule.withRoutes([])],
          providers: [{provide: HeroService, useValue: heroService}]
        })
        .compileComponents();

    fixture = TestBed.createComponent(DashboardComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  }));

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

  it('should display "Top Heroes" as headline', () => {
    expect(fixture.nativeElement.querySelector('h2').textContent).toEqual('Top Heroes');
  });

  it('should call heroService', waitForAsync(() => {
       expect(getHeroesSpy.calls.any()).toBe(true);
     }));

  it('should display 4 links', waitForAsync(() => {
       expect(fixture.nativeElement.querySelectorAll('a').length).toEqual(4);
     }));
});

既存のコードでは以下 4 つのテストケースがあります。

  • コンポーネントが作成されていること
  • h2 タグで "Top Heroes" が表示されていること
  • HeroService::getHeroes() が呼ばれていること
  • a タグが 4 つあること

Testing Library で書き換える

事前準備

テストをおこなうには、まず対象のコンポーネントレンダリングする必要があり、Testing Library では render() を使用します。 対象のコンポーネントが依存しているサービスや子コンポーネントがあるなら TestBed.configureTestingModule() に渡すように render() の第 2 引数としてオブジェクトで渡すことができます。

また render() の返り値には DOM ノードが RenderResult として返り fixture などを取り出すことができます。

  let component: DashboardComponent;
  let fixture: ComponentFixture<DashboardComponent>;
  const heroService = jasmine.createSpyObj('HeroService', ['getHeroes']);
  const getHeroesSpy = heroService.getHeroes.and.returnValue(of(HEROES));

  beforeEach(async () => {
    const renderResult = await render(DashboardComponent, {
      declarations: [HeroSearchComponent],
      providers: [{ provide: HeroService, useValue: heroService }],
    });

    fixture = renderResult.fixture;
    component = fixture.componentInstance;
  });
});

Angular のテスト用 API である TestBed を使わず記述できるようになるのが特徴です。

テストケースの作成

コンポーネントが作成されていること

render() の返り値から componentInstance を取り出せるため、このテストケースは特に変更なく、そのままとなります。

h2 タグで "Top Heroes" が表示されていること

もともとのテストケースを再掲します。

it('should display "Top Heroes" as headline', () => {
  expect(fixture.nativeElement.querySelector('h2').textContent).toEqual('Top Heroes');
});

Testing Library では role を指定することで対象の要素を見つけることができます。 画面上から 'Top Heroes' と書かれた heading role を探し、その存在を確認するテストケースが以下になります。

it('should display "Top Heroes" as headline', () => {
  expect(screen.getByRole('heading', { name: 'Top Heroes' })).toBeTruthy();
});

今回は getByRole() を使っていますが、どのメソッドを使うのが適切かわからない場合は、まず getByText() を使って対象のテキストがあるかを探します。

it('should display "Top Heroes" as headline', () => {
  expect(screen.getByText('Top Heroes')).toBeTruthy();
});

各メソッドの第 2 引数には MatcherOptions を渡すことができますが、その際に suggest を指定することでよりよいメソッドが提案されます。

it('should display "Top Heroes" as headline', () => {
  expect(screen.getByText('Top Heroes', { suggest: true })).toBeTruthy();
});

これを使って積極的によりよいメソッドを使えると良さそうです。

HeroService::getHeroes() が呼ばれていること

これは Testing Library を使っても変わるところがないので割愛します。

a タグが 4 つあること

既存のテストケースは a タグが 4 つあることを確認しています。 このままテストしてもいいですが、どのような a タグなのかを示せたほうがテストケースとしてはより有用だと考えられます。

今回の場合は「Bombasto, Celeritas, Magneta, RubberMan と書かれたリンクがあること」をそれぞれ確認するテストケースに変更します。

it('should display 4 links', () => {
  expect(screen.getByRole('link', { name: /Bombasto/i })).toBeTruthy();
  expect(screen.getByRole('link', { name: /Celeritas/i })).toBeTruthy();
  expect(screen.getByRole('link', { name: /Magneta/i })).toBeTruthy();
  expect(screen.getByRole('link', { name: /RubberMan/i })).toBeTruthy();
});

このようなテストケースも簡単にかけます。

まとめ

今回は導入ということで、簡単なコンポーネントについてテストを書いてみましたが、比較的直感的に書けることがわかりました。 後回しにしがちな view のテストも書いていきたい気持ちになったのではないでしょうか?

明日は nontangent さんです!