やりたいこと
- Angular CLI使って、MEANスタック(MongoDB + Express + Angular + NodeJS)のアプリを作りたい。どうせならサーバ側もTypeScriptで作りたい。
- フロント側とサーバ側の両方をwebpack、gulpなどは使わずにnpm scriptsだけでビルド、テストできるようにしたい。
- Dockerを使ってアプリを簡単に配布したい。
これらを達成するための最小構成プロジェクトの作り方を3回に分けて紹介します。
- その1. ビルド編
- その2. テスト編 ⇦ 今回はココ
- その3. Dockerビルド編
その2. テスト編
その1. ビルド編では、Angular CLIで作成したプロジェクトをベースに、
MongoDBに登録しているメッセージを画面に一覧で表示するアプリを作成しました。
今回は、クライアント側とサーバ側のJasmineを使った単体テスト、Protractorを使ったE2Eテスト、それらを実行するnpm scriptsを作成します。
最終的には下記のようにnpm test
コマンドで単体テストが実行できるようになります。
またE2Eテストはnpm run e2e
コマンドで実施できるようになります。
プロジェクト構成
今回のチュートリアル終了すると下記のようなプロジェクトの構成になります。
その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を再インストールします。
- クライアント側のテストで使用します。Angular CLIでプロジェクトを作成した時点でインストールされていますが、テスト実行時に
-
supertest
- サーバ側のテストで使用します。APIテストを簡単にしてくれます。
2. クライアント側を作成
コンポーネント(app.component.ts)とサービス(message.service.ts)に対するテストコードを作成します。
クライアント側のテスト実行にはng test
コマンドを使うので、ビルド周りの設定は不要です。
src/app/app.component.spec.ts
コンポーネントは画面描画についてテストします。
コンポーネントで使うサービスは、TestBed
のoverrideComponent
メソッドを使ってモック化します。
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
メソッドのテストは割愛しています。
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
メソッドで処理を置き換えます。
全て載せると冗長なのでメッセージ登録のテストは割愛しています。
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
前回作成したものをベースに単体テストのスクリプトを追加してください。
"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で作ったプロジェクトにはデフォルトでインストール済みです。
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
を使って指定してください。
export const config = {
spec_dir: '.',
spec_files: [
`${__dirname}/*spec.js`
],
'stopSpecOnExpectationFailure': false,
'random': false
};
server_test/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
を指定するとコンパイルした時に下記のように出力されてしまいます。
.
└── dist
└── server_test
├── server
└── server_test
.
└── dist
├── server
└── server_test
5. E2Eテストを作成
単体テストを作成したので次はE2Eテストを作りましょう。
Angular CLIで作成したプロジェクトにデフォルトで用意されているProtractorを使ったテストコードを作成します。
e2e/app.e2e-spec.ts
基本的にelementメソッドで要素を取得して、sendKeysメソッドやclickメソッドで操作を行います。
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"コマンドは削除して、スクリプトに下記を追加してください。
"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を指定します。
exports.config = {
...
baseUrl: 'http://localhost:3000/',
...
}
7. 試してみる
単体テストを実行してみる
-
MongoDBをローカルで立ち上げる
- 具体的な方法について触れませんが、Dockerでもなんでもいいのでローカルにポート27017でMongoDBを立ち上げておいてください。DB、テーブルの作成などは不要です。
-
プロジェクト直下で
npm test
コマンドを実行するとテストが実行されます。クライアント側のテスト結果はブラウザに、サーバ側はターミナル(またはコンソール)に表示されます。資産はウォッチしているので、テストコードを修正すると、コンパイルされ再度テストが実行されるでしょう。
E2Eテストを実行してみる
-
MongoDBをローカルで立ち上げる
- これも単体テストと同じでDBを事前に起動しておいてください。
-
ビルドしたアプリを起動する
- プロジェクト直下で
npm run buildRun
を実行し、ビルド資産を起動します。
- プロジェクト直下で
-
npm run e2e
する
終わりに
今回はMEANスタックアプリの単体テスト、E2Eテストについて紹介しました。
これでビルドとテストができるようになったので、次回「その3. Dockerデプロイ編」では、Dockerでアプリを起動する方法とDockerでアプリのイメージを作ってデプロイする方法ついて紹介します。