Angular入門: http通信部分やテストを試す

## 概要

前回（[Angular入門: Component/ServiceをTestまで含めて試す](http://qiita.com/uryyyyyyy/items/4b142ffe12d78037cfc2)）の続きです。

今回は

- 通信処理の書き方
- テストの書き方
- 動作確認・デプロイ

を書いていきます。

## 作った画面

![async.gif](https://qiita-image-store.s3.amazonaws.com/0/7185/80a6b5cb-5fa3-073e-c8ce-9d4f97988e7f.gif)


要件としては、前回分に加えて

- AsyncIncrementボタンを押すと、サーバーにデータを取りに行って非同期に数字が加算される。

という形です。

## 環境

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

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

## 前提：APIサーバー

SPAの場合は、フロントのリソースはS3などのホスティングサービスから配信するかもしれません。
その場合はAPIサーバーのエンドポイントは別のホストになることが有り得ます。

本記事ではそのような配信体系を想定して、動作確認用にexpressでAPIサーバーを立てます。
コードはすごくシンプルにこんな感じです。

```js:api-server.js
const express = require('express');
const app = express();

app.get('/api/count', (req, res) => {
  res.contentType('application/json');
  res.header("Access-Control-Allow-Origin", "*");
  const obj = {"num": 100};
  setTimeout(() => res.json(obj), 1000);
  //res.status(400).json(obj); //for error testing
});


// CORSを許可する
app.use((req, res, next) => {
  res.header("Access-Control-Allow-Origin", "*");
  res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
  next();
});

app.listen(9000, (err) => {
  if (err) {
    console.log(err);
  }
  console.log("server start at port 9000")
});
```

これで、別ホストからのAPI問い合わせに対してもレスポンスを返せるようになります。
（`/api/count` は後で画面から叩くエンドポイントです。）

## Service

### src

```ts:counter.service.ts
import {Injectable} from '@angular/core';
import {Http, Headers, Response} from '@angular/http';
import {Observable, BehaviorSubject} from 'rxjs/Rx';
import {environment} from '../../environments/environment';
import {ErrorObservable} from 'rxjs/observable/ErrorObservable';

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

@Injectable()
export class CounterService {

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

  //①
  headers: Headers = new Headers({
    'Content-Type': 'application/json',
    'Accept': 'application/json',
    'X-Requested-With': 'XMLHttpRequest'
  });

  constructor(private http: Http) { }

  /* ... */

  //②
  fetchNumber(): Observable<{num: number}> {
    return this.http.get(messageEndPoint, {headers: this.headers})
      .map(res => {
        if (res.status === 200) {
          return res.json();
        } else {
          throw new Error(`response.status is bad: ${res.status}`);
        }
      }).catch(err => this.handleError(err));
  }

  //③
  private handleError(error: Response | any): ErrorObservable<string> {
    let errMsg: string;
    if (error instanceof Response) {
      const body = error.json() || '';
      const err = body.error || JSON.stringify(body);
      errMsg = `${error.status} - ${error.statusText || ''} ${err}`;
    } else {
      errMsg = error.message ? error.message : error.toString();
    }
    // console.error(errMsg);
    return Observable.throw(errMsg);
  }

  //④
  asyncIncrement(): Observable<{num: number}> {
    return this.fetchNumber()
      .do((obj: {num: number}) => this.point.next(this.point.getValue() + obj.num));
  }

}
```

今回はServiceの中で通信処理を行うので、HttpモジュールをDIしています。

画面から叩くメソッドは④の `asyncIncrement` です。ここでは、②の `fetchNumber()` から来たStreamの中で副作用を用いて状態を更新して、呼び出し元（画面側）にStreamを返しているだけです。（ちなみに、状態の更新は最新の値を元に行われるので、race conditionの心配はありません。）

②では、通信を行って結果の成否で処理を振り分けています。失敗時には③の `handleError` というところで良い感じにエラーメッセージを作っています。

ちなみに、①でheaderを指定してあげてサーバーでも対応することで、CSRFを（ほとんどの場合）防ぐことができます。

### test

```ts:counter.service.spec.ts
import {BaseRequestOptions, Http, Response, ResponseOptions} from '@angular/http';
import {MockBackend, MockConnection} from '@angular/http/testing';
import {CounterService, messageEndPoint} from './counter.service';
import {Observable} from 'rxjs/Rx';

describe('CounterService', () => {

  const defaultOptions = new BaseRequestOptions();

  //①
  it('asyncIncrement() should query url', () => {
    const httpMock: any = {get: () => null};
    spyOn(httpMock, 'get').and.returnValue(Observable.of());
    const service = new CounterService(httpMock);
    service.fetchNumber().subscribe();
    expect(httpMock.get).toHaveBeenCalledTimes(1);
    expect(httpMock.get).toHaveBeenCalledWith(messageEndPoint, jasmine.any(Object));
  });

  //②
  it('asyncIncrement() should increase point', () => {
    const backend = new MockBackend();
    const service = new CounterService(new Http(backend, defaultOptions));
    backend.connections.subscribe((connection: MockConnection) => {
      connection.mockRespond(new Response(new ResponseOptions({
        status: 200,
        body: JSON.stringify({num: 100})
      })));
    });
    expect(service.point.getValue()).toBe(0);
    service.asyncIncrement().subscribe();
    expect(service.point.getValue()).toBe(100);
  });

  //③
  it('asyncIncrement() should fail with bad response', () => {
    const backend = new MockBackend();
    const service = new CounterService(new Http(backend, defaultOptions));
    backend.connections.subscribe((connection: MockConnection) => {
      connection.mockRespond(new Response(new ResponseOptions({
        status: 400,
        body: JSON.stringify({error: 'WTF'})
      })));
    });
    service.asyncIncrement().subscribe(null, (err: string) => {
      expect(err).toBe(`response.status is bad: 400`);
    });
  });
});
```

ここでは３つのテストを行っています。

①では、通信処理を行うときに正しいURLに問い合わせていることの確認をします。

②では、通信処理の成功時に状態が正しく変わることの確認をします。
ここは少々特殊で、AngularのHttpモジュールは内部にBackendというものを持っていて、実質ここで通信処理が行われます。Angularではここのテスト用にMockBackendというオブジェクトを用意していて、MockBackendにconnectionが要求された時にダミーのレスポンスが返るように設定することができます。
こうすることで、 `http.get` が任意のレスポンスを返すようにし、色々な挙動のテストをすることができるのです。（ぶっちゃけAngularのHttpモジュールめんどくさいのですが、Angular wayに乗っかっておきます。）

③では、通信処理の失敗時にSubscriberにエラーを通知できることの確認をします。
先ほど同様でレスポンスに400を返すことで、Subscriberにエラーが渡ってきます。

以上となります。
前回同様、ここはAngularの基盤に依存していないので、あえて自前でDIすることでテストを書いてみました。

## Component

### src

```html:app.component.html
<p>Counter</p>
<p *ngIf="isLoading()">Loading... Queue: {{loadingCount}}</p>
<p>Point: {{point}}</p>
<button (click)="increment(3)">Increment 3</button>
<button (click)="decrement(2)">Decrement 2</button>
<button (click)="asyncIncrement()">AsyncIncrement 100</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 {

  loadingCount = 0; // 複数リクエストが来ることがあるのでboolでなくnumber

  /* ... */

  async asyncIncrement() {
    this.loadingCount ++;
    try {
      await this.counterService.asyncIncrement().toPromise();
    } catch (err) {
      console.error(err);
    } finally {
      this.loadingCount --;
    }
  }

  isLoading(): boolean {
    return this.loadingCount !== 0;
  }
}
```

非同期処理である、 `asyncIncrement` を呼ぶところを追加しています。
ここでは、返り値のObservableを使いやすいようにPromiseに変換して、成否に応じて処理を分岐させています。

また、「loading中を示すぐるぐるするやつ」を表示するための状態である `loadingCount` を管理させるようにしていて、通信中は「Loading...」の表示がされるようになっています。

### 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', () => {

  /* ... */

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

  //②
  it('should log when asyncIncrement failed', async () => {
    const fixture = TestBed.createComponent(AppComponent);
    const service = fixture.debugElement.injector.get(CounterService);
    spyOn(service, 'asyncIncrement').and.returnValue(Observable.throw('WTF'));
    spyOn(console, 'error');
    const button = fixture.debugElement.nativeElement.querySelectorAll('button')[2];
    await button.click();
    expect(console.error).toHaveBeenCalledTimes(1);
    expect(console.error).toHaveBeenCalledWith('WTF');
  });
});
```

①は前回と同じで、ボタンを押したらちゃんとServiceの `asyncIncrement` が呼ばれていることを確認しています。

②は、 `asyncIncrement` がエラーを返した場合のテストです。この画面ではconsole.errorが呼ばれることになっているのでそれをテストしています。
注意点としては、 `asyncIncrement` をPromiseとして扱っているため、async/awaitで書いてあげる必要があることです。

## 動作確認

```
npm run build
```

をした後に、

```
npm run server:static
//別プロセスとして
npm run server:api
```

でAPIサーバと静的リソース配信サーバを立ち上げて、
localhost:3000で動作確認できます。

## デプロイ

APIサーバのエンドポイントは開発時と本番時で違いますよね？
こういう時はAngular CLIで作ったプロジェクトに備わっている `environment` という仕組みで、本番ビルド時の定数値を書き換えることで実現できます。

```ts:environment.prod.ts
import {Environment} from '../main';
export const environment: Environment = {
  production: true,
  host: 'http://production.example.com'
};

```

次回はルーティングを扱ってようと思います。