はじめに

前回はユニットテストにモックを仕込む手法を記しました。今回は予告通りにAngularJSにビルトインされているサービスを使用したアプリのテストについて記述してみようと思います。

開発プロセス例

1. 設計

前回のHelloServiceを拡張することにしましょう。現在は MyServices.HelloService.say(name)といった ‘name’ を引数として与えると ‘Hello N-A-M-E’ という文字列を返すメソッドが実装されています。今回はサービスがロードされてから一定時間(今回は10msとします)経過したら’Hello N-A-M-E’(変換あり)経過前は ‘Hello name’(変換なし)となるように拡張します。 ここではAngularJSにビルトインされている$timeoutを利用することにします。

2. 実装

1. テストのリファクタリング

まずは設計した仕様の実現の前に簡単なリファクタリングをしたいと思います。ここではbeforEach()で一度注入したサービスを複数のit()で使いたいと考えました。このような際にはdescribe()ブロックのスコープで宣言した変数に割り当ててやることで実現できます。これで各々のit()で都度inject()することなくサービスを使えます。

// helloSpec.js
describe('MyServicesのテスト', function() {
+    var refHelloService, refUpperDashService;

    beforeEach(function() {
        module('MyServices');
        angular.module('MyUtilServices', [])
            .service('UpperDashService', function() {
                this.format = function(name) {
                    return 'Q-U-A-R-T-E-T';
                };
            })
        ;
    });

+    beforeEach(inject(function(HelloService, UpperDashService) {
+        refHelloService = HelloService;
+        refUpperDashService = UpperDashService;
+    }));

-    it('HelloService のテスト', inject(function(HelloService) {
-        expect(HelloService.say('Quartet')).toEqual('Hello Q-U-A-R-T-E-T');
-    }));
+    it('HelloService のテスト', function() {
+        expect(refHelloService.say('Quartet')).toEqual('Hello Q-U-A-R-T-E-T');
+    });

-    it('HelloService のテスト。UpperDashServiceがコールされているか', inject(function(HelloService, UpperDashService) {
-        spyOn(UpperDashService, 'format');
-        HelloService.say('dummy');
-        expect(UpperDashService.format).toHaveBeenCalled();
-        expect(UpperDashService.format).toHaveBeenCalledWith('dummy');
-    }));
+    it('HelloService のテスト。UpperDashServiceがコールされているか', function() {
+        spyOn(refUpperDashService, 'format');
+        refHelloService.say('dummy');
+        expect(refUpperDashService.format).toHaveBeenCalled();
+        expect(refUpperDashService.format).toHaveBeenCalledWith('dummy');
+    });
});

これでうまくいきました。it()がこの例では二つしかありませんがさらに増えていったことを考えると都度inject()しなくても良いので楽ですね。 しかし、refHelloService,refUpperDashServiceという名前が気持ち悪いですね。どうせならHelloService,Upperdashserviceという名前で使いたいです。では試してみます。

// helloSpec.js
describe('MyServicesのテスト', function() {
-    var refHelloService, refUpperDashService;
+    var HelloService, UpperDashService;

    beforeEach(function() {
        module('MyServices');
        angular.module('MyUtilServices', [])
            .service('UpperDashService', function() {
                this.format = function(name) {
                    return 'Q-U-A-R-T-E-T';
                };
            })
        ;
    });

    beforeEach(inject(function(HelloService, UpperDashService) {
-        refHelloService = HelloService;
-        refUpperDashService = UpperDashService;
+        HelloService = HelloService;
+        UpperDashService = UpperDashService;
    }));

    it('HelloService のテスト', function() {
-        expect(refHelloService.say('Quartet')).toEqual('Hello Q-U-A-R-T-E-T');
+        expect(HelloService.say('Quartet')).toEqual('Hello Q-U-A-R-T-E-T');
    });

    it('HelloService のテスト。UpperDashServiceがコールされているか', function() {
-        spyOn(refUpperDashService, 'format');
-        refHelloService.say('dummy');
-        expect(refUpperDashService.format).toHaveBeenCalled();
-        expect(refUpperDashService.format).toHaveBeenCalledWith('dummy');
+        spyOn(UpperDashService, 'format');
+        HelloService.say('dummy');
+        expect(UpperDashService.format).toHaveBeenCalled();
+        expect(UpperDashService.format).toHaveBeenCalledWith('dummy');
    });
});

以下のようにテストが落ちてしまいました。HelloServiceUpperdashserviceもオブジェクトとして認識されていないのは明らかです。

TypeError: Cannot read property 'say' of undefined
spyOn could not find an object to spy upon for format()

AngularJSではこのようなケースの解決策としてUnderscore Wrappingという手法を用意してくれています。 この手法を適用してみましょう。

// helloSpec.js
-    beforeEach(inject(function(HelloService, UpperDashService) {
-        HelloService = HelloService;
-        UpperDashService = UpperDashService;
+    beforeEach(inject(function(_HelloService_, _UpperDashService_) {
+        HelloService = _HelloService_;
+        UpperDashService = _UpperDashService_;
    }));

これでテストが通りました。 これより設計した仕様を満たすように実装を行っていきます。

2. テストを粗く修正

// helloSpec.js
    it('HelloService のテスト', inject(function(HelloService) {
+        expect(HelloService.say('Quartet')).toEqual('Hello Quartet');
+
+        // 10ms経過

        expect(HelloService.say('Quartet')).toEqual('Hello Q-U-A-R-T-E-T');
    }));

以下のメッセージが表示されテストはエラーとなりましたね。このまま実装に手を付けてみます。

Expected 'Hello Q-U-A-R-T-E-T' to equal 'Hello Quartet'.

3. 「フラグが立っていたら変換する。そうでなかったら変換なし」というロジックを実装。

// myServices.js
angular.module('MyServices', ['MyUtilServices'])
    .service('HelloService', ['UpperDashService', function(UpperDashService) {
+        var expired = false;
        this.say = function(name) {
+           if (expired) {
                var upperDashedName = UpperDashService.format(name);
                return 'Hello ' + upperDashedName;
+           } else {
+               return 'Hello ' + name;
+           }
        }
    }])
;

さらにエラーメッセージが増えましたがとりあえずは気にせず実装を進めます。

4. 「10msたったらフラグを立てる」というロジックを実装。

AngularJSにビルトインされている$timeoutを使用してフラグの操作をしましょう。今回は先述したように10msをフラグの変更しきい値としました。

// myServices.js
angular.module('MyServices', ['MyUtilServices'])
-    .service('HelloService', ['UpperDashService', function(UpperDashService) {
+    .service('HelloService', ['UpperDashService', '$timeout', function(UpperDashService, $timeout) {
        var expired = false;
+        $timeout(function () {
+            expired = true;
+        }, 10);
        this.say = function(name) {
            if (expired) {
                var upperDashedName = UpperDashService.format(name);
                return 'Hello ' + upperDashedName;
            } else {
                return 'Hello ' + name;
            }
        }
    }])
;

エラーメッセージは変わらないと思います。ここで実装に即してテストを適用していきます。

5. テストで $timeoutを使えるようにする

2-1.で行ったのと同様の手法を採用します。

// helloSpec.js
describe('MyServicesのテスト', function() {
    var HelloService, UpperDashService;
+    var $timeout;

    beforeEach(function() {
        module('MyServices');
        angular.module('MyUtilServices', [])
            .service('UpperDashService', function() {
                this.format = function(name) {
                    return 'Q-U-A-R-T-E-T';
                };
            })
        ;
    });

-    beforeEach(inject(function(_HelloService_, _UpperDashService_) {
+    beforeEach(inject(function(_HelloService_, _UpperDashService_, _$timeout_) {
        HelloService = _HelloService_;
        UpperDashService = _UpperDashService_;
+        $timeout = _$timeout_;
    }));

6. テストで $timeoutを操作して時間を経過させる

$timeout.flush(delay)を実行することで時間を経過させることができます。

// helloSpec.js
    it('HelloService のテスト', inject(function(HelloService) {
        expect(HelloService.say('Quartet')).toEqual('Hello Quartet');

        // 10ms経過
+        $timeout.flush(10);

        expect(HelloService.say('Quartet')).toEqual('Hello Q-U-A-R-T-E-T');
    }));

これで一つ目のテストが通りました。もう一つのテストも実体に即して修正してみます。 5ms経過後にHelloService.say()を実行してもUpperDashService.format()はコールされず、 さらに5ms経過(通算10ms経過)後にはHelloService.say()を実行するとUpperDashService.format()がコールされていることが担保されました。

// helloSpec.js
    it('HelloService のテスト。UpperDashServiceがコールされているか', inject(function(UpperDashService) {
        spyOn(UpperDashService, 'format');

+        $timeout.flush(5);

+        HelloService.say('dummy');
+        expect(UpperDashService.format).not.toHaveBeenCalled();
+        expect(UpperDashService.format).not.toHaveBeenCalledWith('dummy');

+        $timeout.flush(5);

        HelloService.say('dummy');
        expect(UpperDashService.format).toHaveBeenCalled();
        expect(UpperDashService.format).toHaveBeenCalledWith('dummy');
    }));

最後に

実際のアプリケーションではAjaxを利用して外部のAPIとやりとりをしたりユーザに確認画面で入力を求めたりと非同期な処理をすることが往々にしてあります。このような際に肝となるのは$q$scope.$apply()で、今回利用した$timeoutはこれらを特に意識しなくても直感的に使用できるような実装になっています。 このあたりを更に深く掘り下げたい方はAngularJS and scope.$apply - Jim Hoskinsをご覧いただくと良いかもしれません。

ちなみに当エントリのコードはこちらにありますのでご参考まで。

http://plnkr.co/edit/BdML9L