1. Qiita
  2. 投稿
  3. angular

Angular入門: Component/ServiceをTestまで含めて試す

  • 3
    いいね
  • 0
    コメント

概要

SPAを作る上で最初に考えるであろう、

  • Viewの描画やイベント処理の流れ
  • Serviceでのロジックの書き方
  • テストの書き方
  • 動作確認・デプロイ

を私見で書いていきます。

「もっと良い書き方あるよ」「こういうときどうするんだ」などなどお気軽にコメント頂ければと思います。

環境

  • angular-cli: beta-32
  • Angular: 2.4
  • NodeJS: 6.9

公式からAngular CLIというのが提供されていて、推奨っぽいのでこれを用います。(近々RCが出るとかいう話があるそうで。)

ソースコードはこちら↓
https://github.com/uryyyyyyy/angularSample/tree/helloWorld

最初のうちにstrictNullChecksを付けておくことをオススメします。

作った画面

hello.gif

すごくシンプルな表示を扱うものです。
要件として、

  • Incrementボタンを押すと同期的に数字が加算される
  • Decrementボタンを押すと同期的に数字が減算される

という感じです。

Component

src

こんな感じです。

app.component.html
<p>Counter</p>
<p>Point: {{point}}</p>
<button (click)="increment(3)">Increment 3</button>
<button (click)="decrement(2)">Decrement 2</button>
app.component.ts
import { Component } from '@angular/core';
import {CounterService} from './services/counter.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {

  point: number;

  constructor(private counterService: CounterService) {
    this.point = counterService.point.getValue();
    counterService.point.subscribe((v) => this.point = v);
  }

  increment() {
    this.counterService.increment(3);
  }

  decrement() {
    this.counterService.decrement(2);
  }
}

ボタンをクリックするとserviceへイベントを通知して、その結果をsubscribeして画面に表示しています。簡単ですね。

余談:状態管理について

状態管理は、Reactではfluxアーキテクチャや、reduxというデファクト実装がありますが、Angularではそういうデファクトと言えるものはまだ無いようです。

個人的に良いと思っている設計としては、

  • そのComponentや子の表示のために必要な状態
    • Componentで管理する
  • 複数のComponentで共有したい状態・ドメインモデルなどの永続化したい状態
    • Serviceで管理する

という感じで考えています。今回だと、Pointの値はちゃんと扱おうということでServiceで管理しています。

test

app.component.spec.ts
import {TestBed} from '@angular/core/testing';
import {AppComponent} from './app.component';
import {BehaviorSubject} from 'rxjs/Rx';
import {CounterService} from './services/counter.service';

describe('AppComponent', () => {
  //①
  const counterServiceMock = {
    point: new BehaviorSubject(0),
    increment: () => void(0)
  };

  //②
  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [AppComponent],
      providers: [
        {provide: CounterService, useValue: counterServiceMock}
      ]
    }).compileComponents();
  });

  //③
  it('should create', () => {
    const fixture = TestBed.createComponent(AppComponent);
    const component = fixture.componentInstance;
    expect(component).toBeTruthy();
  });

  //④
  it(`should have point value`, () => {
    const fixture = TestBed.createComponent(AppComponent);
    fixture.detectChanges();
    const app = fixture.debugElement.componentInstance;
    expect(app.point).toEqual(0);
  });

  //⑤
  it('should render point', () => {
    const fixture = TestBed.createComponent(AppComponent);
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;
    const pTag = compiled.querySelectorAll('p')[1];
    expect(pTag.textContent).toContain(`Point: 0`);
    const service = fixture.debugElement.injector.get(CounterService);
    service.point.next(3);
    fixture.detectChanges();
    expect(pTag.textContent).toContain(`Point: 3`);
  });

  //⑥
  it('should call increment', () => {
    const fixture = TestBed.createComponent(AppComponent);
    const service = fixture.debugElement.injector.get(CounterService);
    spyOn(service, 'increment');
    const button = fixture.debugElement.nativeElement.querySelectorAll('button')[0];
    button.click();
    expect(service.increment).toHaveBeenCalledTimes(1);
    expect(service.increment).toHaveBeenCalledWith(3);
  });
});

少々特殊なので順を追って説明します。

まず、AppComponentの単体テストなので、InjectされるServiceは本物じゃなくてダミーを使うのが一般的です。
②のbeforeEachでTestBed.configureTestingModuleを呼ぶことで、Test用のModulesを用意します。ここではAppComponentのテストなのでdeclarationsにAppComponent、providersにその依存であるCounterServiceを用意します。
ちなみに、単体テストにおいては他のクラスは実体でなくダミーを用いるのが一般的です。
ここでは①にあるように、本物のServiceでなく counterServiceMock を渡してあげることで、単体テストで考えることが減ります。
以降、このTestBedを使ってテストを行います。

③では、実際にAppComponentをインスタンス化できているかどうかを見ています。HTMLの記述がバグってたりすると怒られるようです。

④では、インスタンスがpointという値を持っているかを見ています。

⑤では、DOMに適切な表示がされていること、さらにはserviceの状態が変わるとその表示が変わることを確認しています。少々クセがありますが見ていきましょう。
まずDOMへの表示はZone.jsが変更を検知することで行われるため、テストにおいては fixture.detectChanges() を呼び出すことで表示されます。
次に変更の検知ですが、ここではServiceが状態持っているため、変更するにはまずDIされたserviceを取り出して、状態を更新してあげます。
この後に再度 fixture.detectChanges() を呼ぶことで、最新の値で表示がされます。

⑥では、ボタンをクリックしたときにServiceの関数が叩かれることを確認しています。
⑤と同様にDIされたserviceを取得して、spyを差し込むことで呼ばれたことが確認できます。

Service

src

counter.service.ts
import {Injectable} from '@angular/core';
import {BehaviorSubject} from 'rxjs/Rx';

export const messageEndPoint = `${environment.host}/api/count`;

@Injectable()
export class CounterService {

  //①
  point: BehaviorSubject<number> = new BehaviorSubject(0);

  constructor() { }

  //②
  increment(num: number): void {
    const current = this.point.getValue();
    this.point.next(current + num);
  }

  decrement(num: number): void {
    const current = this.point.getValue();
    this.point.next(current - num);
  }

}

今回はすごく簡単な例なのでシンプルです。

①で、Observableなオブジェクト(point)を作って、変更があるとSubscribeしている全てに変更を通知します。
②では、そのpointに新しい値をpushしています。
これだけです。特に迷わないですかね。

test

counter.service.spec.ts
import {CounterService} from './counter.service';

describe('CounterService', () => {

  it('increment() should increase point', () => {
    const service = new CounterService();
    service.increment(3);
    expect(service.point.getValue()).toBe(3);
  });
});

ServiceはAngularに依存していないので、普通のオブジェクトとしてテストが書けますね。
なので、特筆することはありません。

動作確認

動作確認ですが、個人的に ng serve でなく ng build --watch を使うのが好きです。
ng serve は手軽に動かせるしroutingも対応してて便利なのですが、ちょっと黒魔術感が強いので。。

ng build を使うと、デプロイ時と同じような構成で扱えるので重宝しています。代わりにroutingとかしようとすると色々と設定が必要になってしまうので、expressを立てています。
S3のようなホスティングサービスでも同じような設定をします。 Link

あとはテストはデフォルトではwatchがかかってしまうので、CI上で走らせるには ng test --watch=false などをする必要があります。ついでにlintのチェックも一緒にかければなお良しです。。

このあたりの設定はpackage.jsonに書いてあるので参考にして頂ければ。

デプロイ

ここまで問題がなければ、 ng build などで生成したファイル群をサーバーに置いておけばバッチリです。
フロントエンドでルーティングさせる場合は、他のURLパスへのアクセスの際にルートindex.htmlを返してあげる処理を挟む必要がありますが、そのくらいです。Link

Comments Loading...