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

## 概要

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

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

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

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

## 環境

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

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

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

最初のうちに[strictNullChecks](https://github.com/uryyyyyyy/angularSample/blob/helloWorld/src/tsconfig.json#L11)を付けておくことをオススメします。

## 作った画面

![hello.gif](https://qiita-image-store.s3.amazonaws.com/0/7185/30e3dcad-eb9c-14a5-10e5-bf830106c478.gif)

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

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

という感じです。

## Component

### src

こんな感じです。

```html:app.component.html
<p>Counter</p>
<p>Point: {{point}}</p>
<button (click)="increment(3)">Increment 3</button>
<button (click)="decrement(2)">Decrement 2</button>
```

```ts: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

```ts: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

```ts: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

```ts: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](http://blog.mitsuruog.info/2015/01/hosting-angularjs-application-on-s3-via-cloudfront.html)

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

このあたりの設定は[package.json](https://github.com/uryyyyyyy/angularSample/blob/helloWorld/package.json)に書いてあるので参考にして頂ければ。

## デプロイ

ここまで問題がなければ、 `ng build` などで生成したファイル群をサーバーに置いておけばバッチリです。
フロントエンドでルーティングさせる場合は、他のURLパスへのアクセスの際にルートindex.htmlを返してあげる処理を挟む必要がありますが、そのくらいです。[Link](http://blog.mitsuruog.info/2015/01/hosting-angularjs-application-on-s3-via-cloudfront.html)