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

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

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

* [その１. ビルド編](http://qiita.com/Takumon/items/572438809384e2e11792)
* その２. テスト編　⇦　今回はココ
* その３. Dockerビルド編


## その２. テスト編
[その１. ビルド編](http://qiita.com/Takumon/items/572438809384e2e11792)では、Angular CLIで作成したプロジェクトをベースに、
MongoDBに登録しているメッセージを画面に一覧で表示するアプリを作成しました。
今回は、クライアント側とサーバ側の[Jasmine](https://jasmine.github.io/)を使った単体テスト、[Protractor](http://www.protractortest.org/#/)を使ったE2Eテスト、それらを実行するnpm scriptsを作成します。
最終的には下記のように`npm test`コマンドで単体テストが実行できるようになります。

![10_単体テスト実施イメージ.png](https://qiita-image-store.s3.amazonaws.com/0/49915/641a661f-3624-c4f1-373f-cb4319304e02.png)

またE2Eテストは`npm run e2e`コマンドで実施できるようになります。
![20_E2Eテスト実施イメージ.png](https://qiita-image-store.s3.amazonaws.com/0/49915/5ad84a6a-0e8e-a80b-c7c5-538b70aa9259.png)


### プロジェクト構成
今回のチュートリアル終了すると下記のようなプロジェクトの構成になります。
[その１. ビルド編](http://qiita.com/Takumon/items/572438809384e2e11792)で作成したものをベースにテスト用の資産を追加します。詳細は[リポジトリ](https://github.com/Takumon/angular4-express4-typescritp2/tree/test)を参照してください。

```:プロジェクト構成（完成イメージ）
.
├── 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`](https://www.npmjs.com/package/zone.js)
    * クライアント側のテストで使用します。Angular CLIでプロジェクトを作成した時点でインストールされていますが、テスト実行時に`Failed: Cannot create property '__creationTrace__' on string '__zone_symbol__optimizedZoneEventTask'`のようなエラーが出ます。[GitHubのissues](https://github.com/angular/zone.js/issues/832)によるとv0.8.12はエラーが出ないそうなので、v0.8.12を再インストールします。
* [`supertest`](https://www.npmjs.com/package/supertest)
    * サーバ側のテストで使用します。APIテストを簡単にしてくれます。

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

#### src/app/app.component.spec.ts
コンポーネントは画面描画についてテストします。
コンポーネントで使うサービスは、`TestBed`の`overrideComponent`メソッドを使ってモック化します。


```js: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('メッセージを３件保持しているか', async(() => {
    expect(component.messages).toEqual([
        { message : 'テスト用メッセージ1' },
        { message : 'テスト用メッセージ2' },
        { message : 'テスト用メッセージ3' }
    ]);
  }));


  it('画面にメッセージが３件表示されているか', 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`メソッドのテストは割愛しています。

```js: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ディレクトリを作ってテストコードを書いていきます。
どちらかというと結合テストよりで、１つ１つの資産に対してではなくapp.tsに対して、実際にDBに接続しながらAPIテストを行います。規模が小さい場合はコレで充分だと思います。
またExpressのテストフレームワークは[Mocha](https://mochajs.org/)が一般的ですが、クライアント側と統一したいので、今回は[Jasmine](https://jasmine.github.io/)を使うことにします。

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

```js: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: 'テスト用メッセージ１' },
        { message: 'テスト用メッセージ２' },
        { message: 'テスト用メッセージ３' },
      ];
　　　　　　　　　　　　
　　　　　　　　　　　　// 事前準備（テストデータを作成）
      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('テスト用メッセージ１');
            expect(sortedMessages[1].message).toEqual('テスト用メッセージ２');
            expect(sortedMessages[2].message).toEqual('テスト用メッセージ３');
          })
          .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
[前回](http://qiita.com/Takumon/items/572438809384e2e11792#pakcagejson)作成したものをベースに単体テストのスクリプトを追加してください。

```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](https://www.npmjs.com/package/jasmine-spec-reporter)を使いましょう。このライブラリはAngular CLIで作ったプロジェクトにはデフォルトでインストール済みです。

```js: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`を使って指定してください。

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



#### server_test/tsconfig.server_test.json

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

```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](http://www.protractortest.org/#/)を使ったテストコードを作成します。

#### e2e/app.e2e-spec.ts
基本的にelementメソッドで要素を取得して、sendKeysメソッドやclickメソッドで操作を行います。

```js: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"コマンドは削除して、スクリプトに下記を追加してください。

```json: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`はクライアント資産だけコンパイルして起動する処理が入っているからです。今回はビルドしたアプリ(クライアントとサーバが１つにまとまったアプリ)に対してテストします。
* **webdriver:update**でE2Eテストに必要なWebDriverをインストールまたは更新します。
* **webdriver:start**でWebDriverを起動します。Protractorのテストは事前にWebDriverを起動しておく必要があります。
* **protractor**でE2Eテストを実行します。起動時の設定は下で触れるprotractor.conf.jsを使います。

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

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

### 7. 試してみる
#### 単体テストを実行してみる
* MongoDBをローカルで立ち上げる
    * 具体的な方法について触れませんが、Dockerでもなんでもいいのでローカルにポート27017でMongoDBを立ち上げておいてください。DB、テーブルの作成などは不要です。

* プロジェクト直下で`npm test`コマンドを実行するとテストが実行されます。クライアント側のテスト結果はブラウザに、サーバ側はターミナル（またはコンソール）に表示されます。資産はウォッチしているので、テストコードを修正すると、コンパイルされ再度テストが実行されるでしょう。

![10_単体テスト実施イメージ.png](https://qiita-image-store.s3.amazonaws.com/0/49915/641a661f-3624-c4f1-373f-cb4319304e02.png)



#### E2Eテストを実行してみる
* MongoDBをローカルで立ち上げる
    * これも単体テストと同じでDBを事前に起動しておいてください。
 
* ビルドしたアプリを起動する
    * プロジェクト直下で`npm run buildRun`を実行し、ビルド資産を起動します。

* `npm run e2e`する
    * 別ターミナル（またはコマンドプロンプト）を開き、プロジェクト直下で`npm run e2e`コマンドを実行します。するとブラウザが立ち上がりテストが実行されます。
![20_E2Eテスト実施イメージ.png](https://qiita-image-store.s3.amazonaws.com/0/49915/5ad84a6a-0e8e-a80b-c7c5-538b70aa9259.png)


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