170
119

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

AngularAdvent Calendar 2016

Day 3

AngularとZone.jsとテストの話

Last updated at Posted at 2016-12-03

AngularとZone.jsとテストの話

どうも @Quramy です。このエントリはAngular Advent Calendar の3日目向けに書いています。

はじめに

Angularにおいて、Componentのテストコードは少し癖が強いですね。少なくとも僕はそう感じました。

次のComponentを例としてみましょう。ngModel を使って name プロパティをバインディングしただけのシンプルなComponent です。

src/app/my-form/my-form.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-my-form',
  template: `<input [(ngModel)]="name">`
})
export class MyFormComponent  {
  name: string = '';
}

このComponent に対して、「name プロパティが変更されたら、<input>タグのvalue値へ値が反映されていること」を検証するJasmineのテストコードを書いてみましょう。次のようになるはずです。
なお、手元で試す場合は angular-cli を使うと良いです。僕も angular-cli で検証用のプロジェクトを作成しながら、このエントリを書いています。

src/app/my-form/my-form.component.spec.ts
/* tslint:disable:no-unused-variable */
import { inject, async, ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';

import { MyFormComponent } from './my-form.component';

describe('MyFormComponent', () => {
  let component: MyFormComponent;
  let fixture: ComponentFixture<MyFormComponent>;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [FormsModule],
      declarations: [ MyFormComponent ]
    })
    .compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(MyFormComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should display name property into it\'s input element', () => {
    component.name = 'Quramy';
    fixture.detectChanges();
    const inputElm = fixture.debugElement.query(By.css('input')).nativeElement as HTMLInputElement;
    expect(inputElm.value).toEqual('Quramy', 'Input value for name element');
  });

});
  1. Component の状態を操作する
  2. detectChanges メソッドの実行により、Componentの変更検知を実行してDOMを最新の状態にする
  3. DOMを検証する

という典型的なAngularのテストコードの作法に従っているのに、実行してみると下記のようなメッセージとともにkarmaの出力が真っ赤になってしまいます。悲しい。

MyFormComponent should display name property into it's input element FAILED
    Expected '' to equal 'Quramy', 'Input value for name element'.

karma-chromeのdebug画面を開いて目で確認してみると、ちゃんとinputタグに specで指定した 'Quramy' が含まれてるのにも関わらず、です。
こんな簡単なテストすら通せないなんて、不甲斐無さで胸がいっぱいになります。

対処療法的に「こう書けば通るよ」という話であれば簡単です。後述しますが、fixture.whenStablefakeAsync + tick を使うことであっさり解決します。
最初は意味を分からずにこれらの関数を使っていたのですが、こいつらが何をしているのかがよく分からないままに使っているとストレスが貯まる一方でした。
このストレスの要因を掘り下げていくと、Zone.jsとAngularの関係を把握できていないことに起因していました。
逆に @angular/core/testing とZone.jsの協調動作を理解した後では、テストコードを書く際の迷いも減ったように感じています。

ということで、今回のエントリでは僕の理解を整理する意味を込めて、Zone.jsを中心にAngularの変更検知やテストの話をつらつらと書いていきます。
まだZone.jsについてまとめられた記事も見当たらないですし、Zone.jsの紹介も兼ねれたら良いなと思っています。

Zone.js の基礎知識

Zone.js とは

Zone.js はAngularチームが開発している非同期処理のユーティリティライブラリです。
元々はDart にインスパイアされて作成されたライブラリですが、2016年12月現在、TC39にstage 0として提案されています(提案仕様)1

Zone.js自体はAngular(2以降)がalphaの頃から存在しているライブラリなのですが、マニアックな機能なためか、そもそも何なの?ってレベルで解説している記事もほぼ見かけません2。Angularとは切っても切り離せない機能なのに、です。

READMEを読むと

Zoneは非同期タスクを跨いだ実行コンテキストである。JavaScript VMにおけるスレッドローカルなストレージだと思えばよい

とさらっとした説明が書いてあるだけです。なんのこっちゃなので、簡単なサンプルで説明します。

import 'zone.js';

helloZone();        // <root>
Zone.current.run(() => {
  setTimeout(() => {
    helloZone();    // <root>
  }, 0);
  Zone.current.fork({name: 'myZone'}).run(() => {
    helloZone();    // myZone
    setTimeout(() => {
      helloZone();  // myZone
    }, 10);
  });
});

function helloZone() {
  console.log(Zone.current.name);
}

上記を実行すると次の出力が得られます。

<root>
myZone
<root>
myZone

上述のコードは<root>というZone(トップレベルのコンテキスト)をforkして、myZoneという名前のコンテキストを作成している例です。

グローバル変数 Zone.current がそれぞれのコンテキストに応じてnameを出し分けられていることが分かりますね。
通常、JavaScriptにおいて、非同期を跨いで同じデータを参照するにはクロージャを利用することとなり、結果的にCallback hellと揶揄されるようなコードになりがちです。
Zone.jsを使うと、クロージャに頼らずとも非同期コールバックを登録した側とコールバックの内側でデータのやりとりができる、という訳です。

Zone.js の仕組み

さて、Zone.js はどのようにしてZone.current を制御しているのでしょうか。先述したようにZone.current は只のグローバル変数です。

答えは非常に単純です。「コールバックを受けとる非同期関数をラップする」という力技で実現しています。

例えば、先ほどのサンプルにおけるsetTimeout であれば、Zone.jsは次のようなパッチを当てます。

  1. setTimeout が実行されたときのZoneを記憶しておく
  2. 1.で記憶しておいたZoneをcurrentにセットしなおす処理を加えて、コールバック関数をラップする
  3. 本物のsetTimeoutに2.でラップしたコールバック関数を食わせる

擬似コードで記述するのであれば、下記のようなイメージです3

function wrapSetTimeout () {
  if (setTimeout["__wrapped__"]) return;
  const _setTimeout = window.setTimeout;
  window.setTimeout = function (task: (...args: any[]) => void, timeout: number) {
    const z = Zone.current;                         // 1.
    const wrappedTask = function() {                // 2.
      Zone.current = z;
      task.apply(this, arguments);
    };
    return <any>_setTimeout(wrappedTask, timeout);  // 3.
  };
  window.setTimeout["__wrapped__"] = true;
};

wrapSetTimeout();

Zone.js はsetTimeoutだけでなく、ありとあらゆる非同期関数をパッチします

Zone.js とtask、その種類

ここで一旦 Zone.js における用語の説明をしておきます。

  • task : コールバック関数のこと
  • schedule : taskを登録する操作のこと

先述のsetTimeout であれば、「setTimeoutは第1引数に与えられたtaskを第2引数で与えられたmsec秒後にscheduleする関数である」という用法ですね。

Zone.js はChromeだろうとNode.jsだろうと、JavaScript実行エンジンで利用可能な全ての非同期関数にパッチを当てていくのですが、task をその性質によって下記の通りに分類しています。

  • MicroTask : 現在実行しているtaskの直後に必ず1回だけ実行されるtask。キャンセルできない。
  • 例: Promiseのresolve, reject。
  • MacroTask : 遅延実行されて、1回以上動作するtask。多くの場合キャンセル可能であり、遅延時間を知っている。
  • 例: setTimeout, setInterval, requestAnimationFrame, requestIdleCallback, etc...
  • EventTask : あるイベントが発生したときに動作するtask。いつ実行されるかは分からない。
  • 例: DOM, XHRのaddEventListener, Node.jsのEventEmitter, etc...

AngularのAPI リファレンスを読む上でもこれらのtask 種別に言及することがあるので、是非覚えておきましょう。特に重要なのは、Promiseに食わせたコールバックは全てMicroTaskであるという点です。

taskとinterception

先ほど、Zone.js が実行時コンテキスト(Zone.current)を制御するためにsetTimeout 関数を例にしてパッチの流れを説明しましたね。

  1. setTimeout が実行されたときのZoneを記憶しておく
  2. 1.で記憶しておいたZoneをcurrentにセットしなおす処理を加えて、コールバック関数をラップする
  3. 本物のsetTimeoutに2.でラップしたコールバック関数を食わせる

この流れをもう少し汎用的に書いてみましょう。

  1. あるtaskがscheduleされたときのZoneを記憶しておく
  2. 1.で記憶しておいたZoneをcurrentにセットしなおす処理を加えて、taskをラップする
  3. scheduler本体の関数に2.でラップしたtaskを食わせる

まぁ用語を書き換えただけなのですが、どのようなschedulerに対しても上記は成立します。
taskの種別によっては、clearTimeoutのようにさらにキャンセル処理もパッチする必要がありますが、taskのscheduleとtask自体の実行という意味では違いはありません。

要は、schedule時とtask実行時に、それぞれZoneの記憶と復帰を行っているだけですね。もう少しだけ汎用化してみましょう。

  • Zoneはschedule実行時とtask実行時に任意の処理を挟み込む(interceptする)ことができる
  • Zone.js 自体はそれぞれのinterceptorとして、Zone.current の記憶と復帰を差し込んでいる

実際、Zone.jsにはinterceptの仕組みが用意されており、fork時に次のinterceptorを仕掛けることができるようになっています。

zone.js/dist/zone.js.d.ts(一部抜粋)
/**
 * Allows interception of task scheduling.
 *
 * @param parentZoneDelegate Delegate which performs the parent [ZoneSpec] operation.
 * @param currentZone The current [Zone] where the current interceptor has beed declared.
 * @param targetZone The [Zone] which originally received the request.
 * @param task The argument passed into the `scheduleTask` method.
 */
onScheduleTask?: (parentZoneDelegate: ZoneDelegate, currentZone: Zone, targetZone: Zone, task: Task) => Task;
onInvokeTask?: (parentZoneDelegate: ZoneDelegate, currentZone: Zone, targetZone: Zone, task: Task, applyThis: any, applyArgs: any) => any;

利用例の提示は行いませんが、onScheduleTask はtaskのscedulingとラップ処理を、 onInvokeTask はtask自体の実行に対する拡張ポイントです。
delegateという引数の名前に示されている通り、こいつに処理を移譲させることが可能です。

また、Zoneのforkには onHasTask という拡張ポイントも用意されています。
この拡張ポイントは MicroTask, MacroTask, EventTask のそれぞれについて、「scheduleされたが実行されていないtaskの存在有無」が変化した際に発火します。

Zone.js 解説のまとめとして、「ボタンをクリックしたらXHRでJSONを取得してname項目をログ出力する」という簡単なアプリについて、上記の拡張ポイントがどのように動作するか見ていきましょう。

import "zone.js";

const myZone = Zone.current.fork({ name: "myZone" }).run(() => {
  document.getElementById("xhr_btn").addEventListener("click", () => {
    getJson("/user.json")
      .then(data => data.name)
      .then(name => console.log(name))
    ;
  });
});

function getJson(url: string) {
  return new Promise<any>(resolve  => {
    const xhr = new XMLHttpRequest();
    xhr.addEventListener("load", () => resolve(JSON.parse(xhr.responseText)));
    xhr.open("GET", url);
    xhr.send();
  });
}

taskのscheduleと実行状態は下図のようになります(青: onScheduleTask, 緑: onInvokeTask, オレンジ: onHasTask):

Zone.png

Angular と Zone.js

ここからはAngularとZone.jsがどのように連携しているかを見ていきます。

Zone.js の中身をよく知らなくとも「AngularはZone.jsのお陰で変更検知ができる」といった話を耳にしたことはないでしょうか。ないですかそうですか。

AngularのComponentには、それぞれChangeDetectorという奴を保有しています。ChangeDetectorは、対応するComponentのテンプレートに記載されているexpressionについて、一つ前と現在の差分を計算して自身のViewを更新する機能を有しています。

Angular AoTガイドの中で「ComponentのテンプレートをJiTコンパイルするとテンプレートを更新する関数が生成される」という話を書きました。「テンプレートを更新する関数」というのが正にChangeDetectorの実体処理です。

また、ChangeDetectorの詳細については、Angular2のChange Detectionについてが詳しいです。

NgZone

では、いつAngularはComponentのChange Detectionを実行したらよいのでしょうか。ここで今までのZone.jsの話が活きてきます。
Zone.jsが支配している世界では、ありとあらゆる非同期処理について、scheduleのタイミングとtaskが実行されるタイミングの両方に任意の処理を挟み込むことができるのですから、これを変更検知に利用しない手はありません。

真っ先に思い付くのは「各taskのinvokeが完了したタイミングでRoot ComponentのChange Detectionを実行する」という方式です。
ですが、これはやりすぎであることがすぐに分かります。図で説明した方がわかりやすいでしょう。

Angular change detection.png

Change Detectionを実行するべきは、図中に赤字で"Change Detection"と書いたタイミングで十分です。
MicroTaskの性質を思い出してください。MicroTaskは「現在実行しているtaskの直後に実行される」task です。
図のXHR callback部分のようにPromise.thenがチェーンされている場合、すなわち、複数のMicroTaskがスタックしている場合、それぞれのMicroTask実行毎にChange Detectionを実行したとしても、最後のMicroTask実行後以外の状態には意味がありません。
1つ目のMicroTaskで確定したViewの状態は2つ目のMicroTaskでさらに書き換えれてしまう可能性がある上、ユーザにとっては、途中のMicroTask実行後の状態は知覚できないのですから。

必要十分な変更検知の実行条件は各taskの実行後で且つ、未実行状態のMicroTaskが存在しないときとなります。

AngularにはZoneをラップしたNgZoneというクラスが存在しますが、こいつがこの条件時に発火するonMicrotasksEmptyというEventEmitterを持っています。
NgZoneは、先述したZone のonHasTask interceptorを利用してMicroTaskの個数を監視しつつ、MicroTaskが空になっているかどうかという状態を持っています(hasPendingMicrotasks プロパティ)。
そして、NgZone はZone の onInvokeTaskhasPendingMicrotasks が偽の場合にonMicrotasksEmpty をemitするように実装されているのです。

ついでに、onMicrotasksEmpty で実行されるtaskでさらにMicroTaskがschedulingされた場合のことを考慮して、onMicrotasksEmpty の発火後にもう一度MicroTaskの存在確認をするようになっており、ここでMicroTaskが存在しないと判定された場合に、NgZoneは自身の状態をstableとします。
NgZoneがstableとなったタイミングで発火するEventEmitterが onStableです。AngularJS 1.xでいうのであれば、$scope.$$postDigest に近い存在と言えるでしょう。

アプリケーション実行時における変更検知

もう我々はNgZoneのonMicrotasksEmpty で変更検知を動作させれば良い、ということを知っています。
Angularのアプリケーションをbootstrapした場合は、bootstrap時に作成されるAppicationRef がonMicrotasksEmptyを購読して変更検知を実行します。

modules/@angular/core/src/application_ref.ts
    this._zone.onMicrotaskEmpty.subscribe(
        {next: () => { this._zone.run(() => { this.tick(); }); }});

tickメソッドが変更検知を実行している実体です。

テスト実行時における変更検知

そういえば今回はテストの話でしたっけ。最早僕も忘れかけていました。

テストの場合はApplicationRefは存在しません。
代わりにComponentFixtureがテスト対象のComponentを握っています。
ApplicationRefが存在しないため、テストコードの側で変更検知を明示的に実行してあげる必要があるわけです4

fixture.componentInstance.name = 'Quramy';
fixture.detectChanges();

とすると、componentInstanceの状態に従って、ComponentのViewが更新される訳です。

ところで、先ほど「onMicrotaskEmptyで実行されるtaskの内部から、MicroTaskがscheduleされた場合、NgZoneはstableにならない」と書きました。
これを変更検知で当てはめると、「detectChanges により呼びだされたChangeDetectorでMicroTaskがscheduleされた場合」となります。

冒頭の再掲となりますが、 <input [(ngModel)]="name" /> というテンプレートに対する下記のテストコードは、実のところ、detectChangesの内側でMicroTaskがschedulingされるパターンに該当します。

it('should display name property into it\'s input element', () => {
  component.name = 'Quramy';
  fixture.detectChanges();
  const inputElm = fixture.debugElement.query(By.css('input')).nativeElement as HTMLInputElement;
  expect(inputElm.value).toEqual('Quramy', 'Input value for name element');
});

NgModel Directiveは ngOnChnagesフックから、DefaultControlValueAccessorを経由してinputタグのvalueプロパティを更新するようになっています5
このとき、NgModel は更新を同期的に行わずに、Promise.resove().then(() => /** 更新処理 */) のようにMicroTaskとして要素更新を行うのです。何故そのような挙動になっているかは後述します。

いずれにせよテストを正しく動作させるためには、scheduleされたMicroTaskの完了を待つ必要があります。
例えば次のように、ComponentFixtureのwhenStableを利用することで実現できます。

it('should display name property into it\'s input element', (done) => {
  component.name = 'Quramy';
  fixture.detectChanges();
  fixture.whenStable().then(() => {
    const inputElm = fixture.debugElement.query(By.css('input')).nativeElement as HTMLInputElement;
    expect(inputElm.value).toEqual('Quramy', 'Input value for name element');
    done();
  });
});

"stable"という名前ですが、fixture.whenStable() は NgZoneの onStableとは意味が若干異なります。whenStableはNgZoneのstable、且つMacroTaskも空の状態でないと発火しません。

fakeAsyncflushMicrotasks を使うという手もあります。fakeAsyncの実体は、FakeAsyncTestZone というZoneです。
このZoneでは、 MicroTaskと一部のMacroTask(setTimeout, setInterval)を独自のキューにぶち込むことで、外部から任意のタイミングでtaskの実行を可能にしています。

it('should display name property into it\'s input element', fakeAsync(() => {
  component.name = 'Quramy';
  fixture.detectChanges();
  flushMicrotasks();
  const inputElm = fixture.debugElement.query(By.css('input')).nativeElement as HTMLInputElement;
  expect(inputElm.value).toEqual('Quramy', 'Input value for name element');
}));

flushMicrotasks はその名の通り、FakeAsyncTestZone に登録されている全てのMicroTaskを実行し切ります。
flushMicrotasksの代わりに tick を利用することも可能です。tickはfakeAsync Zoneのタイマー処理を強制的に進める機能ですが、MicroTaskを実行してから、MacroTaskであるタイマー系の処理を実行するからです。

Componentのテストで頻出するパターンをまとめると、次のようになります。

  • MicroTask(Promise.then)を実行させたい: fixture.whenStable, fakeAsync + flushMicrotasks, fakeAsync + tick
  • MacroTaskの完了を待ちたい: fixture.whenStable
  • MacroTask(但しタイマー系のみ)を実行したい: fakeAsync + tick
  • EventTask(但しDOMのみ)を強制的に実行したい: fixture.query(...).triggerEventHandler

各taskの終了を待った後、fixture.detectChanges を実行すれば、テスト対象のComponentとそのViewを狙った状態にすることが出来ると思います。
AppricationRefの挙動である各taskの実行後で、且つMicroTaskが空の場合に変更検知を行うを押えておけばどうということはありません6

当然と言えば当然なのですが、XHRのようにブラウザの外部と通信するようなケースはどうしようもないので、serviceをmockingするなり、HttpBackendを使うなり、JasmineのspyOnを使うなり工夫しましょう。

まとめ

今回もダラダラと書いてしまいました。ここまでお付き合い頂けたのであれば、ありがたい限りです。

このエントリではZone.jsとAngularの関係について書き連ねてきました。細かいところは正直どうでもよいのですが、下記あたりを押えて置けばAngularに対する理解が深まるんじゃないかなー。

  • Zone.jsは非同期処理をinterceptする機能を持ったユーティリティ
  • MicroTask(Promise), MacroTask(timer系), EventTask(DOM等)の3種類
  • AngularはZoneのtask状態を監視して変更検知を実行している
  • テストコードではfixture.whenStabletick, flushMicrotasks, triggerEventHandlerでtask実行を制御し、その後 fixture.detectChangesを実行するようにする

おまけ

何故NgModel はChange Detectionで非同期に値を更新するのか

そもそもはChange Detectionを1回実行しただけではngModelがDOMを更新してくれない、というのがこのエントリを書いた発端です。
NgModel DirectiveはngOnChangeの内部で以下のprivate methodを実行しています。

  private _updateValue(value: any): void {
    resolvedPromise.then(
        () => { this.control.setValue(value, {emitViewToModelChange: false}); });
  }

resolvedPromise という名前の通り、このPromiseは既に解決されているため、MicroTaskのみがschedulingされます。
Change Detectionの処理中にさらにMicroTaskを発行するということは、さらにもう一度Change Detectionの実行を予約するのと同じ意味ですから、パフォーマンスを考えると明らかにデメリットのはずなのに、何故こんな処理を行っているのでしょう。

NgModelのソースコード にひっそりとその理由が書いてありました。

I.e. ngModel can export itself on the element and then be used in the template.
Normally, this would result in expressions before the input that use the exported directive
to have and old value as they have been
dirty checked before. As this is a very common case for ngModel, we added this second change
detection run.

exported directive というのは、テンプレート中で #myModel="ngModel" のようにして、Directiveをテンプレート中で使いまわすことです。

具体例で考えると得心すると思います。下記はTemplate Driven Formによる必須バリデーションの実装例です。

@Component({
  selector: 'app-my-form',
  template: `
    <span *ngIf="nameModel.errors?.required">It's required!</span>
    <input [(ngModel)]="name" #nameModel="ngModel" required />
  `
})
export class MyFormComponent  {
  name: string = '';
}

MyFormComponentのnameが、null -> 何らかの値 に変化した場合のChange Detectionを考えてみましょう。

MyFormComponent 自身のChangeDetector 中では、まだNgModelのChangeDetector が動作していないため、nameModel.errors?.required というexpressionは真のままです。
また、NgModelのChangeDetectorが実行されても、親ViewであるMyFormComponentの更新は行いません。
結果的にMyFormComponentのnameが非nullなのにも関わらず、エラー表示している状態になってしまいます。

この不整合7を回避するべく、Promise.then(() => {...} として MicroTask を発行することでもう1回Change Detectionの実行を予約している、ということです。

参考資料・脚注

  1. わざわざJavaScriptの標準仕様にするほどか?と正直思わないでもないです。

  2. Qiitaだと、async awaitでキレイなコードが書けないと辛い気持ちになってたところ、zone.jsを使ったらいろいろと解決できそうなことが分かった くらいしか記事がない

  3. https://github.com/angular/zone.js/blob/master/lib/common/timers.ts あたり

  4. AutoDetectChangesを使うと端折れるのですが、面白くないので割愛

  5. https://github.com/angular/angular/blob/master/modules/@angular/forms/src/directives/ng_model.ts#L215 あたり

  6. このことを同僚に伝えるために「ApplicatoinRefの気持ちになれば簡単だろ?」って言ってみたのですが、全く通じませんでした...

  7. この件から学ぶべきは、Template DrivenなDirectiveを作成する場合は、親Viewの変更検知を気にしてあげないと画面の不整合が発生しかねない、という事実です

170
119
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
170
119

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?