はじめに

Angular Advent Calendar 2021 20日目の記事です。 前回は @nontangent さんの [SCSS] Host Scoped Custom Property でした。

Angular で setTimeout / Promise / Observable などの非同期処理を扱った時、なんだか良くわからないまま呪文のように fakeAsync や tick を使ってテストを通す事はありませんか?この記事では、そんな非同期処理のテストについて掘り下げた内容を紹介したいと思います。

試した環境

  • Angular 13.1
  • Node 16.10
  • Jasmine 3.10

この記事のサンプルコードは GitHub リポジトリに保存しています。
https://github.com/ringtail003/blog-angular-async-testing/tree/main/src/app/tests

テスト対象のサービス

今回の記事ではテスト対象としてこのようなサービスを使用します。メソッドが呼ばれるとカウンター内部の変数がインクリメントする単純なサービスです。

import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class CounterService {
  value = 0;
  
  countUpBySetTimeout(ms: number): void {
    setTimeout(() => this.value++, ms);
  }
}

まず初めに setTimeout を使って、メソッドを記述しました。

setTimeout

それではテストを書いてみましょう。まず fakeAsync を使わずにテストを書いた場合です。このテストは失敗します。

it('落ちるテスト:0ミリ秒後の非同期処理', () => {
  const sut = TestBed.inject(CounterService);
  sut.countUpBySetTimeout(0);

  // Error: Expected 0 to be 1.
  expect(sut.value).toBe(1);
});

なぜならテストが同期的に実行され expect が評価されるタイミングでは、非同期処理の setTimeout のコールバックは「未実行」な状態であり、変数は 0 のまま変化が加えられていないからです。

ここで登場するのが fakeAsync と tick です。

import { fakeAsync, tick } from '@angular/core/testing';

it('0ミリ秒後の非同期処理', fakeAsync(() => {
  const sut = TestBed.inject(CounterService);
  sut.countUpBySetTimeout(0);
  tick();
  
  // PASS
  expect(sut.value).toBe(1);
}));

fakeAsync は setTimeout のような非同期処理をラップし、tick によって内部のタイマーを進めます。setTimeout に 5 ミリ秒の遅延を指定した場合は、tick にも 5 ミリ秒タイマーを進めるよう指定します。

sut.countUpBySetTimeout(5);
tick(5);
expect(sut.value).toBe(1);

Promise

次に Promise を使った非同期処理について見てみましょう。サービスを拡張してメソッドを追加します。

export class CounterService {
  value = 0;

  countUpByPromise(): void {
    Promise.resolve().then(() => this.value++);
  }
}

fakeAsync を使わずにテストを書いた場合、テストは失敗します。

it('落ちるテスト:resolve後の非同期処理', () => {
  sut.countUpByPromise();
  
  // Error: Expected 0 to be 1.
  expect(sut.value).toBe(1);
});

Promise も setTimeout と同じように非同期処理であり、expect が評価されるタイミングでは then のコールバックの処理が「未実行」な状態です。こちらも fakeAsync と tick を使えばテストが通るようになります。

it('resolve後の非同期処理', fakeAsync(() => {
  const sut = TestBed.inject(CounterService);
  sut.countUpByPromise();
  tick();
  
  // PASS
  expect(sut.value).toBe(1);
}));

Observable

次に Observable を使った非同期処理について見てみましょう。Promise と同じようにサービスを拡張してメソッドを追加します。

Observable は内部で持っているスケジューラの種類により、同期処理・非同期処理のどちらで実行されるかが分かれます。今回の例では非同期処理で実行されるよう asyncScheduler を指定しておきます。

import { asyncScheduler, observeOn, Subject } from 'rxjs';

export class CounterService {
  value = 0;
  
  countUpByObservable(): void {
    const subject$ = new Subject();
    
    subject$.pipe(observeOn(asyncScheduler)).subscribe({
      complete: () => this.value++,
    });

    subject$.complete();
  }
}

fakeAsync を使わずにテストを書いた場合、テストは失敗します。expect が実行されたタイミングでは、まだ subscribe のコールバックの処理が「未実行」な状態のためです。

it('落ちるテスト:complete後の非同期処理', () => {
  sut.countUpByObservable();
  
  // Error: Expected 0 to be 1.
  expect(sut.value).toBe(1);
});

asyncScheduler を指定した Observable も fakeAsync と tick を使えばテストが通るようになります。

it('complete後の非同期処理', fakeAsync(() => {
  const sut = TestBed.inject(CounterService);
  sut.countUpByObservable();
  tick();
  
  // PASS
  expect(sut.value).toBe(1);
}));

fakeAsync

fakeAsync は Zone.js を使って特別な「fakeAsync ゾーン」というものを生成します。ゾーンの中で実行される setTimeout や Promise などの非同期処理はラップされ Zone.js の管理下に置かれます。

it('...', fakeAsync(() => {
  setTimeout(); // ===> Zone.js の setTimeout に置き換わる
  setInterval(); // ===> Zone.js の setInterval に置き換わる
  Promise; // ===> Zone.js の Promise に置き換わる
}));

この時、ゾーンの中でタイマーを進める役割を担っているのが tick です。そのため fakeAsync ゾーンの外で tick を使用する事はできません。

it('落ちるテスト:tickはゾーン外では呼び出せない', () => {
  // Error: The code should be running in the fakeAsync zone to call this function
  tick();
});

queueMicrotask

setTimeout / Promise 以外にも、非同期処理を発生させる JavaScript ネイティブの queueMicrotask という関数があります。こちらも前述のテストコードと同じように、fakeAsync と tick を使う事でテストを通す事ができます。

export class CounterService {
  value = 0;

  countUpByQueueMicrotask(): void {
    queueMicrotask(() => this.value++);
  }
}
it('queueMicrotask後の非同期処理', fakeAsync(() => {
  const sut = TestBed.inject(CounterService);
  sut.countUpByQueueMicrotask();
  tick();
  
  // PASS
  expect(sut.value).toBe(1);
}));

queueMicrotask は「マイクロタスク」と呼ばれるキューに何らかの処理をエンキューするための関数です。理解を深めるためにキューについてちょっとだけ深追いしてみましょう。

task queue / microtask queue

JavaScript の世界では「タスクキュー」「マイクロタスクキュー」という非同期処理のためのキューが存在します。

同期処理はどこにもエンキューされず、即時実行されます。

console.log('sync');
// > 'sync'

Promise がエンキューされるのは「マイクロタスクキュー」です。

Promise.resolve().then(() => console.log('promise'));
// > 'promise'

setTimeout / setInterval がエンキューされるのは「タスクキュー」です。

setTimeout(() => console.log('setTimeout'), 0);
// > 'setTimeout'

遅延のための非同期処理を記述するには setTimeout を使う場面が多いと思いますが queueMicrotask を使うと意図的に「マイクロタスクキュー」にエンキューする事ができます。

queueMicrotask(() => console.log('queue microtask'));
// > 'queue microtask'

「マイクロタスクキュー」「タスクキュー」それぞれのエンキューは、実行順に明らかな違いが現れます。

it('タスクキューとマイクロタスクキューの実行順を確認する', () => {
  // タスクキューにエンキュー
  setTimeout(() => console.log('setTimeout'));
  
  // マイクロタスクキューにエンキュー
  Promise.resolve().then(() => console.log('promise'));
  queueMicrotask(() => console.log('queue microtask'));
  
  // キューに入れない
  console.log('sync');
});

上記のコードは記述順とは異なり、下記のように出力されます。

// 結果
// > 'sync'(キューに入れない)
// > 'promise'(マイクロタスクキュー)
// > 'queue microtask'(マイクロタスクキュー)
// > 'setTimeout'(タスクキュー)

JavaScript では、イベントループをトリガにしてメインスレッドで 同期処理 が即時実行されます。'sync' はここで出力されます。

メインスレッドにそれ以上実行するものがなくなった時、マイクロタスクキュー に積まれたタスクが取り出され実行されます。マイクロタスクキューはキューが空になるまで取り出しと実行が繰り返し行われます。 'promise' 'queue microtask' はここで出力されます。

マイクロタスクキューが空になると、タスクキュー に積まれたタスクが取り出され実行されます。'setTimeout' はここで出力されます。ミリ秒指定が 0 であっても、記述順が先であっても、マイクロタスクキューより先に実行される事はありません。

※タスクを取り出して実行する事を、以下「消化」と記載します。

flushMicrotasks

tick を呼び出すと、タイマーが進みマイクロタスクキュー/タスクキューの両方が消化されます。

it('tickによってマイクロタスクキューとタスクキューを実行する', fakeAsync(() => {
  const sut = TestBed.inject(CounterService);
  
  // タスクキュー
  sut.countUpBySetTimeout(0);
  
  // マイクロタスクキュー
  sut.countUpByPromise();
  
  // マイクロタスクキュー/タスクキューを消化
  tick();
  
  // PASS
  expect(sut.value).toBe(2);
}));

マイクロタスクキューのみ消化したい時は flushMicrotasks を使います。flushMicrotasks は tick と同じように「fakeAsync ゾーン」の中で使用します。

import { flushMicrotasks } from '@angular/core/testing';

it('resolve後の非同期処理', fakeAsync(() => {
  const sut = TestBed.inject(CounterService);
  
  // マイクロタスクキュー
  sut.countUpByPromise();
  
  // マイクロタスクキューを消化
  flushMicrotasks();
  
  // PASS
  expect(sut.value).toBe(1);
}));

flushMicrotasks ではマイクロタスクキューのみ消化され、タスクキューは消化されません。

it('落ちるテスト:resolve/setTimeout後の非同期処理', fakeAsync(() => {
  const sut = TestBed.inject(CounterService);
  
  // マイクロタスクキュー(実行される)
  sut.countUpByPromise();
  
  // タスクキュー(実行されない)
  sut.countUpBySetTimeout(0);
  
  // マイクロタスクキューを消化
  flushMicrotasks();
  
  // Error: Expected 1 to be 2.
  expect(sut.value).toBe(2);
  
  // キューにタスクが残っているとエラーになるため消化
  tick();
}));

flush

タスクキューを消化したい時は flush を使います。flush は tick / flushMicrotasks と同じように「fakeAsync ゾーン」の中で使用します。

import { flush } from '@angular/core/testing';

it('setTimeout後の非同期処理', fakeAsync(() => {
  const sut = TestBed.inject(CounterService);
  sut.countUpBySetTimeout(0);

  flush();
  
  // PASS
  expect(sut.value).toBe(1);
}));

タスクキューはより優先度の高い「マイクロタスクキュー」が空になった状態で実行されます。そのため Promise がマイクロタスクキューにエンキューされている場合、まずマイクロタスクキューが消化され、その次にタスクキューが消化されます。

it('flushによってマイクロタスクキューも実行される', fakeAsync(() => {
  const sut = TestBed.inject(CounterService);
  
  // マイクロタスクキュー
  sut.countUpByPromise();
  
  // タスクキュー
  sut.countUpBySetTimeout(0);

  // マイクロタスクキューを消化してからタスクキューを消化
  flush();
  
  // PASS
  expect(sut.value).toBe(2);
}));

tick

もしタスクキューへのエンキューがネストしている場合はどうなるでしょうか?

デフォルトの動作では、ネストしたタスクキューも全て消化されます。

it('ネストしたタスクキューを実行する(デフォルト)', fakeAsync(() => {
  const sut = TestBed.inject(CounterService);
  
  // 0ミリ秒後にエンキューされる
  setTimeout(() => {
    // countUpBySetTimeout内のsetTimeoutによって0ミリ秒後にエンキューされる
    sut.countUpBySetTimeout(0);
  });

  // ネストしたエンキューが実行されタスクキューが消化される
  tick();
  
  // PASS
  expect(sut.value).toBe(1);
}));

もしネストしたタスクを実行したくない場合は tick のオプションを指定する事で、この挙動を回避する事ができます。

it('ネストしたタスクキューを実行しない', fakeAsync(() => {
  const sut = TestBed.inject(CounterService);
  setTimeout(() => {
    sut.countUpBySetTimeout(0);
  });

  tick(0, { processNewMacroTasksSynchronously: false });
  
  // PASS
  expect(sut.value).toBe(0);
  
  // キューにタスクが残っているとエラーになるため消化
  tick();
}));

Error: timer(s) still in the queue

非同期処理のテストを書いている時、このようなエラーメッセージに遭遇する事があります。

Error: 1 timer(s) still in the queue.
  at UserContext.fakeAsyncFn (node_modules/zone.js/fesm2015/zone-testing.js)
    at ZoneDelegate.invoke (node_modules/zone.js/fesm2015/zone.js)
      at ProxyZoneSpec.onInvoke (node_modules/zone.js/fesm2015/zone-testing.js)

このメッセージはマイクロタスクキュー/タスクキューに未実行のタスクが残ったままテストが終了した事を警告しています。

it('タスクキューに未実行タスクが残っている', fakeAsync(() => {
  const sut = TestBed.inject(CounterService);
  sut.countUpBySetTimeout(0);

  flush();
  expect(sut.value).toBe(1);
  
  // flushの後にタスクキューにエンキュー
  sut.countUpBySetTimeout(0);
}));

// fakeAsyncゾーンの終了時に残タスクがある
// Error: 1 timer(s) still in the queue.

このエラーはテストの最後で tick や flush などを使ってタスクを消化する事で解決します。

it('...', fakeAsync(() => {
  ...
  
  tick();
}));

Error: periodic timer(s) still in the queue.

もうひとつ、このようなエラーメッセージに遭遇する事があります。

Error: 1 periodic timer(s) still in the queue.
  at UserContext.fakeAsyncFn (node_modules/zone.js/fesm2015/zone-testing.js)
    at ZoneDelegate.invoke (node_modules/zone.js/fesm2015/zone.js)
      at ProxyZoneSpec.onInvoke (node_modules/zone.js/fesm2015/zone-testing.js)

これは setInterval などを使って periodic(定期的)な非同期処理を実行している場合に、マイクロタスクキュー/タスクキューに未実行のタスクが残ったままテストが終了した事を警告しています。

export class CounterService {
  value = 0;

  countUpInterval(): void {
    setInterval(() => this.value++, 1000);
  }
}
it('タスクキューに未実行タスクが残っている', fakeAsync(() => {
  sut.countUpInterval();
  tick(1000);

  expect(sut.value).toBe(1);
  
  // tickの後にタスクキューにエンキューがスケジュールされる
}));

// fakeAsyncゾーンの終了時にスケジュールされたタスクがある
// Error: 1 periodic timer(s) still in the queue.

このエラーはテストの最後で discardPeriodicTasks を使ってそれ以降のタスクを破棄する事で解決します。

import { discardPeriodicTasks } from '@angular/core/testing';

it('...', fakeAsync(() => {
  ...
  
  discardPeriodicTasks();
}));

waitForAsync

これまではサービスを使って非同期処理のテストについて確認してきましたが、最後にコンポーネントのテストについても確認してみましょう。

Promise の解決を待って DOM にレンダリングするコンポーネントをテスト対象とします。

@Component({
  selector: 'fake',
  template: '{{value}}',
})
export class FakeComponent implements OnInit {
  value = 0;

  ngOnInit(): void {
    Promise.resolve().then(() => this.value++);
  }
}

このようなコンポーネントの場合、fakeAsync と tick を使って下記のようなテストコードを書く事ができます。

it('tickとdetectChangesを使う', fakeAsync(() => {
  TestBed
    .configureTestingModule({ declarations: [FakeComponent] })
    .compileComponents();

  fixture = TestBed.createComponent(FakeComponent);
  fixture.detectChanges();
  
  // マイクロタスクキュー/タスクキューの消化
  tick();
  
  // DOM を更新
  fixture.detectChanges();
  
  // PASS
  expect(fixture.debugElement.nativeElement.textContent).toBe('1');
}));

また waitForAsync/whenStable を使ってテストする事もできます。

import { waitForAsync } from '@angular/core/testing';

it('waitForAsyncを使う', waitForAsync(() => {
  TestBed
    .configureTestingModule({ declarations: [FakeComponent] })
    .compileComponents();

  fixture = TestBed.createComponent(FakeComponent);
  fixture.detectChanges();

  // 非同期処理の終了を待つ
  fixture.whenStable().then(() => {
    
    // DOM を更新
    fixture.detectChanges();
    
    // PASS
    expect(fixture.debugElement.nativeElement.textContent).toBe('1');
  });
}));

おわりに

いかがでしたでしょうか。私と同じように呪文のように fakeAsync や tick を書いていた方にとって、ほんの少しでも理解の助けとなれば幸いです。

弊社ではフロントエンドのフレームワークに Angular を採用しています。今年は普段バックエンドを担当しているエンジニアも含め、4 人が Anagular Advent Calendar に参加しました。ぼっち参加だと思っていた私にとって、とてもとても嬉しい出来事でした!

今年の Advent Calendar も残すところあと数日となりました。明日は @FumioYoshida さんの投稿です!盛り上がっていきましょう!