AngularJS1.5とJasmineでComponentのユニットテストを書いてみる


[前回](/suetake/items/f61b4413eefeb60ef7fb)、AngularJS1.5から使えるようになった`component()`を用いて、シンプルなTODOアプリを作成してみました。
今回はそのアプリを教材としてユニットテストを作成していきます。

## Jasmineとは

JasmineはJavascript用のテストフレームワークです。
公式サイトにも`Behavior-Driven JavaScript`とある通り、ビヘイビア駆動を意識してテストコードを記述していきます。
ビヘイビア駆動に関する詳細は[こちら](https://ja.wikipedia.org/wiki/%E3%83%93%E3%83%98%E3%82%A4%E3%83%93%E3%82%A2%E9%A7%86%E5%8B%95%E9%96%8B%E7%99%BA)(Wikipedia)


## 準備

今回はJasmineのStandalone Distributionを用いてテストを行います。
[Jasmineのgithubリポジトリ](https://github.com/jasmine/jasmine/releases)から`jasmine-standalone-2.4.1.zip`をダウンロードし、解凍します。
出てきたフォルダ内にある`lib`がJasmineの本体、`SpecRunner.html`にアクセスすることでテストを開始することが出来ます。
今回作成するspecファイルは`SpecRunnner.html`と同一階層にある`spec`というフォルダ内に`todoListSpec.js`として配置します。

前回作成したアプリも含めた最終的なディレクトリ構造は下記の通りです。

```text:directory
.
├── test
│   ├── lib
│   │   └── jasmine-2.4.1
│   │       ├── boot.js
│   │       ├── console.js
│   │       ├── jasmine.css
│   │       ├── jasmine_favicon.png
│   │       ├── jasmine-html.js
│   │       └── jasmine.js
│   ├── spec
│   │   └── todoListSpec.js
│   ├── MIT.LICENSE
│   └── SpecRunner.html
├── todoApp
│   ├── components
│   │   └── todoList.js
│   ├── controllers
│   │   └── todoListController.js
│   ├── factories
│   │   └── tasksResource.js
│   ├── app.js
│   └── mock.js
└── index.html
```

`test`ディレクトリ以下が今回作成したファイル郡となります。

- lib
  - Jasmine本体です、読み込めれば位置はどこでも構いません
- spec
  - 作成したspecファイルを配置する場所です。今回は`todoListSpec.js`を作成しています
- MIT.LICENSE
  - Jasmineに元から同梱されているライセンスファイルです。Jasmineの動作には一切必要ありません
- SpecRunner.html
  - このファイルにアクセスすることでテストが開始され、実行結果がレンダリングされます。


## テスト作成

では早速テストを作成していきましょう。今回作成するファイルは`SpecRunner.html`と`todoListSpec.js`の2ファイルです。

### SpecRunner.html

```html:SpecRunner.html
<!DOCTYPE html>
<html>
    <head>
    
        <meta charset="utf-8">
        <title>Jasmine Spec Runner v2.4.1</title>
    
        <link rel="shortcut icon" type="image/png" href="lib/jasmine-2.4.1/jasmine_favicon.png">
        <link rel="stylesheet" href="lib/jasmine-2.4.1/jasmine.css">
    
        <script src="lib/jasmine-2.4.1/jasmine.js"></script>
        <script src="lib/jasmine-2.4.1/jasmine-html.js"></script>
        <script src="lib/jasmine-2.4.1/boot.js"></script>
    
        <!-- include source files here... -->
        <script src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.2/angular.min.js"></script>
        <script src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.2/angular-resource.min.js"></script>
        <script src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.2/angular-mocks.js"></script>
    
        <script src="../todoApp/app.js"></script>
        <script src="../todoApp/mock.js"></script>
        <script src="../todoApp/factories/tasksResource.js"></script>
        <script src="../todoApp/components/todoList.js"></script>
        <script src="../todoApp/controllers/todoListController.js"></script>
    
        <!-- include spec files here... -->
        <script src="spec/todoListSpec.js"></script>
    
    </head>
    <body>
    </body>
</html>
```

`jasmine-standalone-2.4.1.zip`を解凍してきた際に出てきた`SpecRunner.html`を多少編集しただけです。AngularJS本体と前回の記事で作成したjsファイル全てを読み込み（[`index.html`](/suetake/items/f61b4413eefeb60ef7fb#indexhtml)で読み込んでいたもの）、最後にこれから作成するspecファイル（`todoListSpec.js`）を読み込んでいます。


### todoListSpec.html

このファイルがユニットテストの本体となります。
Jasmineでは`discribe()`でテストのグループを定義し、`it()`で詳細なテスト内容を定義していきます。`discribe()`を入れ子にすることも出来ます。
関連性のあるテストを`describe()`によってグルーピングし、`it()`は仕様を記述するように定義していくと良いでしょう。
後は上から順に読んでいけば雰囲気をつかめるようかなり細かくコメントを入れたので、各行コメントと併せて読み進めてみてください。

```js:todoListSpec.html
/**
 * todoListコンポーネントに関連するテストグループ
 */
describe('todoListコンポーネントのテスト', function() {

    var $compile,
        $rootScope;

    /**
     * `beforeEach()`は`describe()`ブロック内の各`it()`が実行される前に必ず実行される
     * `module()`により`todoApp`をロードし利用出来るように
     */
    beforeEach(module('todoApp'));

    /**
     * $compileと$rootScopeへの参照を取得し、先に用意した変数へ格納
     * これにより`discribe()`ブロック内のテストにて利用可能となる
     * `inject()`によってサービスを注入（参照を取得）したい場合、
     * そのサービス名を'_'で囲ったパラメーター名を引数として記述する
     * 例えば`_myService_` は`myService`への参照として解決される
     */
    beforeEach(inject(function(_$compile_, _$rootScope_) {
        $compile = _$compile_;
        $rootScope = _$rootScope_;
    }));

    /**
     * `afterEach()`は`describe()`ブロック内の各`it()`が実行された後に必ず実行される
     */
    afterEach(inject(function(_$httpBackend_) {

        // 全てのリクエストが、expectのAPI作成を介して定義されているかを検証
        _$httpBackend_.verifyNoOutstandingExpectation();

        // フラッシュする必要がある、未処理のリクエストがないことを確認
        _$httpBackend_.verifyNoOutstandingRequest();
    }));

    it('コンポーネントが適切な要素へ置換されているか', function() {

        // コンポーネントをコンパイル
        var component = $compile('<todo-list></todo-list>')($rootScope);

        // コンポーネントのテンプレートに含まれているであろう文字列が存在しているか確認
        expect(component.html()).toContain('追加');
    });

    it('コンポーネント初期化後、`taskResource.query()`が1回のみ実行されているか', inject(function(_tasksResource_) {

        // コンポーネント初期化前にtasksResourceのqueryメソッドを監視対象とする
        // 監視対象に登録されたメソッドは本来の挙動を示さなくなる
        spyOn(_tasksResource_, 'query');

        // コンポーネントをコンパイル
        var component = $compile('<todo-list></todo-list>')($rootScope);

        // 監視下においたメソッドが実行されているかどうか確認
        expect(_tasksResource_.query).toHaveBeenCalled();

        // `query()`が実行された回数が1回であることを確認する
        expect(_tasksResource_.query.calls.count()).toBe(1);

        // todoListコンポーネントで利用しているコントローラーを取得
        var controller = component.controller('todoList');

        // taskResourceの`query()`は本来の挙動を示さないため
        // コントローラーの`_this.items`はundefinedとなる
        expect(controller.items).toBeUndefined();
    }));

    it('コンポーネント初期化後、`taskResource.query()`が実行され、`controller.items`へレスポンスを格納できているか', inject(function(_$httpBackend_, _tasksResource_) {

        // /api/tasksへGETリクエストされた際のレスポンスを定義
        // このURIへリクエストが行われた場合、空オブジェクトが5個返却されると仮定
        _$httpBackend_.expectGET('/api/tasks').respond([
            {}, {}, {}, {}, {}
        ]);

        // `.and.callThrough()`とすることで本来の実装も実行させることが出来る
        spyOn(_tasksResource_, 'query').and.callThrough();

        // コンポーネントをコンパイル
        var component = $compile('<todo-list></todo-list>')($rootScope);

        // 監視下においたメソッドが実行されているかどうか確認
        expect(_tasksResource_.query).toHaveBeenCalled();

        // todoListコンポーネントで利用しているコントローラーを取得
        var controller = component.controller('todoList');

        // /api/tasksへのリクエストはまだ完了していないので`controller.items.length`は0のはず
        expect(controller.items.length).toBe(0);

        // 保留中のリクエストをフラッシュ（完了）させる
        _$httpBackend_.flush();

        // 全てのリクエストが完了したので`controller.items.length`は5のはず
        expect(controller.items.length).toBe(5);
    }));

    it('入力欄が空の場合、保存処理が走っていないか', inject(function(_$controller_, _tasksResource_) {

        // コントローラーの挙動のみの確認を行いたいので直接コントローラーを取得
        var controller = _$controller_('todoListController', {$scope : $rootScope});

        // 未入力の場合通信が走らないことを確認するため、特定の関数を監視
        // 今回はResourceの`save()`が実行された場合に例外を投げるように設定
        spyOn(_tasksResource_, 'save').and.throwError('保存処理が走っています');

        // 保存処理を実行、通信が走らないことを確認
        var result = controller.addTask();

        // undefinedが返されていることを確認
        expect(result).toBeUndefined();
    }));

    it('入力値がある場合、正常に保存処理が走るか', inject(function(_$controller_, _tasksResource_, _$httpBackend_) {

        // /api/tasksへPOSTされた際のレスポンスを定義
        // このURIへリクエストが行われた場合、POSTしたpayload（データ）をそのまま返却するよう設定
        _$httpBackend_.expectPOST('/api/tasks').respond();

        // コントローラーを取得
        var controller = _$controller_('todoListController', {$scope : $rootScope});

        // 今度は値を入力したと仮定して`controller.data.text`に値をセット
        controller.data.text = 'test task';

        // Resourceの`save()`関数を監視、本来の実装も実行させる
        spyOn(_tasksResource_, 'save').and.callThrough();

        // 保存処理を実行
        controller.addTask();

        // Resourceの`save()`が呼ばれているか
        expect(_tasksResource_.save.calls.count()).toBe(1);

        // `controller.data.text`がクリアされているか
        expect(controller.data.text).toBeUndefined();

        // まだ通信は完了していないので`controller.items.length`は0のはず
        expect(controller.items.length).toBe(0);

        // 保留中のリクエストをフラッシュ
        _$httpBackend_.flush();

        // `controller.items`が1件増えているか
        expect(controller.items.length).toBe(1);

        // `controller.items`の1件目のデータのtitleが'test task'かを（一応）確認
        expect(controller.items[0].title).toBe('test task');
    }));


    it('正常に削除処理が行えるか', inject(function(_$controller_, _$httpBackend_) {

        // タスク追加用にbackendを設定
        _$httpBackend_.expectPOST('/api/tasks').respond();

        // コントローラーを取得
        var controller = _$controller_('todoListController', {$scope : $rootScope});

        // テスト用にタスクを追加
        controller.data.text = 'test task';
        controller.addTask();
        _$httpBackend_.flush();

        // タスクが追加されたか確認
        expect(controller.items.length).toBe(1);

        // 存在しないindexの削除処理を実行した場合例外が投げられるか
        expect(controller.removeTask.bind(null, 99)).toThrow();

        // タスク削除時に走るリクエストの受け口を準備
        _$httpBackend_.expectDELETE(/\/api\/tasks\/\d+/).respond();

        // 追加したタスクを削除する
        controller.removeTask(0);

        // ここではまだ`$remove()`の通信が完了していないので、タスクは削除されていないはず
        expect(controller.items.length).toBe(1);

        // `$remove()`の通信を完了させる
        _$httpBackend_.flush();

        // タスクが削除されたか確認
        expect(controller.items.length).toBe(0);
    }));

});
```

## 実行

作成した`SpecRunner.html`へアクセスします。
JSファイルのロードが完了し次第、テストが実行され結果が表示されます。
この程度のテストであれば一瞬で完了するので、htmlにアクセスした瞬間に結果が表示されるかと思います。

![2f9f357a2b24afa6d56ca8aeea425cdb.png](https://qiita-image-store.s3.amazonaws.com/0/112725/10c2c59d-cd00-2f54-6d15-30bb504f72d6.png)

「6個のテストが実行され、0個の障害があった」と表示されています。つまり全て成功です。
もしテスト中にエラーが発生した場合、次のように表示されます。

![e813347c290080577d8c178266d5653b.png](https://qiita-image-store.s3.amazonaws.com/0/112725/a0551742-bfda-2be7-4553-af737ede3d00.png)

上記のエラーは、4個目のテストにて入力欄が空の場合でも保存処理が走ってしまうようにコントローラーを変更し、ワザとバグを発生させた際の結果画面です。
保存処理が実行された際の例外が正常に投げられていることが確認出来ます。
意図せずテストにコケてしまった場合でも、スタックトレースが表示されているので、それを元に原因を特定していくと作業が捗るかと思います。

## おわりに

Jasmineを用いたBDDなテストの雰囲気は伝わったでしょうか？
公式が採用しているだけあってAngularJSとJasmineは非常に相性が良く、とても記述しやすいように思います。
また記述されたテストを読む際も自然言語を読み取るようにコードリーディング出来るので、仕様の把握もしやすいです。ウィンウィンですね！（言いたいだけ）

## ということで

次回はE2Eテストをしてみようと思います。