概要
以前触ってみたときはRC3でRC5が出たらもう一回触るかってことで、以前作ったサンプルのRC5への更新、ステート管理の変更、ユニットテストについて試してみた。以前の記事は以下。
RC5への更新
情報収集をするとNgModule
が追加されたことが大きいようで、コンポーネントごとにdirectives
やpipes
での指定を行う必要がなくなり、stableでこの方法は廃止になるとのこと。現状、このサンプルにおいてはRC3のコードのまま動作するしwarning
もでなかった。
詳細は以下で確認すると良さそう。
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への差分は以下。
ステート管理の見直し
前回のサンプルを作成したときには、containerにステートを持たせて管理する方法をとっていた。ただ、React
とRedux
などを使っていて、コンポーネントに状態を閉じた場合、後々いい結果にならないケースが多い、印象があるので模索していたところ以下の記事を見つけたので今回のサンプルに反映してみることにした。特にコンポーネント数が多く、コンポーネントのステートが様々なコンポーネントに影響するような構成の場合、アプリケーションのステートを局所化する手段はあったほうがいいと思う。
概要
基本的には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と同等です。
この辺りの挙動はテストを書いてみるとわかりやすく、参照記事と同様のものを書いてみた。
後述するが、テストまわりは不明点周りで現状かなり辛い印象をもったが、この辺りは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が追加されたらしく、こいつを使ってテスト用のコンポーネントを作成したりしてテストしていくことになりそう。
最初はTestBed.createComponent(CommentList)
として、createComponent
に直接テスト対象のコンポーネントを追加していたんだけど、この場合@Inputをどのように解決するのかわからず、上位コンポーネントとしてTestCmp
を作成し、こいつを渡すことにした。以下のモジュールのテストを参考にしていて、これならspy
やstub
などを仕込むのも簡単にできそう。
また、以下の記事もユニットテストやマーブル記法のテストまで触れてあって参考になった。
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用テストユーティリティがあると良さそう。すでにあるのかもしれないけど・・。
追記
ここにテストについて結構書いてある