Fork me on GitHub

Gatsby Starter Qiita

Angular4 + Express4 + MongoDB3 + TypeScript2 の最小構成プロジェクトをAngular CLIベースで構築する。(ビルド、テスト、Dockerデプロイまで) その2. テスト編

2017/07/24

Takumon
Takumon
SIer

やりたいこと

  • Angular CLI使って、MEANスタック(MongoDB + Express + Angular + NodeJS)のアプリを作りたい。どうせならサーバ側もTypeScriptで作りたい。
  • フロント側とサーバ側の両方をwebpack、gulpなどは使わずにnpm scriptsだけでビルド、テストできるようにしたい。
  • Dockerを使ってアプリを簡単に配布したい。

これらを達成するための最小構成プロジェクトの作り方を3回に分けて紹介します。

その2. テスト編

その1. ビルド編では、Angular CLIで作成したプロジェクトをベースに、
MongoDBに登録しているメッセージを画面に一覧で表示するアプリを作成しました。
今回は、クライアント側とサーバ側のJasmineを使った単体テスト、Protractorを使ったE2Eテスト、それらを実行するnpm scriptsを作成します。
最終的には下記のようにnpm testコマンドで単体テストが実行できるようになります。

10_単体テスト実施イメージ.png

またE2Eテストはnpm run e2eコマンドで実施できるようになります。
20_E2Eテスト実施イメージ.png

プロジェクト構成

今回のチュートリアル終了すると下記のようなプロジェクトの構成になります。
その1. ビルド編で作成したものをベースにテスト用の資産を追加します。詳細はリポジトリを参照してください。

プロジェクト構成(完成イメージ)
.
├── dist                              ・・・(1) コンパイル資産出力先
│   ├── server
│   │   ├── ...
│   │   ...
│   │ 
│   └── server_test                      ・・・(1-1) コンパイルされたサーバ側テスト資産
│       ├── app.spec.js
│       ├── app.spec.js.map
│       ├── test.server.conf.js
│       ├── test.server.conf.js.map
│       ├── test.server.js
│       └── test.server.js.map
├── e2e                                ・・・(2) E2Eテスト資産
│   ├── app.e2e-spec.ts
│   ├── app.po.ts
│   └── tsconfig.e2e.json
├── node_modules
│   ├── ...
│   ...
│
├── server
│   ├── ...
│   ...
│
├── server_test                         ・・・(3) サーバ側テスト資産
│   ├── app.spec.ts
│   ├── test.server.conf.ts
│   ├── test.server.ts
│   └── tsconfig.server_test.json
├── src
│   ├── app
│   │   ├── app.component.css
│   │   ├── app.component.html
│   │   ├── app.component.spec.ts      ・・・(4) クライアント側テスト資産
│   │   ├── app.component.ts
│   │   ├── app.module.ts
│   │   └── message
│   │       ├── message.service.spec.ts ・・・(4) クライアント側テスト資産
│   │       └── message.service.ts
│   ...
│
├── package-lock.json
├── package.json
├── protractor.conf.js                   ・・・(5) E2Eテスト設定ファイル
├── proxy.conf.json
├── karma.conf.js
├── tsconfig.json
├── tslint.json
└── README.md

各資産について

(1) dist

コンパイル資産出力先。

(1-1) dist/server_test

コンパイルされたサーバ側テスト資産(JSファイル)の出力先。
デプロイを考慮して本資産(dist/server)とは別ディレクトリにしています。

(2) server_test

サーバ側テスト資産のディレクトリ。
コンパイル用の設定ファイルとテスト用の設定ファイルもココに格納します。

(3) e2e

E2Eテスト用資産のディレクトリ。

(4) src/app配下のspec.tsファイル

フロント側テスト資産。
コンパイルやテストはngコマンドで実施します。

(5) protractor.conf.js

E2Eテスト設定ファイル。
今回はAngular CLIでプロジェクトが作成するデフォルトから少しだけ修正します。

構築手順

1. テストに必要なライブラリをインストール

$ npm install --save zone.js@0.8.12
$ npm install --save-dev @types/mongoose nodemon npm-run-all
  • zone.js@0.8.12
    • クライアント側のテストで使用します。Angular CLIでプロジェクトを作成した時点でインストールされていますが、テスト実行時にFailed: Cannot create property '__creationTrace__' on string '__zone_symbol__optimizedZoneEventTask'のようなエラーが出ます。GitHubのissuesによるとv0.8.12はエラーが出ないそうなので、v0.8.12を再インストールします。
  • supertest
    • サーバ側のテストで使用します。APIテストを簡単にしてくれます。

2. クライアント側を作成

コンポーネント(app.component.ts)とサービス(message.service.ts)に対するテストコードを作成します。
クライアント側のテスト実行にはng testコマンドを使うので、ビルド周りの設定は不要です。

src/app/app.component.spec.ts

コンポーネントは画面描画についてテストします。
コンポーネントで使うサービスは、TestBedoverrideComponentメソッドを使ってモック化します。

app.component.spec.ts
import { async, ComponentFixture, TestBed, inject } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { Observable } from 'rxjs/Rx';
import 'rxjs/Rx';

import { AppComponent } from './app.component';
import { MessageService } from './message/message.service';

describe('AppComponent', () => {
  // テスト対象のComponent
  let component: AppComponent;

  // テスト対象のFixture
  let fixture: ComponentFixture<AppComponent>;

  // MessageServiceのモック
  class MessageServiceMock {
    getAll(): Observable<any> {
      const response =  { messages : [
        { message : 'テスト用メッセージ1' },
        { message : 'テスト用メッセージ2' },
        { message : 'テスト用メッセージ3' }
      ]};

      return Observable.from([response]);
    }
  }

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [ FormsModule ],
      declarations: [
        AppComponent
      ],
    })
         // MessageServiceのモックを設定
    .overrideComponent(AppComponent, {
      set: {
        providers: [
          { provide: MessageService, useClass: MessageServiceMock },
        ]
      }
    })
    .compileComponents();

    fixture = TestBed.createComponent(AppComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  }));

  it('オブジェクトが生成されるか', async(() => {
    const app = fixture.debugElement.componentInstance;
    expect(app).toBeTruthy();
  }));


  it('メッセージを3件保持しているか', async(() => {
    expect(component.messages).toEqual([
        { message : 'テスト用メッセージ1' },
        { message : 'テスト用メッセージ2' },
        { message : 'テスト用メッセージ3' }
    ]);
  }));


  it('画面にメッセージが3件表示されているか', async(() => {

    const el = fixture.debugElement.nativeElement;

    expect(el.querySelectorAll('li').length).toEqual(3);
    expect(el.querySelectorAll('li')[0].textContent).toContain('テスト用メッセージ1');
    expect(el.querySelectorAll('li')[1].textContent).toContain('テスト用メッセージ2');
    expect(el.querySelectorAll('li')[2].textContent).toContain('テスト用メッセージ3');
  }));
});

src/app/message/message.service.spec.ts(一部抜粋)

サービスのテストです。
サーバとのやりとり(HTTP通信)についてはMockBackendを使ってモック化しています。
なおErrorは別途モックを作らなければなりません。
全て載せると冗長なのでregisterメソッドのテストは割愛しています。

message.service.spec.ts(一部抜粋)
import { TestBed, async, inject } from '@angular/core/testing';
import {HttpModule, BaseRequestOptions, Http, Response, ResponseOptions} from '@angular/http';
import {MockBackend, MockConnection} from '@angular/http/testing';
import { RequestMethod } from '@angular/http';

import { MessageService } from './message.service';


describe('MessageService', () => {
    // HTTP通信エラー用のモック
  class MockError extends Response implements Error {
    name: any;
    message: any;
  }

    // HTTP通信はMockBackendでモック化
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpModule],
      providers: [MessageService, {
        provide: Http,
        useFactory: (backend, options) => new Http(backend, options),
        deps: [MockBackend, BaseRequestOptions]
      }, MockBackend, BaseRequestOptions]
    });
  });

  it('オブジェクトが生成されるか', async(inject([MockBackend, MessageService], (backend: MockBackend , service: MessageService) => {
    expect(service).toBeTruthy();
  })));


  describe('getAll', () => {

    it('メッセージが取得できるか', async(inject([MockBackend, MessageService], (backend: MockBackend , service: MessageService) => {
            // HTTP通信のモックで返す具体的な値の設定
      backend.connections.subscribe((conn: MockConnection) => {
        const body =  { messages : [
          { message : 'テスト用メッセージ1' },
          { message : 'テスト用メッセージ2' },
          { message : 'テスト用メッセージ3' }
        ]};

        const ops = new ResponseOptions({
          status: 200,
          body: JSON.stringify(body)
        });

        conn.mockRespond(new Response(ops));
      });

            // リクエストの内容を検証
      backend.connections.subscribe((conn: MockConnection) => {
        expect(conn.request.url).toEqual('/api/messages');
        expect(conn.request.method).toEqual(RequestMethod.Get);
      });

            // レスポンスの内容を検証
      service.getAll().subscribe((res) => {
        expect(res.messages.length).toEqual(3);
        expect(res.messages).toEqual([
          { message : 'テスト用メッセージ1' },
          { message : 'テスト用メッセージ2' },
          { message : 'テスト用メッセージ3' }
        ]);
      });
    })));


    it('異常時にエラーハンドリングされるか', async(inject([MockBackend, MessageService], (backend: MockBackend , service: MessageService) => {
            // HTTP通信のモックで返す具体的な値の設定
      backend.connections.subscribe((conn: MockConnection) => {
        const body =  {
          title : 'エラーが発生しました。',
          error: 'エラー'
        };

        const ops = new ResponseOptions({
          status: 500,
          body: JSON.stringify(body)
        });

        conn.mockError(new MockError(ops));
      });

            // リクエストの内容を検証
      backend.connections.subscribe((conn: MockConnection) => {
        expect(conn.request.url).toEqual('/api/messages');
        expect(conn.request.method).toEqual(RequestMethod.Get);
      });

            // レスポンスの内容を検証
      service.getAll().subscribe(() => {
        fail('エラーハンドリングされなかった。');
      }, res => {
        expect(res).toEqual({
          title : 'エラーが発生しました。',
          error: 'エラー'
        });
      });
    })));

  });


});

3. サーバ側を作成

プロジェクトの直下にserver_testディレクトリを作ってテストコードを書いていきます。
どちらかというと結合テストよりで、1つ1つの資産に対してではなくapp.tsに対して、実際にDBに接続しながらAPIテストを行います。規模が小さい場合はコレで充分だと思います。
またExpressのテストフレームワークはMochaが一般的ですが、クライアント側と統一したいので、今回はJasmineを使うことにします。

server_test/app.spec.ts(一部抜粋)

ポイントとしてはテスト実行前にMessageモデルを使ってDBを初期化していることです。
それによりテストデータがテストメソッドごとに想定する形になるようにしています。
異常時のテストは、Messsageのfindメソッドでエラーが発生するようにJasmineのspyOnメソッドで処理を置き換えます。
全て載せると冗長なのでメッセージ登録のテストは割愛しています。

app.spec.ts(一部抜粋)
import * as supertest from 'supertest';

import app from '../server/app';
import { Message } from '../server/models/message';


describe('/api/messages', () => {
  const request = supertest(app);
  const endpoint = '/api/messages';

  const messageAscending = (m1, m2) => {
    if (m1.message > m2.message) {
      return 1;
    }

    if (m1.message < m2.message) {
      return -1;
    }

    return 0;
  };

  // テスト前にDBのmessagesを初期化する
  beforeEach(() => {
    Message.remove({}, () => {});
  });


  describe('Get', () => {

    it('レスポンスがjson形式でステータスコードが200か', (done) => {

             // リクエストを投げる
      request.get(endpoint)
        .expect((res) => {

                    // 検証
          expect(res.type).toEqual('application/json');
          expect(res.statusCode).toEqual(200);
        }).end(done);
    });


    it('メッセージ一覧が取得できるか', (done) => {

      const testData = [
        { message: 'テスト用メッセージ1' },
        { message: 'テスト用メッセージ2' },
        { message: 'テスト用メッセージ3' },
      ];
            
            // 事前準備(テストデータを作成)
      Message.create(testData, (erro , doc ) => {

                 // リクエストを投げる
        request.get(endpoint)
          .expect((res) => {
                        
                        // 検証
            const sortedMessages = res.body.messages.sort(messageAscending);

            expect(sortedMessages.length).toEqual(3);
            expect(sortedMessages[0].message).toEqual('テスト用メッセージ1');
            expect(sortedMessages[1].message).toEqual('テスト用メッセージ2');
            expect(sortedMessages[2].message).toEqual('テスト用メッセージ3');
          })
          .end(done);
      });
    });


    it('異常時にエラーハンドリングされるか', (done) => {

      // エラーとなるようにMessageのfindメソッドを置き換える
      spyOn(Message, 'find').and.callFake(function(callback) {
        callback(new Error('エラー'), null);
      });
             
            // リクエストを投げる
      request.get(endpoint)
        .expect((res) => {

          // 検証
          expect(res.type).toEqual('application/json');
          expect(res.statusCode).toEqual(500);

          expect(res.body.title).toEqual('エラーが発生しました。');
          expect(res.body.error).toEqual('エラー');
        })
        .end(done);
    });

  });
});

4. 単体テスト周りの環境を整備

E2Eの説明に入る前に、いったん単体テスト周りの環境を整備します。

package.json

前回作成したものをベースに単体テストのスクリプトを追加してください。

package.json
 "scripts": {
    ...
    "test": "run-p test:*",
    "test:client": "ng test",
    "test:server": "npm-run-all -s build:server_test -p watch:server_test  boot:server_test",
    "watch:server_test": "tsc -w -p ./server_test/tsconfig.server_test.json",
    "boot:server_test": "nodemon ./dist/server_test/test.server.js",
    "build:server_test": "tsc -p ./server/tsconfig.server.json",
    ...
  },
  • testでクライアント側とサーバ側のテストを実行します。
  • test:clientでクライアント側のテストを実行します。Angular CLIのngコマンドにお任せしています。
  • watch:server_testでサーバ側テスト資産をウォッチして変更があればコンパイルするようにします。
  • boot:server_testでコンパイルしたサーバ側テスト資産を起動します。nodeではなくnodemonを使うことで資産に更新があった場合でも即座に反映するようにしています。
  • build:server_testでサーバ側テスト資産をコンパイルします。コンパイル時の設定は下で触れるserver_test/test.server.conf.tsを使います。

server_test/test.server.ts

サーバ側テストの起動処理を書きます。
レポーターにはjasmine-spec-reporterを使いましょう。このライブラリはAngular CLIで作ったプロジェクトにはデフォルトでインストール済みです。

test.server.ts
import { SpecReporter, DisplayProcessor } from 'jasmine-spec-reporter';
const Jasmine = require('jasmine');
import SuiteInfo = jasmine.SuiteInfo;

import { config } from './test.server.conf';


class CustomProcessor extends DisplayProcessor {
    public displayJasmineStarted(info: SuiteInfo, log: string): string {
        return `TypeScript ${log}`;
    }
}

const runner = new Jasmine();
runner.loadConfig(config);
runner.addReporter(new SpecReporter({
    customProcessors: [CustomProcessor],
}));
runner.onComplete(function(passed){
  if ( passed ) {
    console.log('Success');
  } else {
    console.error('Failed');
  }
});

runner.execute();

server_test/test.server.conf.ts

サーバ側テスト起動時の設定です。
注意点としてspec_filesに指定する相対パスはプロジェクト直下が起点になります。そのため__dirnameを使って指定してください。

test.server.conf.ts
export const config = {
  spec_dir: '.',
  spec_files: [
    `${__dirname}/*spec.js`
  ],
  'stopSpecOnExpectationFailure': false,
  'random': false
};

server_test/tsconfig.server_test.json

サーバ側テスト資産をコンパイルする時の設定ファイルです。

tsconfig.server_test.json
{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "preserveConstEnums": true,
    "outDir": "../dist",
    "mapRoot": "../dist",
    "module": "commonjs"
  } ,
  "include": [
    "**/*.spec.ts",
    "./test.server.ts",
    "./test.server.conf.ts"
  ]
}

outDir../dist/server_testではなく../distであることに注意してください。
テスト資産はserverディレクトリ配下の資産に依存しているため、../dist/server_testを指定するとコンパイルした時に下記のように出力されてしまいます。

(悪い例)outDirに"../dist/server_test"を指定したときのコンパイル結果
.
└── dist
    └── server_test
        ├── server
        └── server_test 
(良い例)outDirに"../dist"を指定したときのコンパイル結果
.
└── dist
    ├── server
    └── server_test 

5. E2Eテストを作成

単体テストを作成したので次はE2Eテストを作りましょう。
Angular CLIで作成したプロジェクトにデフォルトで用意されているProtractorを使ったテストコードを作成します。

e2e/app.e2e-spec.ts

基本的にelementメソッドで要素を取得して、sendKeysメソッドやclickメソッドで操作を行います。

app.e2e-spec.ts
import { Angular4Express4Typescritp2Page } from './app.po';
import { browser, element, by } from 'protractor';


describe('E2Eテスト', () => {
  let page: Angular4Express4Typescritp2Page;

  beforeEach(() => {
    page = new Angular4Express4Typescritp2Page();
  });

  it('画面タイトルが正しいか', () => {
    page.navigateTo();
    expect(page.getParagraphText()).toEqual('メッセージ一覧');
  });


  it('メッセージが登録できるか', () => {
    page.navigateTo();
    const newMessage = `サンプルメッセージ ${new Date().toString()}`;
    element(by.id('registerMessage')).sendKeys(newMessage);

    element(by.id('registerMessageButton')).click();

    // 登録後メッセージ入力項目が初期化されているか
    expect(element(by.id('registerMessage')).getText()).toEqual('');

    // 登録後一覧に登録したメッセージが含まれているか
    const messages = element(by.id('messageList')).all(by.tagName('li'));
    expect(messages.last().getText()).toEqual(newMessage);
  });

});

6. E2Eテスト周りの環境を整備

package.json

Angular CILプロジェクトデフォルトの"e2e"コマンドは削除して、スクリプトに下記を追加してください。

package.json
 "scripts": {
    ...
    "e2e": "npm-run-all -s  webdriver:update -p webdriver:start protractor",
    "webdriver:update": "webdriver-manager update",
    "webdriver:start": "webdriver-manager start",
    "protractor": "protractor protractor.conf.js",
    ...
  },
  • e2eでE2Eテストを実行します。Angular CILプロジェクトデフォルトのe2eコマンド(= ng e2eコマンド)は使いません。ng e2eはクライアント資産だけコンパイルして起動する処理が入っているからです。今回はビルドしたアプリ(クライアントとサーバが1つにまとまったアプリ)に対してテストします。
  • webdriver:updateでE2Eテストに必要なWebDriverをインストールまたは更新します。
  • webdriver:startでWebDriverを起動します。Protractorのテストは事前にWebDriverを起動しておく必要があります。
  • protractorでE2Eテストを実行します。起動時の設定は下で触れるprotractor.conf.jsを使います。

protractor.conf.js

デフォルトでbaseUrlのポートは4200になっていますが、今回はビルドしたアプリに対してテストするので3000を指定します。

protractor.conf.js
exports.config = {
  ...
  baseUrl: 'http://localhost:3000/',
  ...
}

7. 試してみる

単体テストを実行してみる

  • MongoDBをローカルで立ち上げる

    • 具体的な方法について触れませんが、Dockerでもなんでもいいのでローカルにポート27017でMongoDBを立ち上げておいてください。DB、テーブルの作成などは不要です。
  • プロジェクト直下でnpm testコマンドを実行するとテストが実行されます。クライアント側のテスト結果はブラウザに、サーバ側はターミナル(またはコンソール)に表示されます。資産はウォッチしているので、テストコードを修正すると、コンパイルされ再度テストが実行されるでしょう。

10_単体テスト実施イメージ.png

E2Eテストを実行してみる

  • MongoDBをローカルで立ち上げる

    • これも単体テストと同じでDBを事前に起動しておいてください。
  • ビルドしたアプリを起動する

    • プロジェクト直下でnpm run buildRunを実行し、ビルド資産を起動します。
  • npm run e2eする

    • 別ターミナル(またはコマンドプロンプト)を開き、プロジェクト直下でnpm run e2eコマンドを実行します。するとブラウザが立ち上がりテストが実行されます。 20_E2Eテスト実施イメージ.png

終わりに

今回はMEANスタックアプリの単体テスト、E2Eテストについて紹介しました。
これでビルドとテストができるようになったので、次回「その3. Dockerデプロイ編」では、Dockerでアプリを起動する方法とDockerでアプリのイメージを作ってデプロイする方法ついて紹介します。