26
26

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-02-19

概要

前回(Angular入門: Component/ServiceをTestまで含めて試す)の続きです。

今回は

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

を書いていきます。

作った画面

async.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サーバーを立てます。
コードはすごくシンプルにこんな感じです。

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

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

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`);
    });
  });
});

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

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

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

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

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

Component

src

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>
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

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 という仕組みで、本番ビルド時の定数値を書き換えることで実現できます。

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

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

26
26
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26
26

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?