homeASCIIcasts

261: JasmineでJavaScriptのテスト 

(view original Railscast)

Other translations: En Es

Other formats:

Written by Naomi Fujimoto

Railsデベロッパーの多くはアプリケーションのRubyコードをテストすることの重要性を理解しています。しかし多くの場合JavaScriptについてはブラウザでアプリケーションを実行してエラーを確認するだけの手動テストで済まされてしまいます。ウェブアプリケーションがますます複雑化しクライアント側に多くの機能を持つようになる中で、JavaScriptコードを自動的にテストする方法を持つことが望まれます。今回のエピソードでは、そのための一つの方法を見ていきます。

対象のアプリケーションを以下に示します。標準的な注文フォームで、クレジットカード番号を入力するテキストフィールドを持っています。このフィールドにクライアント側で動作する検証機能を追加して、フィールドがフォーカスを失うときに入力された番号が検証されるようにしようと思います。

クレジットカード番号用フィールドを持つ注文ページ

完全なクレジットカード番号の入力検証機能を追加するわけではありませんが、それでもロジックはかなり複雑になるのでこのコードをきちんとテストしておくべきでしょう。対応として、エピソード257[動画を見る, 読む]で紹介した受け入れテストを利用する方法があります。この方法ではCapybaraを使用してアプリケーションを総合的にテストできます。JavaScriptもSeleniumを利用することでテストが可能です。

しかしJavaScriptは複雑すぎて、場合によってはテストのためにコード自体に近いレベルの、JavaScript用のユニットテストのようなものが必要になります。そのような場合にJasmineを使用します。JasmineはJavaScript用のテストフレームワークで、 RSpecと同じようにdescribeitの呼び出しでテストを組み立てていきます。もちろんRSpecとは違って、テストはRubyではなくJavaScriptで書かれています。

Jasmineをインストールする

JasmineをRailsアプリケーションで簡単に利用できるようにするgemが用意されているので、アプリケーションに追加しbundleコマンドを実行してインストールします。developmenttest環境のみでJasmineを使用することとし、production環境では使用しないようにします。このアプリケーションでは、jQueryを使用していることに注意してください。これはJasmineを利用するために必須ではありませんが、役には立ちます。

/Gemfile

source 'http://rubygems.org'

gem 'rails', '3.0.5'
gem 'sqlite3'
gem 'nifty-generators'
gem 'jquery-rails'
gem 'jasmine', :group => [:development, :test]

gemがインストールされたら、ジェネレータを実行してJasmineが使用するファイルをインストールします。

$ rails g jasmine:install
      create  spec
      create  spec/javascripts/helpers/.gitkeep
      create  spec/javascripts/support/jasmine.yml
      create  spec/javascripts/support/jasmine_config.rb
      create  spec/javascripts/support/jasmine_runner.rb

ファイルがspecディレクトリにインストールされます。Jasmineは通常RSpecと一緒に利用されることが多いですが、単体でも利用できます。

最初のspecを作成する

最初のJavaScript用specを作成する準備ができました。specファイルは/spec/javascriptsフォルダに置き、ここではcredit_card_spec.jsという名前にします。(ところでもしTextMateを使っているのであれば、 bundleを利用することで、desで新しい記述を作成したり、 itで新しいitブロックを作成することが可能です。)最初のspecでは、入力された番号のハイフンや空白が取り除かれることをテストします。

/spec/javascripts/credit_card_spec.js

describe("CreditCard", function() {
  it("cleans the number by removing spaces and dashes", function() {
    expect(CreditCard.cleanNumber("123 4-5")).toEqual("12345");
  });
});

Jasmineは、対象が特定の値を持つかどうかをチェックするときに、assertではなくexpectを使用します。今回のテストでは、CreditCardオブジェクトのcleanNumberメソッドに“123 4-5”を渡して“12345”が返されることをチェックします。当然ですが、テスト駆動開発をしているので、CreditCardオブジェクトもcleanNumberも現時点ではまだ存在しません。

rake jasmineを実行してJasmineのspecサーバを起動します。これによって8888ポートにサーバが立ち上がります。そのページにアクセスすると、specの実行結果を見ることができます。

最初のspecが失敗している

specは予想通り失敗しているので、成功するようにコードを書き直していきます。public/javascriptsディレクトリのcredit_card.jsというファイルにコードを記述します。そこにオブジェクトと、cleanNumber関数に対するテストを記述します。

/public/javascripts/credit_card.js

var CreditCard = {
  cleanNumber: function(number) {
    return number.replace(/[- ]/, "");
  }
}

Jasmineのページを再度読み込むと、違うエラーが表示されています。JasmineはCreditCardオブジェクトが見つからないというエラーを出さなくなりましたが、cleanNumberは空白やハイフンを削除していないようです。

specはまた失敗

問題は正規表現で、すべての置き換えを指定するgが付いていないため、置き換えが文字列全体に適用されていませんでした。

/public/javascripts/credit_card.js

var CreditCard = {
  cleanNumber: function(number) {
    return number.replace(/[- ]/g, "");
  }
}

再度ページを読み込み、エラーが修正されたかどうかチェックします。今度はspecが成功しました。

specが成功した

次にチェックサム検証を行うmod 10 algorithmを追加して検証範囲を拡張します。このコードをテストするときに、2つのケースを想定してspecを記述します。ひとつは正しいクレジットカード番号がパスすること、もうひとつは不正な番号が失敗することです。この想定に基づいて、新しく作成したvalidNumber関数を呼び出します。一つ目はテスト用の正しいVisaカードの番号なので成功し、もう一つは正しくない番号なので失敗するはずです。数字には空白とハイフンが入っているので、検証がこれらを考慮しているかどうかをテストすることができます。

/spec/javascripts/credit_card_spec.js

describe("CreditCard", function () {
  it("cleans the number by removing spaces and dashes", function() {
    expect(CreditCard.cleanNumber("123 4-5")).toEqual("12345");
  });
  
  it("validates based on mod 10", function () {
    expect(CreditCard.validNumber("4111 1111 1111-1111")).toBeTruthy();
    expect(CreditCard.validNumber("4111 1111 1111-1121")).toBeFalsy();
  });
});

Jasmineのページを再度読み込むと失敗と表示されていますが、今回はvalidNumber関数が見つからなかったというのが理由です。

mod10検証に失敗したspec

この関数をcredit_card.jsファイル内に記述します。

/public/javascripts/credit_card.js

var CreditCard = {
  cleanNumber: function(number) {
    return number.replace(/[- ]/g, "");
  },
  
  validNumber: function(number) {
    var total = 0;
    number = this.cleanNumber(number);
    for (var i=number.length-1; i >= 0; i--) {
      var n = parseInt(number[i]);
      if ((i+number.length) % 2 == 0) {
        n = n*2 > 9 ?n*2 - 9 : n*2;
      }
      total += n;
    };
    return total % 10 == 0;
  }
}

ここで再度Jasmineのページを読み込むと、2つのspecが成功しています。

mod10検証が成功した

ブラウザで検証をテストする

検証が動作するようになったので、これをサイトに追加します。検証機能がウェブページ上で正しく動作することをテストするために、CapybaraとSeleniumを使用します。ページ内に多くの複雑なJavaScriptのコードがある場合、検証コードを単独でテストすることは難しいかも知れませんが可能です。それをここからHTML fixtureを使って実現する方法を紹介します。

Jasmine-jqueryは、名前が示す通りJasmineのjQuery用機能拡張です。これがfixtureサポートを提供しているので、シンプルなHTML句に対してJavaScriptをテストすることができます。また、expectで利用できるmatcherを多く備えています。jasmine-jqueryをインストールするために、次のcurlコマンドを実行すると、spec/javascripts/helpersディレクトリにファイルが自動的にダウンロードされます。

$ curl http://cloud.github.com/downloads/velesin/jasmine-jquery/jasmine-jquery-1.2.0.js > spec/javascripts/helpers/jasmine_jquery-1.2.0.js

spec/javascripts/supportディレクトリのjasmine.ymlsrc_filesセクションで、デフォルトのPrototypeを使用するよう設定されている部分を修正します。

/spec/javascripts/support/jasmine.yml

src_files:
  - public/javascripts/prototype.js
  - public/javascripts/effects.js
  - public/javascripts/controls.js
  - public/javascripts/dragdrop.js
  - public/javascripts/application.js
  - public/javascripts/**/*.js

これらの参照をjQueryへの参照に置き換えます。デフォルト設定ではjavascriptsディレクトリのその他すべてのファイルがincludeされていますが、これは問題を起こす可能性があるので、各ファイルを個別にincludeします。これによって、読み込みの依存関係を正しい順番に保つことにもなります。

/spec/javascripts/support/jasmine.yml

src_files:
  - public/javascripts/jquery.js
  - public/javascripts/credit_card.js

fixtureファイル用のディレクトリが必要になるので、/spec/javascript/fixturesに作成します。ここに簡単なHTMLフォームを含んだorder_form.htmlというファイルを作成して、クレジットカード検証のテストをおこないます。必要なものは、JavaScriptから参照できるようにidを持ったテキストフィールドと、エラーを表示するためのdivだけです。

/spec/javascripts/fixtures/order_form.html
<form>
  <input type="text" id="card_number">
  <div id="card_number_error"></div>
</form>

クレジットカード検証用のコードを使いやすくするために、validateCreditCardNumberというjQueryプラグインを作成します。このプラグインは、対象となるテキストフィールドの値を検証するもので、そのフィールドがフォーカスを失うときに起動されます。検証エラーが発生したら、idがテキストフィールドと同じ要素(ただし_errorが追加される)にテキストが設定されます。

プラグインを作る前に、新しいfixtureを利用するspecを書いてテストします。

/spec/javascripts/credit_card_spec.js

describe("CreditCard", function () {
  // Other specs omitted.
  it("validates when text field loses focus", function() {
    loadFixtures("order_form.html");
    $("#card_number").validateCreditCardNumber();
    $("#card_number").val("123");
    $("#card_number").blur();
    expect($("#card_number_error")).toHaveText("Invalid credit ↵
      card number.");
  });
});

このspecでは、loadFixturesを呼び出してfixtureをロードし、テキストフィールドに対して新しく作成したプラグインを呼び出します。次にテキストフィールドの値を不正なクレジットカード番号に設定し、blur()を呼び出してプラグインを起動させます。最後に、要素が表示しているエラーメッセージの内容が正しいかどうかを、jasmine-jqueryの機能であるカスタムmatcherのtoHaveTextを使ってチェックします。Jasmineページを再度読み込むと、jQueryがvalidateCreditCardNumberを理解できないので、想定通りspecは失敗しています。

jQueryプラグイン用のspecが失敗

これを修正するために、validateCreditCardNumberというjQueryプラグインを記述します。このプラグインは、対象としたすべての要素についてblurイベントの待機状態になり、イベントが発生すると要素の値を検証します。続いて先に書いたvalidNumber関数を用いてその番号が正しいかどうかをチェックして、正しくない場合はエラーを表示します。

/public/javascripts/credit_card.js

var CreditCard = {
  cleanNumber: function(number) {
    return number.replace(/[- ]/g, "");
  },
  
  validNumber: function(number) {
    var total = 0;
    number = this.cleanNumber(number);
    for (var i=number.length-1; i >= 0; i--) {
      var n = parseInt(number[i]);
      if ((i+number.length) % 2 == 0) {
        n = n*2 > 9 ?n*2 - 9 : n*2;
      }
      total += n;
    };
    return total % 10 == 0;
  }
}

(function ($){
  $.fn.validateCreditCardNumber = function () {
    return this.each(function () {
      $(this).blur(function () {
        if (!CreditCard.validNumber(this.value)) {
          $("#" + this.id + "_error").text("Invalid credit card number.");
        }
      });
    });
  };
})(jQuery);

specページを再度読み込むとspecは成功しているので、プラグインは正しく動作したようです。

プラグインのspecが成功した

プラグインを統合する

プラグインの単独テストがうまくいったので、アプリケーションに統合してみましょう。修正が必要なファイルは3つです。まずレイアウトファイルで、作成したjQueryプラグインをincludeします。

/app/views/layouts/application.html.erb

<%= javascript_include_tag :defaults, "credit_card" %>

次に、クレジットカードフィールドを持つフォームを修正して、エラーメッセージを表示する要素を追加します。フォームはOrderモデル用でフィールド名はcredit_card_numberなので、要素のidorder_credit_card_number_errorになります。

/app/views/orders/_form.html.erb

<%= form_for @order do |f| %>
  <%= f.error_messages %>
  <p>
    <%= f.label :credit_card_number %><br />
    <%= f.text_field :credit_card_number %>
    <span id="order_credit_card_number_error">
  </p>
  <p>
    <%= f.label :credit_card_expires_on %><br />
    <%= f.date_select :credit_card_expires_on, :add_month_numbers => true, :start_year => Time.now.year, :order => [:month, :year] %>
  </p>
  <p><%= f.submit %></p>
<% end %>

最後にapplication.jsにコードを加えて、クレジットカード番号のテキストフィールドに対する検証を追加します。

/public/javascripts/application.js

$(function () {
  $("#order_credit_card_number").validateCreditCardNumber();
});

動作の確認のため、新規注文のページにアクセスして誤ったクレジットカード番号を入力してみます。クレジットカード番号のテキストフィールドからタブキーで抜け出すと、エラーメッセージが表示されます。

誤ったクレジットカード番号を入力するとエラーメッセージが表示される

しかし、このページにはバグがあります。クレジットカード番号を正しいものに修正しても、エラーメッセージはページに表示されたままです。メッセージが表示されている場合は、正しい番号が入力されたら隠されるべきです。

バグを見つけたときにまずおこなうのは、そのための失敗するspecを書くことです。ただし今回は新たにspecを書く必要はなく、既存のカード検証のspecにexpectを追加して、正しい番号の場合にエラーが表示されないことをチェックします。

/spec/javascripts/credit_card_spec.js

describe("CreditCard", function () {
  // Other specs omitted.  
  it("validates when text field loses focus", function() {
    loadFixtures("order_form.html");
    $("#card_number").validateCreditCardNumber();
    $("#card_number").val("123");
    $("#card_number").blur();
    expect($("#card_number_error")).toHaveText("Invalid credit card number.");

    $("#card_number").val("4111 1111 1111-1111");
    $("#card_number").blur();
    expect($("#card_number_error")).toHaveText("");
  });
});

Jasmineのページを読み込むと、バグが正しく再現されspecが失敗したことがわかります。エラーを表示するdivにテキストが入っていないのが期待する状態ですが、今はメッセージが表示されています。

正しい番号のときにエラーが表示されないことをテストするspecが失敗する

このバグを修正するのは簡単です。作成したjQueryプラグインのコードを修正して、検証されたクレジットカード番号が正しかった場合はエラーメッセージが隠されるように書き換えます。

/public/javascripts/credit_card.js

(function ($){
  $.fn.validateCreditCardNumber = function () {
    return this.each(function () {
      $(this).blur(function () {
        if (!CreditCard.validNumber(this.value)) {
          $("#" + this.id + "_error").text("Invalid credit ↵
  card number.");
        }
        else {
          $("#" + this.id + "_error").text("");
        }
      });
    });
  };
})(jQuery);

Jasmineページを再度読み込むと、specは再度すべて成功します。

specが成功した

最後にブラウザで再度テストをおこないます。不正な番号を入力するとエラーメッセージが表示され、正しい番号を入力するとエラーが消えます。

正しいクレジットカード番号を入力するとエラーメッセージが隠れる

チップ

specを表示するページを、修正を加えるごとに読み込み直す代わりに、rake jasmine:ciを実行することができます。これによってFirefoxが開いてSeleniumを使用してspecが実行され、ターミナルに出力が表示されます。

Jasmineについての今回のエピソードは以上です。Railsアプリケーションで利用すれば、Rubyコードに対してと同じく完全にJavaScriptをテストする優れた方法を提供してくれます。