36
45

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入門: Component/ServiceをTestまで含めて試す

Last updated at Posted at 2017-02-19

概要

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

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?