undefined

bokuweb.me

Angular2 RC5への更新とステート管理の変更、power-assertによるテストまで試す

概要

以前触ってみたときはRC3でRC5が出たらもう一回触るかってことで、以前作ったサンプルのRC5への更新、ステート管理の変更、ユニットテストについて試してみた。以前の記事は以下。

blog.bokuweb.me

RC5への更新

情報収集をするとNgModuleが追加されたことが大きいようで、コンポーネントごとにdirectivespipesでの指定を行う必要がなくなり、stableでこの方法は廃止になるとのこと。現状、このサンプルにおいてはRC3のコードのまま動作するしwarningもでなかった。

詳細は以下で確認すると良さそう。

ng2-info.github.io

ng2-info.github.io

NgModuleの導入

まずは@NgModuleを使用してモジュールを作ることになる。前回コンポーネントごとに記述していたディレクティブはdeclarationsに記述することになる。これによりモジュール配下のコンポーネントにおいて、ここで記述したディレクティブが使用できるようになる。サンプルでは未使用だがパイプなどもここにまとめて記述することになる。

providerにはDIプロバイダ、bootstrapにはエントリポイントとなるコンポーネントを指定する。

  • app/main.ts
import { NgModule, ApplicationRef } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
 
import { CommentList } from '../components/comment-list';
import { CommentForm } from '../components/comment-form';
import { CommentItem } from '../components/comment-item';
 
import { HTTP_PROVIDERS } from '@angular/http';
import { CommentBox } from '../components/comment-box';

@NgModule({
   declarations: [
     CommentBox,
     CommentList,
     CommentForm,
     CommentItem,
   ],
   providers: [
     HTTP_PROVIDERS,
   ],
   imports: [BrowserModule],
   bootstrap: [CommentBox]
 })
 class AppModule {
 }
 
 platformBrowserDynamic().bootstrapModule(AppModule);

あとは各コンポーネントのdirectivesを削除し、動作した。 前回のRC3からRC5への差分は以下。

github.com

ステート管理の見直し

前回のサンプルを作成したときには、containerにステートを持たせて管理する方法をとっていた。ただ、ReactReduxなどを使っていて、コンポーネントに状態を閉じた場合、後々いい結果にならないケースが多い、印象があるので模索していたところ以下の記事を見つけたので今回のサンプルに反映してみることにした。特にコンポーネント数が多く、コンポーネントのステートが様々なコンポーネントに影響するような構成の場合、アプリケーションのステートを局所化する手段はあったほうがいいと思う。

vsavkin.com

概要

基本的にはReduxライクな構成でイミュータブルなステートをアクション経由で変更するというもの。そこにRxJS(というよりObservableか)を使用することでaction発行毎にreducerが走る問題などを解決しつつ、RxJSの恩恵を受ければ状態管理シンプルにできるよ。という趣旨に見える。

Actions

アクションを用意する。ひとまずpayloadにデータを放り込んでる。

  • actions/action
import { Comment } from '../interfaces/comment';

export class AddCommentAction {
  public payload: {
    id: number;
    text: string;
    author: string;
  }

  constructor(comment: Comment) {
    this.payload = comment;
  }
}

export class FetchCommentAction {
  public payload: {
    comments: Comment[];
  }

  constructor(comments: Comment[]) {
    this.payload = { comments };
  }
}

export type Action = AddCommentAction | FetchCommentAction;

Observable

ActionをもらってアプリケーションステートのObservableを返すステート関数を用意する。 Reduxでのreducerに相当する関数。ただし、reducerと違いこの関数自体は一度しか呼ばれないことを利点として上げている。 これによりすべてのReducerが呼ばれたり、Selectorが呼ばれたりしてパフォーマンスが落ちることを避けられるというかな?

  • store/comments.ts
import { AddCommentAction, FetchCommentAction, Action } from '../actions/action';
import { Comment } from '../interfaces/comment';
import { Observable } from 'rxjs';

export function comments(initState: Comment[], actions: Observable<Action>): Observable<Comment[]> {
  return actions.scan((state: Comment[], action: Action) => {
    if (action instanceof AddCommentAction) {
      const { id, text, author } = action.payload;
      const newComment = { id, text, author };
      return [...state, newComment];
    } else if (action instanceof FetchCommentAction) {
      return action.payload.comments;
    } else {
      return state;
    }
  }, initState);
}

次にrootReducerに相当する各ステート関数をcombineするstateFnを用意する。このサンプルではcombineする必要はないけど、参照記事ではfilter機能などを実装しているため、複数のステート関数をcombineしている。このサンプルではあまり意味を成さないものになっているので詳細は参照記事を確認してください。

  • store/index.js
import { AddCommentAction, Action } from '../actions/action';
import { Comment } from '../interfaces/comment';
import { Observable, BehaviorSubject } from 'rxjs';
import { comments } from './comments';

export function stateFn(initState: Comment[], actions: Observable<Action>): Observable<Comment[]> {
  const appStateObs = comments(initState, actions);
  return wrapIntoBehavior(initState, appStateObs);
}

function wrapIntoBehavior(init, obs) {
  const res = new BehaviorSubject(init);
  obs.subscribe(s => res.next(s));
  return res;
}

一つ注意点があって、wrapIntoBehaviorを実行しないとコンポネーントがsubscribeしたときにemitされるまでデータが受け取れないことになる。その問題をBehaviorSubjectで解決しているとのこと。

このあたりRxJSの理解力が乏しくてアレなんだが、BehaviorSubjectの挙動としては以下のようなものらしい。

直前にonNextで渡された値を保持し、subscribe()した直後に保持していた値を流します。その後の動作は後述のPublishSubjectと同等です。

f:id:bokuweb:20160818101059p:plain

Rxで知っておくと便利なSubjectたちより

この辺りの挙動はテストを書いてみるとわかりやすく、参照記事と同様のものを書いてみた。 後述するが、テストまわりは不明点周りで現状かなり辛い印象をもったが、この辺りはAngularから切り離されているためテストも容易だった。

import assert = require('power-assert');
import Rx = require('rxjs');
import { stateFn } from '../store/index';
import { AddCommentAction, Action } from '../actions/action';

describe ('store test.', () => {
  it('should create a new comment', () => {
    const actions = new Rx.Subject<Action>();
    const states = stateFn([], actions);
    actions.next(new AddCommentAction({ id: 1, text: 'text', author: 'author' }));
    actions.next(new AddCommentAction({ id: 2, text: 'foo', author: 'bar' }));
    states.subscribe(s => {
      assert.equal(s.length, 2);
      assert.deepEqual(s[0], {
        id: 1, text: 'text', author: 'author',
      });
      assert.deepEqual(s[1], {
        id: 2, text: 'foo', author: 'bar',
      });
    });
  });
});

Application and View Boundary

上記のactionやstateをView側で扱う準備をする。 この辺り理解が怪しいんだが、Angular 2のDIは基本的に、 トークン に対して値をセットしており、OpaqueTokenはProviderのトークンとして使いやすいインスタンスを提供してくれると書いてある。詳細は以下を参照すると良さそう。

import { OpaqueToken } from '@angular/core';
import { Subject } from 'rxjs';
import { Action } from '../actions/action';
import { stateFn } from '../store';

export const initState = new OpaqueToken("initState");
export const dispatcher = new OpaqueToken("dispatcher");
export const state = new OpaqueToken("state");

export const stateAndDispatcher = [
  {
    provide: initState,
    useValue: [],
  }, {
    provide: dispatcher,
    useValue: new Subject()
  }, {
    provide: state,
    useFactory: stateFn,
    deps: [initState, dispatcher]
  }
];

そして、こいつを@NgModuleで登録してやる。

...
import { stateAndDispatcher } from '../store/state-and-dispatcher';

@NgModule({
  declarations: [
    CommentBox,
    CommentList,
    CommentForm,
    CommentItem,
  ],
  providers: [
    HTTP_PROVIDERS,
    stateAndDispatcher,
  ],
  ...

View

以下のようにInjectしてdispatchと、stateを使用している。

  • components/comment-box.ts
import { Component, OnInit, Inject } from '@angular/core';
import { CommentService } from '../service/comment';
import { Comment } from '../interfaces/comment';
import { Observable, Observer } from 'rxjs';
import { Action } from '../actions/action';
import { state, dispatcher } from '../store/state-and-dispatcher';
import { AddCommentAction, FetchCommentAction } from '../actions/action';

@Component({
  selector: 'comment-box',
  providers: [CommentService],
  template: `
    <div class="commentBox">
      <h1>Comments</h1>
      <comment-list [comments]="comments"></comment-list>
      <comment-form (onCommentSubmit)="handleCommentSubmit($event)"></comment-form>
    </div>
  `,
})
export class CommentBox implements OnInit {

  comments: Comment[];

  constructor(private commentService: CommentService,
              @Inject(state) private state: Observable<Comment[]>,
              @Inject(dispatcher) private dispatcher: Observer<Action>) {
    this.state.subscribe(comments => this.comments = comments);
  }

  ngOnInit() {
    this.commentService
      .startIntervalFetch()
      .subscribe(comments => this.dispatcher.next(new FetchCommentAction(comments)));
  }

  handleCommentSubmit(comment) {
    comment.id = this.comments.length;
    this.commentService
      .add(comment)
      .subscribe(res => this.dispatcher.next(new AddCommentAction(comment)));
  }
}

書いてて思ったけど、このコンポーネントはSmartComponentとしての責務に専念して、templateへの記述は最小限に抑えるべきな気がする。 あとこういった構成の場合複雑な非同期処理が必要となった場合(API設計が悪いとか、BFFが、とかはひとまず置いておいて)、シンプルに対応できるかというのが頭をよぎるんだが、この場合サービス側でごにょごにょやる感じになるのかな。

これはAngularではなくRxJS力の低さの問題なんだけど、非同期処理をチェーンさせる(たとえばユーザーリストをフェッチ後、ユーザidからお友達リストを取ってここなくてはならない、など)場合、どう書くのがシンプルなのかわからない。

テスト

mocha + power-assertでテストが書けるとのことなので一番シンプルなコンポーネントで試してみた。 とは言え、現状不明点が多く、「こうしたらなんか動いた」的な状態に近い。

テスト対象

  • components/comment-list.ts
import { Component, Input } from '@angular/core';

@Component({
  selector: 'comment-list',
  template: `
    <div class="comment-list">
      <div *ngFor="let comment of comments">
        <comment-item [author]="comment.author" [text]="comment.text"></comment-item>
      </div>
    </div>
    `
})

export class CommentList {
  @Input() comments;
}

テスト

  • test/comment-list.test.ts
import 'es6-shim';
import 'reflect-metadata';
import 'zone.js/dist/zone';
import 'zone.js/dist/long-stack-trace-zone';
import 'zone.js/dist/async-test';
import 'rxjs';
import { TestBed } from '@angular/core/testing';
import { Component } from '@angular/core';
import assert = require('power-assert');
import { CommentList } from '../components/comment-list';
import { CommentItem } from '../components/comment-item';
import { Comment } from '../interfaces/comment';
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
TestBed.initTestEnvironment(
    BrowserDynamicTestingModule,
    platformBrowserDynamicTesting()
);

describe ('comment-list test.', () => {
  @Component({
    template: `
      <comment-list [comments]="comments"></comment-list>
    `,
  })
  class TestCmp {
    comments: Comment[] = [
      { id: 0, text: 'text1', author: 'author1' },
      { id: 1, text: 'text2', author: 'author2' }
    ];
  }

  beforeEach(() => {
    // setup
    TestBed.configureTestingModule({
      declarations: [TestCmp, CommentList, CommentItem]
    });
  });

  it('component test', (done) => {
    // compile test component
    TestBed.compileComponents().then(() => {
      // create component instance
      const fixture = TestBed.createComponent(TestCmp);
      fixture.detectChanges();
      const el = fixture.debugElement.nativeElement as HTMLElement;
      const authors = el.querySelectorAll('comment-item h2.comment-author');
      const text = el.querySelectorAll('span');
      assert.equal(el.querySelectorAll('comment-item').length, 2);
      assert.equal(authors.length, 2);
      assert.equal(authors[0].textContent.trim(), 'author1');
      assert.equal(text.length, 2);
      assert.equal(text[0].textContent.trim(), 'text1');
      assert.equal(text[1].textContent.trim(), 'text2');
      done();
    });
  });
});

以下にも記述があるが、ユニットテスト用のモジュールを管理するTestBed APIが追加されたらしく、こいつを使ってテスト用のコンポーネントを作成したりしてテストしていくことになりそう。

ng2-info.github.io

最初はTestBed.createComponent(CommentList)として、createComponentに直接テスト対象のコンポーネントを追加していたんだけど、この場合@Inputをどのように解決するのかわからず、上位コンポーネントとしてTestCmpを作成し、こいつを渡すことにした。以下のモジュールのテストを参考にしていて、これならspystubなどを仕込むのも簡単にできそう。

github.com

また、以下の記事もユニットテストやマーブル記法のテストまで触れてあって参考になった。

qiita.com

beforeEachでは TestBed.configureTestingModule({declarations: [TestCmp, CommentList, CommentItem]});としてテスト用のモジュール設定を行っている。これは@NgModuleに相当するところのようで、使用するディレクティブなどはここで設定しておく必要がある。(子や孫なども含めて)。

    TestBed.compileComponents().then(() => {
      // create component instance
      const fixture = TestBed.createComponent(TestCmp);
      fixture.detectChanges();
      const el = fixture.debugElement.nativeElement as HTMLElement;

あとは上記のようにすることでレンダリング後のHTMLElementがもらえるので、querySelectorで検索してテストしてみた。

ここまでやって思ったのはShallowRenderingまたはそれに相当するものが欲しい。あるのかどうかわからないが。 そうしないとすべてのコンポーネントが展開されてしまい、不味いことになる。

すでにShallowRenderingのようなものがあって見つけられていないのか、それとも、TestBed.configureTestingModuleのdeclarationsにモックを登録したりTestBed.overrideComponentを上手く使ってやりくりしたりするのかよく分かっていない。上記の記事にも書いてあるけど、とにかく今は情報が少なくて辛い。

また、今回は雑にquerySelectorを使っているけど、Enzyme的なView用テストユーティリティがあると良さそう。すでにあるのかもしれないけど・・。

追記

ここにテストについて結構書いてある

habrahabr.ru