2013年7月6日土曜日

モンスターメソッドを解体する

『レガシーコード改善ガイド』の第22章「モンスターメソッドを変更する必要がありますが、テストを書くことができません」を自分なりに整理して、モンスターメソッドへの対処方法を要約してみる。

何をどこまでテストすれば?

まず、どういう種類のテストを書こうとしているか考える。それには、第13章「変更する必要がありますが、どんなテストを書けばよいのかわかりません」が役に立つ。そこでは、〈仕様化テスト〉と〈狙いを定めたテスト〉が紹介されている。〈狙いを定めたテスト〉を書くのが現実的だろう。モンスターメソッドの振る舞いは多様だから、いきなり全体を対象とした〈仕様化テスト〉は書けないことが多そう。

次に、モンスターメソッドを読み込んで、狙いを定める。どこまで狙いを絞り込めるかは、モンスターメソッドの作りと必要な変更によってケースバイケースになる。少なくとも自分にとっては、技芸の世界で、筋道立った説明をできない。

狙いが定まったら、検証方法を考える。この時、検証対象が一意に識別できなかったり (例: 別のブランチから同じ結果が返ってくる) 、テストから見えなかったりしたら、〈検出用変数〉または〈クエリメソッド〉を導入する。この時点では、これ以上の編集は避けた方が無難だと思う。やり過ぎると〈編集して祈る〉のと変わらない。

テストを書く

ここまでで、何のためにどの範囲でどんな検証をしないといけないか、具体的になってくるはず。ここまで来たら、テストを書き始める。書き始めたはいいが、モンスターメソッドを持っているクラスをインスタンス化できなかったり、モンスターメソッドの引数をインスタンス化できなかったりすることが多いと思う。その場合は、別の章を参照して対処する (ここでは深入りしない)。
  • 第09章 このクラスをテストハーネスに入れることができません
  • 第10章 このメソッドをテストハーネスで動かすことができません

変更とメソッド抽出

無事にテストが書けたら、実行してパスすることを確認する。この後、必要な変更を行うか、〈メソッドの抽出〉を行うかは、悩ましいところ。変更が局所的なら先に変更してもいいと思う。逆に変更がモンスターメソッドのあちこちに散らばるようなら、先にメソッドを抽出しておかないと後が苦しくなる。いずれにせよ、ここでは変更については触れずに、メソッドの抽出についてまとめる。

メソッドを抽出するときには、抽出後に残るメソッドの形を想像しておくと、どういう単位で抽出すればいいか方針を決めやすい。残る形には、〈骨組みメソッド〉と〈処理シーケンス〉の2種類がある。

〈骨組みメソッド〉は制御構造だけを持っていて、条件式や各条件下の処理はメソッドに任せてしまう。『実装パターン』だとConditionalに対応する。『デザインパターン』で例えるとFacadeやMediatorのイメージになる。

もう一つの〈処理シーケンス〉は単に順番にメソッドを呼び出していく。『リーダブル・コード』の無関係の下位問題の抽出を行うイメージ。「いちいちメソッドにするほどの問題ではないんじゃ?」と思っても、メソッドにして意図に即した名前を付けておくとリーダビリティが上がるはず (『実装パターン』のExplaining Message)。

どちらが適しているかは、モンスターメソッドの元々の役割によって変わる。大抵の場合、モンスターメソッドは、実装コストが低いところに追加が集中した結果であって、最初からモンスターメソッドとして生まれたわけではない。処理の振り分けが役割だったなら骨組みメソッド、抽象度の高いAPI提供が目的だったなら処理シーケンスを残す方が自然だと思う。

どういう形を残す決めたら、メソッドを抽出する前に、抽出したメソッドのテストを書く。先にテストを書けばレガシーコードは生まれない。アジャイルじゃなくたって、TDDした方がいいと思う。

さらに分割

ここまで来たら、細かい粒度でテストできるようになっている。でも、モンスターメソッドを持っているオブジェクトは、得てしてGod Objectだったりする。抽出したメソッドを〈メソッドオブジェクト〉として取り出して、委譲するように修正することも考えていく。つまり、『レガシーコード改善ガイド』の〈メソッドオブジェクトの取り出し〉、『リファクタリング』のReplace Method with Method Objectを行う。

References


2013年5月18日土曜日

JavaScriptで依存性を排除するためにデフォルトパラメータを導入する

JavaScriptのクラスHogeをテストしたいのだけれど、次のような状況でそもそもインスタンス化が大変な場合にどうするか? という話。
  • Hogeはコンストラクタ内で別のクラスFugaをインスタンス化してプロパティに設定している。
  • Fugaはインスタンス化の際にサードパーティ・ライブラリのAPIを大量に呼び出している。
  • サードパーティ・ライブラリは、開発環境では使えない(例えば、REST APIを提供するサーバがまだ立っていない)。
この場合をコード例で表してみる。サードパーティ・ライブラリが使えないため、Fugaのコンストラクタ呼び出しでエラーが発生し、Hogeをインスタンス化できないとする。
Hoge = function () {
  this.fuga = new Fuga();
  // ...
}
// ...
Fuga = function () {
  this.thirdPartyLibrary = new ThirdPartyLibrary();
  // ...
}
// ...

FugaがThirdPartyLibraryをラップしているから、テスト時にはテストダブルに入れ替えたい。でも、そもそもHogeもFugaもインスタンス化できない。

こんな状況に対応するためのリファクタリングとして、『レガシーコード改善ガイド』は25.14「コンストラクタのパラメータ化」か、デフォルト引数の追加を紹介している。オーバーロードのないJavaScriptでは、コンストラクタをパラメータ化できないので、デフォルト・パラメータを指定する。

デフォルト・パラメータがtrueと見なせる(false、null、0、""、undefinedのいずれでもない)なら、こんな風に短く書ける。||は真偽値ではなくてfugaを返す性質を利用している。
Hoge = function (fuga) {
  this.fuga = fuga || new Fuga();
  // ...
}
// ...

もっとロバストな書き方は次の通り。こちらは、デフォルト・パラメータがfalseと見なされる場合も使える。実用的なのは、デフォルト値が0やfalseの時。
Hoge = function (fuga) {
  if (typeof fuga === 'undefined') {
    fuga = new Fuga();
  }
  this.fuga = fuga;
  // ...
}
// ...

References



JavaScriptにおける検出用変数

JavaScriptで『レガシーコード改善ガイド』の第22章「モンスターメソッドを変更する必要がありますが、テストを書くことができません」で紹介されている「検出用変数の導入」を行ってみる。

『レガシーコード改善ガイド』のサンプルコードはJavaだからインスタンス変数として導入しているけれど、ここでは関数プロパティとして導入する。JavaScriptでは、関数もオブジェクトだからプロパティを持つことができる。

関数プロパティとして導入した方が、使用する場所に近くなるから読みやすくなるし、何かの拍子に誤ってアクセスする可能性が小さくなる。モンスターメソッドはただでさえ長い上に、そいつを持っているオブジェクトもゴッド・オブジェクトだったりするから、どれだけ慎重になってもなり過ぎるということはないはず。

というわけで、godObject.monsterMethodに検出変数isProcessedを導入してみる。これで複雑な条件をかいくぐって目的のブロックが実行されているかどうかを検出できるようになる(この実行が委譲されているなら、モックを使って検出できるけれど、モンスターメソッドが書かれるような状況では期待薄だと思う)。
var godObject = {
    monsterMethod : function (num) {
        // 検出用変数
        this.monsterMethod.isProcessed = false;
    
        if (true) {
            // ... 
            if (false)  {
                // ...
            } else if (true) {
                if (num === 2) {
                    this.monsterMethod.isProcessed = true;
                    // このブロックが実行されているかどうかを検出したい
                }
            } else {
                // ...
            }
        }
    }
}

検出用変数には、godObject.monsterMethod.isProcessedでアクセスできる。実際には、テストコード中でassertTrue(godObject.monsterMethod.isProcessed)のような形でアクセスすることになるだろうけれど、ここでは簡単に確認するためにconsole.logを使う。
godObject.monsterMethod(1);
console.log(godObject.monsterMethod.isProcessed); // > false
godObject.monsterMethod(2);
console.log(godObject.monsterMethod.isProcessed); // > true
godObject.monsterMethod(3);
console.log(godObject.monsterMethod.isProcessed); // > false

この方法は、『JavaScriptパターン』の4.3「関数プロパティによるメモ化パターン」を参考にしている。関数にその関数のキャッシュ変数を持たせられるなら、検出用変数を持たせられるだろう、と。

References


2013年5月13日月曜日

jgenhtmlでJsTestDriverのCoverageプラグインの出力をHTMLレポートに

JsTestDriverのCoverageプラグインは、LCOVフォーマットのファイルを出力する。「JsTestDriverでUnit Test + Code Coverage」では、Ubuntu上で実行していたからgenhtmlで簡単にHTMLレポートにできたけれど、Windows上ではgenhtmlを使えない。

Windows上でHTMLレポートに変換する方法について調べてみると、javascript - Viewing LCOV file in Windows - Stack Overflowにそのものズバリの質問が。回答を見てみると、Cygwinにgenhtmlをインストールする方法と、Java実装のjgenhtmlを使う方法があるらしい。

というわけで、今度はjgenhtmlを使ってみる。About JGenHtmlに書いてあるとおり、こちらを使うとパスの区切り文字("\"か"/"か) を気にしなくて良いし、JsTestDriverが動く (Javaがインストールされている) なら、jgenhtmlも動かせる。なお、動かしてみたjgenhtmlのバージョンは、1.5.0。

インストールも使い方も簡単。インストールは、jgenhtml-1.5.jar をダウンロードして、任意のフォルダに置けばよい。ここでは、「JsTestDriverでUnit Test + Code Coverage」のディレクトリ構成配下、test-lib/genhtmlに追加配置する。その時の使い方は、コマンドプロンプトで次のコマンドを実行する。
java -Dfile.encoding=UTF-8 -jar test-lib\jgenhtml\jgenhtml-1.5.jar -q -o test-output\coverage test-out\jsTestDriver.conf-coverage.dat
-Dfile.encodingの値は、JavaScriptファイルのエンコーディングに一致させること。そうしないと、HTMLレポートの日本語が文字化けする。jgenhtmlは、JVMのデフォルト・エンコーディングでJavaScriptファイルを読み込み、エンコードに関するオプションを持たないので、JVM側で指定している。エンコードにMS932以外を指定すると、今度はコマンドプロンプト上で文字化けするので、jgenhtmlのオプション-q(uiet)で、表示を止めている。

これで-o(utput)に指定したディレクトリにHTMLレポートが生成されるので、ブラウザで確認すれば良い。ただし、HTML5とCSS3を活用しているので、IEはサポート外とのこと。
The reports produced by jgenhtml use cutting edge HTML5 and CSS3 features.

If you are not using an extremely modern browser stuff probably won't work. If you use Internet Explorer my guess is you have no chance in anything before IE10.
BrowserSupport - jgenhtml - lcov genhtml tool ported to Java - Google Project Hosting
手元のIE9でざっと確認した範囲だと、IE8モードだとソースコードのハイライトを確認できるページがレイアウト崩れしている。

References

WindowsのコマンドプロンプトからJSHint

WindowsのコマンドプロンプトからJShintを実行する方法について書く。いちいちjshint.comのテキストエリアに貼り付けていては、リズムが悪いし、多数のファイルを一度にLintできない。

方法は次の2つが考えられたけれど、『メンテナブルJavaScript』に倣って、後者を選んだ。ざっと検索した感じ、Rhinoをインストールする方が、Nodeとnpmをインストールするより手軽そうだったのも理由の1つ(特にnpmのインストールには、GitとPythonが必要)。

Rhino bundleを使う場合のデメリットは、大きく2つ。前者は規定のオプションを渡すバッチファイルを書けば、代用できると思う。後者は替えが効かないので、将来対応されると嬉しい。
  • 設定ファイルを読み込めない。オプションはコマンドライン引数として渡さないといけない(ソースを直接いじるという荒技もできそう)。
  • コマンドラインフラグを使えない。コマンドラインフラグはNode版しか使えない。

まず、次の環境を構築する。Windows 7へのJava 7のインストールは済んでいるものとする。Rhinoは、Download RhinoからBinariesを、ダウンロードして任意のフォルダに展開すれば良い。JSHintは、Install — JSHintから、Rhino bundleをダウンロードして、任意のフォルダに保存すれば良い。以下、RhinoはRHINO_HOMEに、JSHintはJSHINT_HOMEにインストールしたとする。
  • OS: Windows 7
  • Rhino実行環境: Java 7
  • JSHint実行環境: Rhino 1.7R4
  • JShint (Rhino bundle): jshint-rhino-2.0.1.js

実行するには、次の形式でコマンドを実行すれば良い。-Dfile.encodingの値は、Lint対象のJavaScriptファイルのエンコーディングに一致させること。指定できるオプションについては、JSHint optionsを参照のこと。
java -Dfile.encoding=UTF-8 -jar RHINO_HOME\js.jar JSHINT_HOME\jshint-rhino-2.0.1.js [opt1=val1,opt2=val2,...] [global1=true,global2,global3,...] [list of files]
なお、オプションの書式は、jshint-rhino-2.0.1.jsの11000行目から始まる無名関数のコメントから抜粋。どうやら"="の有無でオプションかどうか判定している。以下はオプションの例。
camelcase=true,curly=true,eqeqeq=true,immed=true,indent=4,latedef=true,newcap=true,nonew=true,quotmark=single,strict=true,unused=true,undef=true,trailing=true,maxlen=80

References

2013年5月6日月曜日

Sinon.JSのテストダブルを使ったユニットテスト

はじめに

JavaScriptでの、テストダブルを使ったユニットテストの書き方について書く。テストランナーにはJsTestDriver, モックライブラリにはSinon.JSを使う。

ベースとなるコードには、Sinon.JS > Getting startedから、Spies, Stubs, Testing Ajax, Fake XMLHttpRequest, Fake Serverの5つを使う。これらのコードはそのままでは実行できない (テストランナーにJasmineやMochaを使う場合のテストメソッドが切り出されている) ので、JsTestDriverで実行できるように書き換えて、サンプルコードとする。

書き換えの際、Sinon.JS > Documentationや『テスト駆動JavaScript』を参考に、次のTIPSを導入する。
  • JsTestDriverとSinon.JSのアサーションの統合
  • サンドボックスの導入
環境は次の通り。「JsTestDriverでUnit Test + Code Coverage」とほぼ同じ。
  • OS: Ubuntu 12.10 (Xubuntu)
  • エディタ: gedit
  • テストランナー: JsTestDriver 1.3.5
  • モックライブラリ: Sinon.JS 1.6.0
  • テストブラウザ: Firefox 20.0

Spies

最初にスパイの書き方。テストコードの書き換えは素直なので、ここでアサーションの統合についても書く。

テスト対象関数は次の通り。この関数は、引数に渡された関数を一度だけ実行し、結果をキャッシュする。
function once(fn) {
  var returnValue, called = false;
  return function () {
      if (!called) {
          called = true;
          returnValue = fn.apply(this, arguments);
      }
      return returnValue;
  };
}
これに対するJsTestDriverのテストコードは次の通り。テストケースの書き換え自体には、特に注意するところはないと思う。

注意すべきは、アサーションの書き方。3つのテストケースで、書き方を変えている。1つ目と2つ目のテストメソッドの書き方は、統合不要。3つ目のテストメソッドの書き方には、統合が必要。それぞれの特徴はコメントを参照。
// JsTestDriverとSinon.JSのアサーションの統合
// 普通は全テストケースで共有するために、グローバルヘルパーで実行する
sinon.assert.expose(this);
 
TestCase('SpyExample', {
  'test calls the original function':function() {
    var callback = sinon.spy();
    var proxy = once(callback);
 
    proxy();

    // JsTestDriverのアサーションを使う場合。メッセージが不親切
    assertTrue(callback.called);
  },
 
  'test calls the original function only once':function() {
    var callback = sinon.spy();
    var proxy = once(callback);
 
    proxy();
    proxy();
 
    // Sinon.JSのアサーションを完全修飾して使う場合。メッセージがフレンドリィ
    sinon.assert.calledOnce(callback);
  },
 
  'test calls original function with right this and args':function() {
    var callback = sinon.spy();
    var proxy = once(callback);
    var obj = {};
 
    proxy.call(obj, 1, 2, 3);
    // Sinon.JSのアサーションを統合して使う場合。
    assertCalledOn(callback, obj);
    assertCalledWith(callback, 1, 2, 3);
  }
});
なお、統合は、sinon.assert.exposeで行っている。メソッド名などオプション引数でカスタマイズできるので、詳しくはリンク先を参照のこと(JsTestDriver以外のテストランナーと統合する場合にも要参照。アサーションを持つオブジェクトによって引数に渡すべき値を変えたり、統合先のテストランナーの失敗の扱い方によってsinon.assert.failのオーバーライドしたりする必要がある)。

Stubs

次に単純なスタブの書き方。テスト対象関数はSpiesと同じ。

テストコードは次の通り。これも特に注意するところはないと思う。
TestCase('StubExample', {
  'test returns the return value from the original function':function() {
    var callback = sinon.stub().returns(42);
    var proxy = once(callback);
 
    assertEquals(42, proxy());
  }
});

Testing Ajax

Ajaxスタブの書き方。ここではサンドボックスを導入する。

テスト対象関数は次の通り。Ajaxを簡単に取り扱うために、jQueryを使っている。
function getTodos(listId, callback) {
  $.ajax({
    url: "/todo/" + listId + "/items",
    success: function (data) {
      // Node-style CPS: callback(err, data)
      callback(null, data);
    }
  });
}
これに対するテストコードは次の通り。ポイントは2行目。スタブ化したグローバルオブジェクト$を復元しないと、他のコードに影響するかもしれない。そこで、sinon.testでテストメソッドをラップしてサンドボックス化している。
TestCase('AjaxExample', {
  'test makes a GET request for todo items':sinon.test(function(stub) {
    this.stub($, 'ajax');
    getTodos(42, sinon.spy());
 
    assertTrue($.ajax.calledWithMatch({url: '/todo/42/items'}));
  })
});
スタブへのアクセスは、this.stubで行う。『テスト駆動JavaScript』ではthisがないけれど、それだと動作しなかった。バージョンアップで変更になったのだと思う。

サンドボックス化すべきテストメソッドが複数ある場合は、代わりにsinon.testCaseでテストクラスをラップできる。次の次のFake Serverで使ってみる。

Fake XMLHttpRequest

続いて、XMLHttpRequestのスタブ。あえてサンドボックスを使わないで書くと、復元が面倒になるという例に使う。テスト対象関数は、Testing Ajaxと同じ。

テストコードは次の通り。setUp, tearDown内でテストダブルを自前で管理しなければならない。
TestCase('FakeXMLHttpRequestExample', {
  setUp: function() {
    this.xhr = sinon.useFakeXMLHttpRequest();
    var requests = this.requests = [];
 
    this.xhr.onCreate = function(req) {
      requests.push(req);
    };
  },
 
  tearDown: function() {
    // Like before we must clean up when tampering with globals
    this.xhr.restore();
  },
 
  'test makes a GET request for todo items': function() {
    getTodos(42, sinon.spy());
 
    assertEquals(1, this.requests.length);
    assertEquals('/todo/42/items', this.requests[0].url);
  }
});

Fake server

最後に、サーバのスタブ。テストケースのサンドボックス化を導入する。なお、これもテスト対象関数は、Testing Ajaxと同じ。

テストコードは次の通り。テストケースをサンドボックス化する場合はこうなるはず(ドキュメントに沿えばこうなると解釈したコードで、テストのパスは確認したがテストダブルの復元までは未確認)。
TestCase('FakeServerExample', sinon.testCase({
  'test calls callback with deserialized data': function(server) {
    this.server = sinon.fakeServer.create();
    var callback = sinon.spy();
    getTodos(42, callback);
 
    // This is part of the FakeXMLHttpRequest API
    this.server.requests[0].respond(
      200,
      {'Content-Type': 'application/json'},
      JSON.stringify([{id: 1, text: 'Provide example', done: true}])
    );
 
    assert(callback.calledOnce);
  }
}));

References

JsTestDriverでテストケースを書く

JsTestDriverで実行するためのテストケースの書き方について記載する。使うバージョンは、1.3.5。テストターゲットが同期処理の場合と非同期処理の場合のそれぞれについて、JsTestDriverのプロジェクトWikiをベースに記載する。なお、テストの実行方法については、「JsTestDriverでUnit Test + Code Coverage」に記載した。

テストケースの書き方には、次の2通りがある。Getting Started with JsTestDriverでは、プロトタイプを使っているが、『JavaScript実践入門』に習って、インライン宣言を使う。インライン宣言だと、テスト名に任意の文字列が使える。また、記述量も少なくなる。
  1. プロトタイプを使う
  2. インライン宣言を使う
Getting Started with JsTestDriverのサンプルコードを元に、インライン宣言を使ってテストケースのスケルトンを書くと、次のようになる。
TestCase('GreeterTest', {
  setUp:function() {
    // 必要に応じてセットアップ処理を実装する。
  },

  'test greet returns Hello World!':function() {
    // Set up
    var sut = new myapp.Greeter();

    // Exercise
    var actual = sut.greet('World');

    // Verify
    assertEquals('Hello World!', actual);
  },

  tearDown:function() {
    // 必要に応じてティアダウン処理を実装する。
  }
});

テストターゲットが非同期の場合は、TestCaseクラスではなくて、AsyncTestCaseクラスを使う。AsyncTestCase - js-test-driverのサンプルコードを元に、インライン宣言を使って書き直すと次のようになるはず(JsHintはおおよそ通したが未実行なので、修正が必要かもしれない)。また、高度な内容(コールバック関数の実行タイミングの制御など)はAsyncTestCase - js-test-driverを参照のこと。
AsyncTestCase('XhrTest', {
  'test XHR using callbacks':function(queue) {
    // Set up
    var xhr = new XMLHttpRequest();
    xhr.open('GET', '/some/path');

    var responseStatus;
    var responseBody;
    
    queue.call('Step 1: send a request to the server and save the response status and body', function(callbacks) {
      var onStatusReceived = callbacks.add(function(status) {
        responseStatus = status;
      });
      
      var onBodyReceived = callbacks.add(function(body) {
        responseBody = body;
      });

      xhr.onreadystatechange = function() {
        if (xhr.readyState === 2) { // headers and status received
          onStatusReceived(xhr.status);
        } else if (xhr.readyState === 4) { // full body received
          onBodyReceived(xhr.responseText);
        }
      };

      // Exercise
      xhr.send(null);
    });

    // Verify
    queue.call('Step 2: assert the response status and body matches what we expect', function() {
      assertEquals(200, responseStatus);
      assertEquals('hello', responseBody);
    });
  }
});

References