ユニットテスト

JavaScriptは強力な表現をすることの出来る動的型付け言語ではありますが、 コンパイラからほとんど手助けしてもらえないという事にもなります。 このため、Angularの開発陣はJavaScriptで書かれたコードには強固なテストセットが必要であると強く感じています。 Angularアプリケーションのテストが簡単に行えるように、Angularには多くの機能が組み込まれています。 そのため、テストをしないことを言い訳にすることは出来ません。

関係を混合"しない"ことが全て

ユニットテストと名付けられたそれは、コードの個別の単位でテストを行います。 ユニットテストは、"このロジックは正しかったのか?"、または"このソート関数は正しくリストを並び替えているか?"、 といった疑問に答えようとするものです。

このような疑問に答えるために、テスト下でコード単位で分離できることは非常に重要です。 何故ならソート関数をテストする際に、DOM要素や何らかのソートのためのXHR呼び出しで取得するデータのように、 関連する部品の作成を強要されたくないからです。

一般的なプロジェクトで個別に関数を呼び出せるようにするのは、非常に難しいと思われてしまうのは明白です。 その理由は開発者はよく関係を混在させたまま、全てを取り行うコードとして作り終えてしまいます。 それは、XHRからデータを読み取り、それをソートし、次にDOMを操作します。

Anglarでは、これを正しく容易に行うことが出来るように、XHR(mock可能な)のための依存性注入を提供し、 DOM操作で逆順にすることを必要とせずに、モデルのソートが出来るような抽象化が用意されています。 そのため、データを並び替えるソート関数を簡単に書くことができるため、 テストのデータセットを作成して関数に適用し、結果のモデルが正しい並び順であることをアサートすることが可能になります。 テストはXHRのために待つ、または正しい種類のDOMを作成する、 または正しい方法でDOMを変化させる関数をアサートするといった必要が無くなります。

大きな力には大きな責任が伴う

Angularはテストの事を念頭に入れて書かれていますが、それでもあなたが正しくそれを行うことを必要とします。 Angularでは、それが正しく簡単に行えるように努めていますがAngularは魔法ではありません。 これは、あなたがガイドラインに従わなければ、テストしにくいアプリケーションになってしまうという事を意味します。

依存性注入

依存性の保持を取得することが出来る方法はいくつか存在します。1
それをnew演算子を使用して作成することが出来ます。2
グローバルのシングルトンとして知られている場所からそれを見つけることが出来ます。3
それのために登録(また、サービスレジストリとしても知らている)を問い合わせることが出来ます。 (ただし、登録の保持はどのように取得するのでしょうか?多くの場合はよく知られた場所を探せばありそうです。#2参照) 4
それが、あなたに渡される事を期待出来ます。

上記のリストの4つの選択肢のうち、最後だけがテスト可能です。 何故なのか考えてみましょう。

new演算子の使用

new演算子自体には何も問題はありませんが、根本的なな問題はコンストラクタでnewを呼び出すと、 恒久的に呼び出し元を型を紐付けてしまう事にあります。 例えば、XHRのインスタンス化を行い、サーバからデータを取得することが出来るとしましょう。

function MyClass() {
  this.doWork = function() {
    var xhr = new XHR();
    xhr.open(method, url, true);
    xhr.onreadystatechange = function() {...}
    xhr.send();
  }
}

これは、擬似データを返しネットワーク接続失敗をシミュレートする実物に非常によく似たMockXHRをインスタンス化して、 テストする際に問題になります。 new XHR()の呼び出しによって実際のXHRに恒久的に紐付けられ、これを置き換える術がありません。 そう、これはモンキーパッチなのです。 このドキュメントに書かれてることだけで無く、多くの理由からこれはバッドアイディアなのです。

上記のクラスではモンキーパッチに頼らざるを得ないため、テストは困難です。

var oldXHR = XHR;
XHR = function MockXHR() {};
var myClass = new MyClass();
myClass.doWork();
// MockXHRは正しい引数で呼び出されたとアサートします
XHR = oldXHR; // もしこれを忘れると、良からぬ事が起こります
グローバルを検索

よく知られた場所でサービスを探すことについての問題について考えてみましょう。

function MyClass() {
  this.doWork = function() {
    global.xhr({
      method:'...',
      url:'...',
      complete:function(response){ ... }
    })
  }
}

全体で依存性のある新しいインスタンスが作成されず、基本的に同じものを参照することになりますが、 テスト目的のためにglobal.xhrの呼び出しを差し替えたりする良い方法が無いため、 モンキーパッチを通した別の方法が必要になります。 テストに対しての基本的な課題は、グローバル変数はmockメソッドを呼び出して置き換えることで変更される事が必要であるという事です。 何故これが良くないのかの詳細を知りたければ、Brittle Global State & Singletonsを参照してください。

グローバルの状態を変更する必要が生じてしまうため、上記のクラスはテストを困難にしてしまいます。

var oldXHR = global.xhr;
global.xhr = function mockXHR() {};
var myClass = new MyClass();
myClass.doWork();
// mockXHRが正しい引数で呼び出される事をアサートします
global.xhr = oldXHR; // もしこれを忘れると、良からぬ事が置きます
サービス登録

全てのサービスにおいて、登録(registry)を持つことでサービスに必要なテストを置き換え可能にすることで、 この問題が解決出来ると思われるかもしれません。

function MyClass() {
  var serviceRegistry = ????;
  this.doWork = function() {
    var xhr = serviceRegistry.get('xhr');
    xhr({
      method:'...',
      url:'...',
      complete:function(response){ ... }
    })
}

しかしながら、serviceRegistryはどこからやって来るのでしょうか? もし、newするのであれば、テストはグローバル検索のテストのためのリセットをする機会が無く、 サービスは同様にグローバルを返します。(翻訳に自信無し) (ただし、リセットすることが出来る唯一のグローバル変数があるため、リセットは簡単です。)

上記のクラスは、グローバル状態を変更しなければならないため、テストが困難になります。

var oldServiceLocator = global.serviceLocator;
global.serviceLocator.set('xhr', function mockXHR() {});
var myClass = new MyClass();
myClass.doWork();
// assert that mockXHR got called with the right arguments
// mockXHRが正しい引数で呼び出される事をアサートします。
global.serviceLocator = oldServiceLocator; // もしこれを忘れると、良からぬ事が起こります
依存性渡し

最後は依存性渡しについてです。

function MyClass(xhr) {
  this.doWork = function() {
    xhr({
      method:'...',
      url:'...',
      complete:function(response){ ... }
    })
}

これは、誰かが作成したクラスが渡されたものの責任を請け負うよりも、 xhrがどこからやって来るのかということを想定しないコードにする方が好ましい、という考え方です。 クラスを作成するコードとクラスを使用するコードは異なるコードであるべきで、 ロジックから作成を請け負う部分を分離します。 そして簡単に言ってしまえば、これこそが依存性注入なのです。

上記のクラスは非常にテストし易いものであるため、下記のようにテストを書くことができます。

function xhrMock(args) {...}
var myClass = new MyClass(xhrMock);
myClass.doWork();
// mockXHRが正しい引数で呼び出される事をアサートします。

この書き方が、グローバル変数(空間)を汚染していないことに注目してください。

依存性注入を備えたAngularはこれらの事を正しく容易く行う事が出来ますが、 あなたがテストを容易に行う事で利益を得たいと望むのであれば、それをしっかりと実践する必要があります。

コントローラー

各アプリケーションを特徴づけるものはそのロジックであり、 我々はそれをテストすることを望みます。 もし、アプリケーションのロジックにDOM操作を伴うものが混ざると、 下記の例のようにテストをすることが困難になってしまいます。

function PasswordCtrl() {
  // DOM要素の参照を取得
  var msg = $('.ex1 span');
  var input = $('.ex1 input');
  var strength;

  this.grade = function() {
    msg.removeClass(strength);
    var pwd = input.val();
    password.text(pwd);
    if (pwd.length > 8) {
      strength = 'strong';
    } else if (pwd.length > 3) {
      strength = 'medium';
    } else {
      strength = 'weak';
    }
    msg
     .addClass(strength)
     .text(strength);
  }
}

上記のコードは、テストでコードを実行する際に正しいDOMを用意することが必須になるため、 テストをするという観点から問題点が多いと言えます。 テストは次のようになるでしょう。

var input = $('<input type="text"/>');
var span = $('<span>');
$('body').html('<div class="ex1">')
  .find('div')
    .append(input)
    .append(span);
var pc = new PasswordCtrl();
input.val('abc');
pc.grade();
expect(span.text()).toEqual('weak');
$('body').html('');

AngularではコントローラーからDOM操作ロジックを切り離すことを徹底しており、 その結果下記の例で確認できるように、テストストーリーが非常に簡易なものにすることを可能にしています。

function PasswordCtrl($scope) {
  $scope.password = '';
  $scope.grade = function() {
    var size = $scope.password.length;
    if (size > 8) {
      $scope.strength = 'strong';
    } else if (size > 3) {
      $scope.strength = 'medium';
    } else {
      $scope.strength = 'weak';
    }
  };
}

そのため、テストの実行も非常に簡単になります。

var $scope = {};
var pc = $controller('PasswordCtrl', { $scope: $scope });
$scope.password = 'abc';
$scope.grade();
expect($scope.strength).toEqual('weak');

テストが短くなっただけでなく、実行が簡単になっていることに注目してください。 我々は、関連性が無いと思われる無作為の断片をアサートすると言うよりも、テストがストーリーに伝えるという言い方をします。(翻訳に自信なし)

フィルター

フィルターは、ユーザーが読むことが出来るフォーマットにデータを変換する関数です。 アプリケーションロジックからフォーマットの処理を請け負うことを除去できることは、アプリケーションロジックをよりシンプルにする上で重要なことです。

myModule.filter('length', function() {
  return function(text){
    return (''+(text||'')).length;
  }
});

var length = $filter('length');
expect(length(null)).toEqual(0);
expect(length('abc')).toEqual(3);

ディレクティブ

AngularのディレクティブはカスタムHTMLタグ、属性、クラス、コメントの複雑な機能をカプセル化することを請け負います。 ディレクティブを含むコンポーネントの作成はアプリケーションと多くの異なるコンテキストを通して使用されるかもしれないため、 このユニットテストは非常に重要になります。

シンプルなHTML要素ディレクトリ

依存無しでAngularアプリケーションを開始してみましょう。

var app = angular.module('myApp', []);

ここで、ディレクティブをアプリケーションに追加することが可能です。

app.directive('aGreatEye', function () {
    return {
        restrict: 'E',
        replace:  true,
        template: '<h1>lidless, wreathed in flame, {{1 + 1}} times</h1>'
    };
});

ディレクティブは、<a-great-eye></a-great-eye>のようなタグで使用されます。 これは、<h1>lidless, wreathed in flame, {{1 + 1}} times</h1>のテンプレートを使ってタグ全体を置き換えます。 それでは、jasmineによるユニットテストを書いて、この機能を検証してみましょう。 {{1 + 1}}式は、描画されるコンテンツ内でも評価されることに注意してください。

describe('Unit testing great quotes', function() {
    var $compile;
    var $rootScope;

    // ディレクティブを含むmyAppモジュールを読み込みます。
    beforeEach(module('myApp'));

    // $rootScopeと$compileへの参照を格納することで、
    // このdescribeブロックの全てのテストで利用可能にします。
    beforeEach(inject(function(_$compile_, _$rootScope_){
      // injectorはマッチする際に、
      // 引数名の両端のアンダースコア(_)を外します。
      $compile = _$compile_;
      $rootScope = _$rootScope_;
    }));

    it('Replaces the element with the appropriate content', function() {
        // ディレクティブを含むHTMLの断片をコンパイル
        var element = $compile("<a-great-eye></a-great-eye>")($rootScope);
        // 全ての監視(watch)を発火し、スコープの式{{1 + 1}}が評価されます。
        $rootScope.$digest();
        // テンプレートコンテンツを含むコンパイルされた要素を確認します。
        expect(element.html()).toContain("lidless, wreathed in flame, 2 times");
    });
});

各jasmineテストの前(beforeEach)に、$compileサービスと$rootScopeを注入します。 $compileサービスは、aGreatEyeディレクティブの描画に使用されます。 ディレクティブの描画の後、ディレクティブがコンテンツに置き換わり、 "lidless, wreathed in flame, 2 times"が提供されていることを確認します。

サンプルプロジェクト

更に多くの例を見たければ、 angular-seedプロジェクトを参照してください。

 Back to top

© 2017 Google
Licensed under the Creative Commons Attribution License 3.0.

このページは、ページトップのリンク先のAngularJS公式ドキュメント内のページを翻訳した内容を基に構成されています。 下記の項目を確認し、必要に応じて公式のドキュメントをご確認ください。 もし、誤訳などの間違いを見つけましたら、 @tomofまで教えていただければ幸いです。

  • AngularJSの更新頻度が高いため、元のコンテンツと比べてドキュメントの情報が古くなっている可能性があります。
  • "訳注:"などの断わりを入れた上で、日本人向けの情報やより分かり易くするための追記を行っている事があります。