現在時刻が関わるユニットテストから、テスト容易性設計を学ぶ

この文章の背景について

この文章はテスト容易性設計をテーマに 2013/11/26 に CodeIQ MAGAZINE に寄稿したものです。残念ながら CodeIQ のサービス終了と共にアクセスできなくなっていたため、旧 CodeIQ MAGAZINE 編集部の皆様に承諾いただき、当時の原稿を部分的に再編集しつつ、ライセンス クリエイティブ・コモンズ — 表示 4.0 国際 — CC BY 4.0 で公開いたしました。

旧 URL にいただいたブックマークとご意見はこちらです(これであなたもテスト駆動開発マスター!?和田卓人さんがテスト駆動開発問題を解答コード使いながら解説します~現在時刻が関わるテストから、テスト容易性設計を学ぶ #tdd|CodeIQ MAGAZINE)。旧記事には本当に多くの反響をいただき、誠に感謝しております。

目次

出題: 現在時刻が関わるテスト

こんにちは、和田(@t_wada)です。この文章では先日(※ 2013/09/30)出題させていただいた TDD に関する問題の総評を行いつつ、テスト容易性設計について考えてみたいと思います。私が出した問題は、以下のものでした。

問1. 下記の仕様をテスティングフレームワークを使ってテストコードを書きながら実装してください。

【仕様1】
「現在時刻」に応じて、挨拶の内容を下記のようにそれぞれ返す機能を作成したい。
(タイムゾーンは Asia/Tokyo とする)


- 朝(05:00:00以上 12:00:00未満)の場合、「おはようございます」と返す
- 昼(12:00:00以上 18:00:00未満)の場合、「こんにちは」と返す
- 夜(18:00:00以上 05:00:00未満)の場合、「こんばんは」と返す

例: 13時に `greeter.greet()` を呼ぶと "こんにちは" と返す



問2. 下記の仕様をテスティングフレームワークを使ってテストコードを書きながら実装してください。
(※問2はオプション問題です。解答できそうでしたら挑戦してみてください)


【仕様2】
「現在時刻」と「ロケール」に応じて、挨拶の内容を下記のようにそれぞれ返す機能を作成したい。
(ただし、タイムゾーンは Asia/Tokyo のままとする)


ロケールが 'ja' の場合

- 朝(05:00:00以上 12:00:00未満)の場合、「おはようございます」と返す
- 昼(12:00:00以上 18:00:00未満)の場合、「こんにちは」と返す
- 夜(18:00:00以上 05:00:00未満)の場合、「こんばんは」と返す

ロケールが 'en' の場合

- 朝(05:00:00以上 12:00:00未満)の場合、「Good Morning」と返す
- 昼(12:00:00以上 18:00:00未満)の場合、「Good Afternoon」と返す
- 夜(18:00:00以上 05:00:00未満)の場合、「Good Evening」と返す

例: ロケール 'en' で18時(JST)に `greeter.greet()` を呼ぶと "Good Evening" と返す

注: この問題では、ロケールへのアクセス方法も含めた設計とテストに挑戦してみてください。
言い換えると、i18nライブラリ等の使い方を問うているのではありません。

簡単に思えるけれど

問題文を見てみると、そんなに難しい機能には思えません。しかし、一見単純な関数/メソッドに思えても、いざテストを書こうとなると面倒な対象に化けるものがあります。今回のお題である、現在時刻が関わるロジックも、テストを書こうとすると面倒になるものの代表格です。今回のお題は、単純そうに見えて実は小さな設計判断が必要になる問題にしよう、という意図で作成しました。

現在時刻が関わるテストはなぜ難しいのか

なぜ現在時刻が関わるテストが難しいのか、あえて悪い例を書いて説明しましょう。

例えばAさんが14時に問1のテストを書いたとします。14時には greet() メソッドは「こんにちは」と返しますから、以下のようなテストを書いてしまいがちです (コード例は JavaScript ですが、一般的なテスティングフレームワークの例と考えてください)。

test('greet メソッド', function () {
    var greeter = new Greeter();
    assert.equal(greeter.greet(), 'こんにちは')
});

このテストコードを書いてすぐに実行すると、もちろんテストは通ります。機能が完成したと考えたAさんはバージョン管理システムにコミットします。CI サーバがコミットを検知してテストを走らせますが、こちらも成功します。

しかし、帰宅間際になってチームがざわつきます。CI 上でテストが失敗しているのです。失敗したのはAさんが 14 時に書いたテストでした。一番直近のコミットを行ったのはBさんだったので CI サーバはテストが失敗する直前のコミットを行ったBさんを「容疑者」として伝えますが、Bさんは全く無関係のコミットを行っただけです。真犯人は、Aさんですね。

ではなぜテストが失敗したのでしょうか。もうおわかりだと思います。18時を過ぎたからですね。

[ポイント] 良いユニットテストは Repeatable (繰り返し可能、再現可能)

現在時刻が関わるテストが難しいのは、(当たり前かもしれませんが)現在時刻がテスト対象の動作に影響を及ぼし、時刻によってテストの結果が左右されるからです。

良いユニットテストは、Repeatable (繰り返し可能、再現可能)でなければなりません。

Repeatable であるとは、テストを実行するだけで、いつでも、何回でも同じように動くということです。環境や時間によってテスト結果が変化してしまう場合、そのテストは Repeatable ではありません。 テストコードが変わっていないのに、状況によって通ったり通らなかったりするテストは、書籍『xUnit Test Patterns』ではErratic Test (不安定なテスト)と表現されています。

現在時刻に依存するテストは「ユニットテストは Repeatable であるべし」という原則に反しているのです。

つまり、現在時刻に依存するユニットテストのテスト容易性設計とは、いつでも、何回繰り返しても、何時に実行しても同じように動作するテストを書けるようにするための設計、ということになります。

考えるべきこと

では、今回のお題を解くにあたって、考えるべきことはどのようなものでしょうか。 具体的には、以下のような事柄を考えなければならないでしょう。

  • どのようなインターフェイスにするか
  • テストデータの選択
  • どのようにテスト可能にするか(どのような内部構造にするか)

これらの点について、解答者の皆様のコードを見ながら考えていきます。

どのようなインターフェイスにするか

どのようなインターフェイスにするかというのは、言い換えれば、外部から見た振る舞いをどう設計すべきかということです。ただ、今回のお題には少しバイアスがかかっています。インターフェイスに関しては、実は問題文の中で「例: 13時に greeter.greet() を呼ぶと "こんにちは" と返す」と誘導していますので、多くの方が greet() という引数無しのメソッドを持つ Greeter というクラスを設計しています。

しかし、挑戦者された方々のうち何名かは Greeter#greet ではなく Greeter#greet(time) というインターフェイスを、デフォルト引数との組み合わせで使えるように設計しています。この設計選択も、テストに対して影響を及ぼします。

テストデータの選択

漫然とテストするのではなく、テストすべきデータも考えなければなりません。考えるべきは、同じ結果になる値の範囲と、その境界線です。今回のお題の仕様では、テストすべき値は以下のものが候補となるでしょう。

  • 00:00:00
  • 04:59:59
  • 05:00:00
  • 11:59:59
  • 12:00:00
  • 17:59:59
  • 18:00:00
  • 23:59:59

(でも、本当にそうでしょうか。ミリ秒はどうでしょうか。夏時間はどうでしょうか。応用問題として、考えてみてださい)

どのようにテスト可能にするか(どのような内部構造にするか)

ここまででインターフェイスは大まかに決まりましたが、この機能はまだテスト可能になっていません。

より正確に言うならば、テストは可能であるのですが、再現性のあるテストを書くための設計ができていません。再現性のあるテストを書くためには、時刻をテストから制御できなければなりません。

ここからが設計のしどころです。どのように時刻をテストから制御するか、解答者の皆様の方針は以下のように分かれました。

  • シンプルに対象メソッドの引数に渡す
  • 組み込みクラス/メソッド/関数に介入する
  • 対象クラスだけが使う組み込みクラスに介入する
  • 対象クラスのテスト用サブクラスをテスト内で作成 (Test-Specific Subclass)
  • テストを対象クラスのサブクラスにしてオーバーライド (Self Shunt)
  • 対象クラスの特定メソッド定義をテストで書き換える
  • 現在時刻へのアクセスを行うインターフェイスを抽出

ではこれから、今回は問1に対するコードを題材に、ひとつひとつのアプローチを詳しく見ていきながら、テストコードの書き方、改善点についても都度レビューを行なっていきたいと思います。

アプローチ1: シンプルに対象メソッドの引数に渡す

メソッドにデフォルト引数が使えたり、メソッドの呼び出し側で引数の省略等が行える言語では、引数が渡されなかった場合のデフォルト値として現在時刻を使うことで、特にテスト容易性のための特別な設計を行わなくともテストを簡潔に書けます。

対象メソッドの引数に渡す設計は何人かの方が選択しましたが、その中から ciel さんのコードを見てみましょう。

class Greeter
    def greet(time=nil)
        time ||= Time.now
        hour = time.hour
        return 'おはようございます' if 5<=hour && hour<12
        return 'こんにちは' if 12<=hour && hour<18
        return 'こんばんは'
    end
end

分量が少なく、簡潔で読み下せるコードになっているところが好印象ですね。ciel さんのテストコード (RSpec) は以下のようになっています。

describe Greeter do
    before do
        @greeter=Greeter.new
    end
    specify 'morning' do
        (5...12).each{|i|
            @greeter.greet(Time.mktime(2000,1,1,i)).should eq 'おはようございます'
        }
    end
    specify 'afternoon' do
        (12...18).each{|i|
            @greeter.greet(Time.mktime(2000,1,1,i)).should eq 'こんにちは'
        }
    end
    specify 'night' do
        [*18...24]+[*0...5].each{|i|
            @greeter.greet(Time.mktime(2000,1,1,i)).should eq 'こんばんは'
        }
    end
end

対象のメソッドに対して時刻オブジェクトを渡せばよいので、テストのための仕組みが不要であり、容易にテストできることがわかります。

このアプローチのメリット

このアプローチのメリットは、まず第一に、依存が少なくシンプルであることです。このアプローチを選択した場合、 Greeter クラスは状態を持ちません。 greet メソッドの戻り値に影響を与えるのは引数だけなので、テストから渡す引数を変えることで様々なテストが簡単になります。引数だけで結果が決まること、テストが行いやすいことから、関数型プログラミングに近いスタイルと言うこともできるでしょう。

このアプローチのデメリット

このアプローチは引数を省略できない言語では使用できません。また、条件が増えるときに工夫が必要です。例えば問2ではロケールが増えますし、その後さらに機能追加されると条件が増えていきます。対象の複雑さが増すとき、単純に引数を増やしていくアプローチでは限界が訪れます。リファクタリング『引数オブジェクトの導入』や関数型プログラミングの知見を活かして、コードの複雑性を上げない取り組みが必要になるでしょう。

このコードの改善できる点

重箱の隅をつつくならば、 Ruby のコードとしては更に改善の余地があったり、ローカルタイムゾーンが Asia/Tokyo であることに依存しているコードなのですが、現時点で十分シンプルなコードです。ただし、テストの書き方には改善できる点があります。それは、テストメソッド内のループです。

(注: これから説明するのは、テスト容易性設計のアプローチとは関係なく、ユニットテストの書き方に関する一般論です)

[ポイント] アサーションルーレット(Assertion Roulette)

テストメソッド内のループは、アサーションルーレット(Assertion Roulette)と呼ばれるテストの不吉な臭いなのです。Assertion Roulette について、先ほどのテストコードからテストメソッドを一つ持ってきて説明しましょう。

specify 'morning' do
    (5...12).each{|i|
        @greeter.greet(Time.mktime(2000,1,1,i)).should eq 'おはようございます'
    }
end

RSpec や Ruby の語法が使われているので Java 風に書き直すと、以下のようになるでしょう。ループの中にアサーションが書かれています。

@Test
public void testMorning() {
    Greeter greeter = new Greeter();
    for (i = 5; i < 12; i++) {
        assertThat(greeter.greet(Time.mktime(2000,1,1,i)), is("おはようございます"));
    }
}

説明のために、ループを使わずに書く場合のコードに更に書き直します(発生するコードの重複は、 Assertion Roulette の説明の本質ではないので、一時的に目をつぶってください)。ループが展開されると、アサーションが縦に7行並んでいます。

@Test
public void testMorning() {
    Greeter greeter = new Greeter();
    assertThat(greeter.greet(Time.mktime(2000,1,1, 5)), is("おはようございます"));
    assertThat(greeter.greet(Time.mktime(2000,1,1, 6)), is("おはようございます"));
    assertThat(greeter.greet(Time.mktime(2000,1,1, 7)), is("おはようございます"));
    assertThat(greeter.greet(Time.mktime(2000,1,1, 8)), is("おはようございます"));
    assertThat(greeter.greet(Time.mktime(2000,1,1, 9)), is("おはようございます"));
    assertThat(greeter.greet(Time.mktime(2000,1,1,10)), is("おはようございます"));
    assertThat(greeter.greet(Time.mktime(2000,1,1,11)), is("おはようございます"));
}

Assertion Roulette は、テストが上手くいっているときは何も問題ないのですが、テストが失敗したときに牙をむきます。このテストが失敗したときに、どのアサーションが失敗したのかがわかりにくいのです。Assertion Roulette という名前は、シリンダーのどの穴に弾が入っているかわからないロシアンルーレットのイメージです。

テスト名からはどのアサーションが失敗したかは読み取れないので、テスト失敗時のスタックトレースから行番号を見つけるなどの方法で、何行目のアサーションが失敗したのかを読み取らなければなりません。テストを手元で実行しているのならまだ読み取りやすいですが、 CI サーバで実行されるときは、失敗したアサーションの推定が難しくなることもあるでしょう。

また、たとえば何らかの事情で4つめのアサーション (8時に対するアサーション) でテストが失敗したとします。この場合に、9時、10時、11時のアサーション結果はどうなったでしょうか。答えは「わからない」です。より正確に表現するなら「実行されなかったのでわからない」です。

一般的なテスティングフレームワークでは、アサーション失敗時に例外が発生し、そのテストメソッドの実行は打ち切られます。8時のアサーションが失敗した場合、その行以降のアサーションは実行されていないのです。8時だけが失敗するのか、9時以降も失敗するのか、実行されていないのでわかりません(ちなみに、 Perl や Go のテスト文化に属するテスティングフレームワークはアサーションが失敗してもテストが続くので、事情がやや異なります)。

ということで、Assertion Roulette、つまり縦に何行も並んだアサーションや、ループの中で書かれたアサーションには、以下の問題点があることがわかりました

  • どのデータが原因でテストが失敗したかわかりにくい
  • テスト失敗以後のアサーションが行われない

Assertion Roulette を避けるためには テストメソッド内に制御構造はなるべく書かないと覚えておいてください。条件分岐はもちろんのこと、繰り返しも、やや不吉な臭いです。なるべく別の方法でテストの場合分け、組み合わせに対応させましょう。

[ポイント] 目指すのは「テストメソッド毎にアサーションひとつ」

Assertion Roulette を避けるためにも、目指したい状態はテストメソッド毎にアサーションひとつ(one assertion per test)です。テストメソッド毎のアサーションがひとつであれば、テストが失敗したときにどのアサーションが失敗したのかは自明です。また、テスト毎にアサーションをひとつにするように努力すると、テストのピントがはっきりし、テストの読み手にも何をテストしたいコードなのかが伝わりやすくなります。多くのことをテストするひとつのテストメソッドがあるよりも、ひとつのことだけをテストする沢山のテストメソッドがあるほうが望ましい、と覚えておいてください。

[ポイント] カスタムアサーションを使う

テスティングフレームワークにデフォルトで提供されているアサーションだけを使うのも、 Assertion Roulette の原因になりがちです。比較的大きな、例えば検証対象のプロパティが沢山あるオブジェクトに対してデフォルトのアサーションだけを使用してテストメソッドを書くと、簡単に Assertion Roulette 状態に陥ってしまいます。このようなときは、カスタムアサーション(Custom Assertion)を作成しましょう。

カスタムアサーションとは、プロジェクト固有の事情に合わせて自分たちで作成したアサーションのことです。今回の Greeter では戻り値が文字列なのでカスタムアサーションを作成する余地はあまりありませんが、大きめのドメインオブジェクトの状態や、属性を沢山持っているデータクラスのひとつひとつの属性に対してアサーションを書いていくのではなく、例えばデータクラスを丸ごと検証するアサーションを自分たちで作成するイメージです。上手く設計されたカスタムアサーションは、テスト毎のアサーション数を減らしつつ、テストコードの読みやすさや失敗時の解析しやすさを高めます。

カスタムアサーションに関しては、JUnit では JUnitのカスタムアサーションを簡単に実装できるcmtest、 RSpec では Custom matchers - RSpec Expectations、PHPUnit では PHPUnit の拡張 - カスタムアサーションの作成なども読んでみてください。

[ポイント] シンプルなコードとテスト失敗時の情報のバランス

テストメソッドの外に制御構造を作れるようなプログラミング言語の場合は、テストメソッド内でループする代わりに、せめてテストメソッドの外でループする、という方法を選択する場合もあります。例えば下記のようなコードです。アサーションが沢山あるひとつのテストメソッドではなく、アサーションがひとつのテストメソッドが沢山ある状態を作り出します。

describe Greeter do
  before do
    @greeter = Greeter.new
  end
  context 'morning' do
    (5...12).each do |i|
      specify "at #{i} AM" do
        @greeter.greet(Time.mktime(2000,1,1,i)).should eq 'おはようございます'
      end
    end
  end
  # ...略...
end

こうすることで、 Assertion Roulette 状態は脱することができます。アサーションが失敗した時に、どのテストが失敗したのか見分けを付けられるようにテスト名にデータをつなげているところがコツです。

[ポイント] やりすぎ禁物

とはいえ、たとえテストメソッドの外であれ、テストにループを書くのはやはり可読性を損ねるものです。やりすぎてループが二重になったりすると、テストの可読性はもはやかなり悪化しています。プロダクトコードと同様にテストコードも重複が無い DRY (Don't Repeat Yourself) な状態が重要ですが、やり過ぎは禁物です。

テストコードは意図を表現するシンプルなコードであることが重要です。Assertion Roulette を避けるコードとシンプルなコードが両立できないときは、シンプルなコードを選択することが大事です。何事もこだわりすぎは禁物です。

シンプルさだけでなくテスト失敗時の振る舞いも重要なので、目指したいのは Assertion Roulette 等の失敗時の情報不足を避けるテストコーディングをしつつ、シンプルなコードを求める姿勢です。

シンプルさと失敗時の情報粒度を保ちつつ、より意図を表現するテストを得るためには、次節の「パラメータ化テスト」に進むことも考えてみてください。

[ポイント] パラメータ化テスト(Parameterized Test)

「名前重要」というプログラミングの基本は、テストメソッド名にも当然適用されます。可読性の高いテストのためには、テストメソッド名が非常に重要です。

ただ、上記のテストコードのように、同じ振る舞いに対してデータが違うだけのテストに対して one assertion per test を心がけて Assertion Roulette 状態を避けるようなテストコードを書いていると、テストメソッド名の名付けに困る状況がしばしば現れます。テストメソッド外にループを書くようなときも、テストの名前よりデータが重要、という状況であるといえます。

ほぼ同じテスト内容でデータだけを変えたテストメソッドを(列挙であれループであれ)書いているときに、テストメソッドにパラメータを渡せればいいのに、と感じることがあると思います。このようなときはパラメータ化テスト (Parameterized Test)というキーワードでテスティングフレームワークの機能を探してみてください。 Parameterized Test とは、まさに似たようなテストメソッドをテストデータだけ変えて複数件実行する手段で、多くの場合テスティングフレームワークの拡張機能として提供されています。

お使いのテスティングフレームワークで、ぜひパラメータ化テストの書き方を調べてみてください。JUnit4 では Parameterized アノテーションTheories アノテーション、JUnit5 ではParameterizedTest アノテーション、PHPUnit ではデータプロバイダ、RSpec ではrspec-parameterized などが使えます。JUnit でパラメータ化テストを書く場合には、内部クラスを利用した入れ子構造のテストを組み合わせると効果的です。JUnit4 における入れ子のテストとパラメータ化テストに関しては、詳しくは『JUnit 実践入門』を読んでみてください。JUnit5 で入れ子のテストを書く場合はJUnit 5 User Guide - Nested Testsが参考になります。

今回の RSpec で書かれたテストコードを rspec-parameterized を使って書いてみると、例えば以下のようなテストコードになります。テストデータをテストメソッドから分離できるパラメータ化テストの特徴が出ているのではないでしょうか。

require 'rspec'
require 'rspec-parameterized'
require_relative './greeter'

describe Greeter do
  def greet_at(anHour)
    greeter = Greeter.new
    greeter.greet(Time.mktime(2000, 1, 1, anHour))
  end

  where(:hour, :expected_greet) do
    [
     [0,  'こんばんは'],
     # 中略
     [4,  'こんばんは'],
     [5,  'おはようございます'],
     # 中略
     [11, 'おはようございます'],
     [12, 'こんにちは'],
     # 中略
     [17, 'こんにちは'],
     [18, 'こんばんは'],
     # 中略
     [23, 'こんばんは'],
    ]
  end

  with_them do
    it { expect(greet_at(hour)).to eq(expected_greet) }
  end
end

なお、パラメタライズドテストをどう使うかは、今回の問題のゲスト解答者、後藤さんのコードにも出てきますので、ぜひ読んでみてください。

アプローチ2: 組み込みクラス/メソッド/関数に介入する

さて、次のアプローチに行きましょう。 今回最も多くの方が選択したのが、この「組み込みクラスに介入する」アプローチでした。

具体的には、多くの言語が備えている「現在時刻を返すメソッド」を動的に差し替え、現在時刻を取得しようとするとテストの中から設定した時刻が返されるようにする、というアプローチです。このアプローチを選択できるのは主に動的型付き言語ですが、きんきんさんは C# 4.0 で Moles というライブラリを使用して標準クラスに介入しています。やりようによっては静的型付き言語でもこのアプローチを行えるようですね(※ Moles はその後 Visual Studio の Fakes Framework に統合されたようです。参考:Microsoft Fakes を使用したテストでのコードの分離 - Visual Studio | Microsoft Docs)。

ではこのアプローチのテストコードの代表例として、 ynak さんのテストコード (RSpec) を見てみましょう。プロダクトコードは、前節の ciel さんのコードの引数無しバージョンとほぼ同じと考えてください。

describe Greeting do
    subject do
        greeter = Greeting.new
        greeter.greet()
    end
    context 'at morning' do
        it do
            Time.stub(:now).and_return(Time.new(2013,10,12,5))
            expect(subject).to eq("おはようございます")
        end
    end

    context 'at noon' do
        it do
            Time.stub(:now).and_return(Time.new(2013,10,12,12))
            expect(subject).to eq("こんにちは")
        end
    end

    context ' at night' do
        it do
            Time.stub(:now).and_return(Time.new(2013,10,12,18))
            expect(subject).to eq("こんばんは")
        end
    end
end

前の節の ciel さんのコードにも出てきましたが、 Ruby は Time.now という特異メソッドで現在時刻を返します。

ynak さんのテストコードでは、テスティングフレームワーク RSpec の stub 機能を使用して、テスト毎に望む値を返すように Time.now を偽物に置き換えています。たとえば Time.stub(:now).and_return(Time.new(2013,10,12,5)) としておくと、Time.now は現在時刻の代わりに Time.new(2013,10,12,5) の結果を返すというわけです。

[ポイント] テストダブル

再現性のあるテストのために本物のオブジェクトを置き換えたテスト用の偽物オブジェクトのことを テストダブル (Test Double) といいます。Double とは、影武者のようなニュアンスだと考えてください。テストダブルには様々な種類がありますが、今回のお題の解答者の皆様はスタブ(Stub)モック(Mock)を使うことが多いようです。

テストダブルは、ユニットテストの依存を減らし、再現性を上げるための非常に強力なテクニックです。強力すぎる故に、多用してしまいやすく、脆いテスト(後述)の原因になりやすいなどの問題もありますが、ユニットテストを記述する際に必須の技術とも言えますので、ぜひマスターしましょう。

テストダブル、スタブ、モックなどのより詳しい分類や説明は、xUnit Test Patterns Wiki の Test Double のページや、xUnit Test PatternsのTest Doubleパターン(Mock、Stub、Fake、Dummy等の定義) - 千里霧中 を読んでみてください。

このアプローチのメリット

このアプローチのメリットは、前節のアプローチとも共通ですが、対象クラスにテストのための仕組みが不要なことです(つまり、今後紹介するアプローチにはテストのための仕組みやコードが多かれ少なかれ登場します)。

テストのためのコードは本来は不要なコードであるという主張は、ある程度は真実です。動的型付き言語の力やモックライブラリの能力を使い、プロダクトコードは特にテストへの考慮を行わずともテストを書けるというのは、実装の後からであっても、他の人の書いたコードに対してであっても、容易にテストを書けることを意味します。

このアプローチのデメリット

このアプローチのデメリットは、強力さの裏返しです。力には責任が伴います。組み込みクラス/メソッド/関数の動きを変えるというのは、影響の大きい変更です。

例えば現在時刻を返すメソッド Time.now を差し替えるとき、影響を受けるのは直接的あるいは間接的に Time.now を使用しているすべての部分です。思ったよりも影響範囲が広いなと感じる方が多いのではないでしょうか。標準ライブラリは一種の有名人であるため、グローバル汚染が発生してしまうのです。強力さはリスクと隣り合わせです。思わぬ副作用に注意しなければなりません。

ゆえに、このアプローチを使う場合には、テスト後の始末を忘れずに行うことが重要です。テストの中で振る舞いを変えた標準クラス/メソッド/関数を元に戻しておかないと、他のテストにも影響を及ぼします。

[ポイント] 良いユニットテストは Independent (独立している)

あるテストの内容が他のテストの結果に作用するとき、それらのテストには依存関係があります。しかし、良いユニットテストはテスト間に依存関係を持ってはならず、互いに独立(Independent)していなければなりません。

テスト間が static 領域やグローバル変数、外部ファイルやデータベース、 Singleton 等を介してつながってしまっているテストは Independent ではありません。

Independent でないユニットテストは、特定の順番でないと通らないテストや、テスト群をまとめて動かすと通るのに、一つだけではなぜか通らない(またはその逆)テストのような、Erratic Test (不安定なテスト)を生み出します。特にテストが裏で繋がって作用しあってしまうことを、Interacting Testsといいます。

また、 Independent でないユニットテストは、並列実行できないという弱点を抱えることにもなります。実行順に依存関係があるテストたちは、直列に実行しなければならないからです。

[ポイント] 後始末を忘れずに行い、テストを独立させる

テストを互いに独立させるために、テストで差し替えた振る舞いは、各テスト後に元に戻しましょう。なお、テスティングフレームワークに同梱されたモックライブラリの場合は、各テストの終了時に差し替えた振る舞いを自動的に元に戻してくれるものもあります。たとえば今回 ynak さんが使用している RSpec の mock/stub 機能は、差し替えた振る舞いを各テスト後に自動的に元に戻します。

フレームワークに頼らず自前で振る舞いを差し替えた場合には、各テストメソッドの終了時に呼び出されるフック (多くの場合 tearDown または afterEach 等の名前がついています) で、差し替えた振る舞いを元に戻しましょう。

今回の解答者の中では、 ishiduca さんが JavaScript で Date クラスに対して自前のスタブを作成し、 teardown で元に戻しています。

// ...略...
q.module('.greet', {
    setup: function () {
        var stub = this.stub = {}
        this.getUTCHours = Date.prototype.getUTCHours
        this.getUTCMinutes = Date.prototype.getUTCMinutes
        Date.prototype.getUTCHours   = function () { return stub.hour }
        Date.prototype.getUTCMinutes = function () { return stub.min }
    }
  , teardown: function () {
        Date.prototype.getUTCHours = this.getUTCHours
        Date.prototype.getUTCMinutes = this.getUTCMinutes
        this.stub = null
    }
})

q.test('.greet は時刻によって適切な挨拶を返すか', function (t) {
    var stub = this.stub
    var g = new Greet
    function subt (gmtHour, min, result) {
        stub.hour = gmtHour
        stub.min  = min
        t.is(g.greet(), result)
    }
    subt(0,  0, 'おはようございます') //  9:00
    subt(2, 59, 'おはようございます') // 11:59
    subt(3,  0, 'こんにちは') // 12:00
    subt(8, 59, 'こんにちは') // 17:59
    subt(9,  0, 'こんばんは') // 18:00
    subt(15, 0, 'こんばんは') // 24:00
    subt(19, 0, 'こんばんは') //  4:00
})
// ...略...

ちなみに ishiduca さんが使用しているのは Perl 系のテスト文化を継いだテスティングフレームワーク QUnit なので、縦に並んでいるアサーションの途中で失敗しても打ち切られず先に進みます。ただし、t.isアサーションメッセージ引数を与えないと、結局は Assertion Rouletteになってしまう点に注意しましょう。

アプローチ3: 対象クラスだけが使う組み込みクラスに介入する

「組み込みクラスに介入する」アプローチに似ていますが、 antimon2 さんは一風変わったアプローチでテストに取り組んでいます。まずはプロダクトコード (Ruby) を見てみましょう。特に特筆する点はなく、シンプルで穏当なコードになっています。

class Greeter
  def greet
    time = Time.now
    hms = time.hour * 10000 + time.min * 100 + time.sec
    case hms
    when 50000...120000
      "おはようございます"
    when 120000...180000
      "こんにちは"
    else
      "こんばんは"
    end
  end
end

では、テストコード (test::unit) を見てみましょう。

require 'test/unit'

class Greeter
  # トップレベルの Time クラスを継承して動作変更
  class Time < ::Time
    @@now = nil

    def initialize *args
      if !@@now.nil? && args.empty?
        args = [@@now.year, @@now.mon, @@now.day, @@now.hour, @@now.min, @@now.sec]
      end
      super *args
    end

    class << self
      def now= time
        @@now = time
      end

      def now
        @@now || new
      end
    end
  end
end

class GreeterTest < Test::Unit::TestCase
  # ...略...
  def test_greet_afternoon_1759
    Greeter::Time.now = Time.local(2013, 10, 8, 17, 59, 59)
    greeter = Greeter.new
    result = greeter.greet
    assert_equal("こんにちは", result)
  end

  def test_greet_evening_1800
    Greeter::Time.now = Time.local(2013, 10, 8, 18, 0, 0)
    greeter = Greeter.new
    result = greeter.greet
    assert_equal("こんばんは", result)
  end
  # ...略...
end

テストコードの最初で Greeter クラスの定義を開き、 Greeter から見える Time を書き換えています。こうすることで、組み込みクラス Time の動作を書き換える影響範囲を Greeter クラスにとどめているわけですね。テストの影響範囲を狭める仕組みとして、面白い発想だと思います。

このアプローチのメリット

このアプローチのメリットは、プロダクトコードにテストのためのコードが不要で、なおかつ「組み込みクラスに介入する」アプローチより影響範囲が小さいことです。

このアプローチのデメリット

このアプローチのデメリットは「組み込みクラスに介入する」アプローチのデメリットと同様です。強力さにはリスクが伴うので利用には注意し、後始末を忘れずに行わなければなりません。また当然ながら、プログラミング言語によっては選択できないアプローチであることも明記しておかなければなりません。

このコードの改善できる点

このテストコードで気になるのはテストメソッド間のコード重複が多いことです。また、現在時刻の設定コードが各メソッドでむき出しになっており、ノイズになってしまっています。ここから、次の改善ポイント「テストコードのノイズを減らす」が導き出されます。

[ポイント] テストコードのノイズを減らす

テストコードの中に重複が増えてきたり、テスト条件の組み立て部分のコードが複雑になってくると、少し見ただけでは何をテストしているのかを読み取りにくくなってきます。テストメソッドの中には、他のテストメソッドと違う点だけを、違いが際立つように書きたいものです。

例えば今回のコードでは共通化できる部分は各テストメソッド開始時のフックメソッド setup に抽出し、現在時刻設定部分は、テスト毎に違う部分だけを書けば良いようにこちらもメソッドに抽出します。すると、例えば以下のように書けます。

class GreeterTest < Test::Unit::TestCase
  def setup
    @greeter = Greeter.new
  end

  # ...略...
  def test_greet_afternoon_1759
    set_current_time(17, 59, 59)
    assert_equal("こんにちは", @greeter.greet)
  end

  def test_greet_evening_1800
    set_current_time(18, 0, 0)
    assert_equal("こんばんは", @greeter.greet)
  end
  # ...略...

  private

  def set_current_time(hour, minute, second)
    Greeter::Time.now = Time.local(2013, 10, 8, hour, minute, second)
  end
end

テスト毎に違う部分が際立つようになり、テストの可読性が上がったことがわかるのではないでしょうか。ノイズ減らしはテストコードのリファクタリングとして手軽に行える第一歩ですので、ぜひ取り組んでみてください。

アプローチ4: 対象クラスのテスト用サブクラスをテスト内で作成 (Test-Specific Subclass)

次のアプローチは、対象クラスの振る舞いをテスト内で部分的に上書きするアプローチです。現在時刻を取得する部分をメソッドにしておいて、そのメソッドだけテストからオーバーライドするというわけです。テスト対象の一部分をテスト用のサブクラスでオーバーライドするアプローチは、Test-Specific Subclassという名前もついているスタンダードな手法です。

解答された方の中では、 kencharos さんがこのアプローチを採用しています。プロダクトコード (Java) を見てみましょう。

public class Greeter {
  private static final int MORNING = 50000;
  private static final int NOON = 120000;
  private static final int NIGHT = 180000;

  protected Calendar current() {
    return Calendar.getInstance(TimeZone.getTimeZone("Asia/Tokyo"));
  }

  public String greet() {
    Calendar cal = current();
    int hh = cal.get(Calendar.HOUR_OF_DAY);
    int mm = cal.get(Calendar.MINUTE);
    int ss = cal.get(Calendar.SECOND);
    int time = hh * 10000 + mm * 100 + ss;
    if (MORNING <= time && time < NOON) {
      return "おはようございます";
    } else if (NOON <= time && time < NIGHT) {
      return "こんにちわ";
    } else {
      return "こんばんわ";
    }
  }
}

kencharos さんのコードでは current メソッドがオーバーライド対象のメソッドですね。このメソッドだけオーバーライドしたインスタンスをテスト内で作成し、テストに使用するという方針です。

ではテストコード (JUnit 4.4) を見てみましょう。

public class GreeterTest {
  private Greeter setTime(final int hh, final int mm, final int ss) {
    return new Greeter() {
      protected Calendar current() {
        Calendar cal = Calendar.getInstance();
        cal.set(Calendar.HOUR_OF_DAY, hh);
        cal.set(Calendar.MINUTE, mm);
        cal.set(Calendar.SECOND, ss);
        return cal;
      }
    };
  }
  @Test
  public void testMorningStert() {
    Greeter greeter = setTime(5,0,0);
    assertThat(greeter.greet(), is("おはようございます"));
  }
  @Test
  public void testMorningEnd() {
    Greeter greeter = setTime(11,59,59);
    assertThat(greeter.greet(), is("おはようございます"));
  }
  // ...以下略...

Greeter クラスを継承してテスト用のインスタンスを返しているのは setTime メソッドですね。各テストメソッドから時、分、秒を渡し、再現性のあるテストを書けるように工夫しています。

kencharos さんのテストコードの良い所は、各テストメソッドにノイズが少ないことです。各テストメソッドは準備、実行、検証を2行にまとめてあります。煩雑なコードになりがちな時刻の制御がメソッドに切りだされているので、各テストメソッドにはデータだけを書けばよく、読みやすいテストコードになっています。

このアプローチのメリット

このアプローチのメリットは、単純であることです。オブジェクト指向言語であれば動的であれ静的であれ選択できるアプローチであり、テスティングフレームワークにモック機能等がなくともテストを行えます。テストのために振る舞いが上書きされた部分はテストクラスの中に単純に書かれているため、見通しが立てやすいのもメリットといえます。

このアプローチのデメリット

このアプローチのデメリットは、テスト容易性のための仕組みがプロダクトコード側に必要な点です。

テストからプロダクトコードの一部をオーバーライドするには、テストコードはプロダクトコードの中身まで知っていなければなりません。これは結合度の高い状態であるといえます。テスト対象の実装が変わったら、テストコード側も忘れずに変更に追従していかなければなりません。

また、テストコードにプロダクトコードの構造の知識が一部流出しているので、テストが「外部から見た振る舞いの検証」になりきれていません。つまり、仕様のテストではなく、実装のテストになってしまっているのです。

[ポイント] 脆いテスト(Fragile Test)

仕様は変わっていないのに、実装が変わったら失敗してしまうテストは、実装の知識がテストコードに漏れ出しているときに発生しやすくなります。つまり、不用意に実装に依存しているテストである可能性があります。このように失敗しなくとも良いタイミングで失敗するテストを脆いテスト(Fragile Test)といいます。不可解なタイミングで失敗するテストは、実装のテストになってしまっていないか注意しましょう。

このコードの改善できる点

かなりきれいな部類のテストコードなのですが、あえて改善点を挙げるならば、setTime という名前からは Greeter のサブクラスのインスタンスを返すことが想像しにくいので、その点が改善されればさらに読みやすいテストコードになると思います。さらに重箱の隅をつつくならば、テストメソッド名の綴り間違い等を直したいところです。ここから、もう一つの改善点「日本語テストメソッド」が導き出されます。

[ポイント] 日本語テストメソッド

日本語テストメソッドとは、日本語でコミュニケーションをとっているプロジェクトの場合、日本語でテストメソッド名を書いても良いのではないかという考え方です。ぎこちない英語による記述をコメントで補うくらいなら、最初から日本語で書いてしまいましょう、とも言い換えられます。英語で書くのは難しかった細かいニュアンス等も母語の日本語であれば書けるのではないでしょうか。

@Test
public void 朝の挨拶開始時刻は午前5時から() {
  Greeter greeter = setTime(5,0,0);
  assertThat(greeter.greet(), is("おはようございます"));
}
@Test
public void 朝の挨拶終了時刻は午前115959秒まで() {
  Greeter greeter = setTime(11,59,59);
  assertThat(greeter.greet(), is("おはようございます"));
}

日本語テストメソッドにはもう一つの利点があります。日本語は英数字や記号だらけのコードや出力の中でひときわ目立つのです。

テストメソッドの中身をわざと失敗するように書き直して、失敗時の出力を見てみましょう。

    JUnit version 4.11
    ...E...E
    Time: 0.013
    There were 2 failures:

    1) 朝の挨拶終了時刻は午前11時59分59秒まで(GreeterTest)
    java.lang.AssertionError:
    Expected: is "おそようございます"
         but: was "おはようございます"
        at org.hamcrest.MatcherAssert.assertThat(MatcherAssert.java:20)
        at org.junit.Assert.assertThat(Assert.java:865)
        at org.junit.Assert.assertThat(Assert.java:832)
        at GreeterTest.朝の挨拶終了時刻は午前11時59分59秒まで(GreeterTest.java:27)

    2) 朝の挨拶開始時刻は午前5時から(GreeterTest)
    java.lang.AssertionError:
    Expected: is "おはよう"
         but: was "おはようございます"
        at org.hamcrest.MatcherAssert.assertThat(MatcherAssert.java:20)
        at org.junit.Assert.assertThat(Assert.java:865)
        at org.junit.Assert.assertThat(Assert.java:832)
        at GreeterTest.朝の挨拶開始時刻は午前5時から(GreeterTest.java:22)

Assertion Roulette の説明のところで「どのアサーションが失敗したのかわかりにくい」という話をしましたが、テスト名もきちんと付けないと、結局どのテストが失敗したのかコードを読んで調べなければならなくなります。テスト名が日本語で適切に書いてあれば、失敗したテストの出力を見るだけで、どのようなテストが失敗したのか分かるようになります。条件に合う場合は、ぜひ日本語テストメソッドを検討してみてください。

日本語テストメソッドに関する議論はテストメソッドを日本語で書くことについて - Togetterや、日本語テストメソッドについて等で読めます。

アプローチ5: テストを対象クラスのサブクラスにしてオーバーライド (Self Shunt)

前節のアプローチではテスト対象クラスのサブクラスをテスト内で作成していました。この考えを更に進めると、テスト対象をテスト可能にするためにサブクラスを作るのであれば、いっそのことテストクラス自身をテスト対象クラスのサブクラスにしてしまえば良いのではないだろうか、というアイデアにたどり着きます。このアプローチはSelf Shunt(自己接続)という名前も付いている、実は由緒正しいテクニックなのです。

今回は anagotan さんが Self Shunt アプローチでお題を解いてくださいました。解答者の中に Self Shunt を使う人が出てくるとは思っていないかったので、これにはかなり驚きました。ではまず anagotan さんのコード (Java) を見てみましょう。

public class Greeter{

    // 現在時刻を返す
    public Date getDate(){
        return new Date();
    }

    public String greet(){
        SimpleDateFormat df=new SimpleDateFormat("HH");
        int now=Integer.parseInt(df.format(getDate()));
        if(5<=now && now<12){
            return "おはようございます";
        }else if(12<=now && now<18){
            return "こんにちは";
        }else{
            return "こんばんわ";
        }
    }
}

次に Self Shunt テクニックが使われているテストコード (JUnit4) を見てみましょう。

public class GreeterTest extends Greeter{
    // staticにしておく
    private static Date date;

    @Override
    public Date getDate(){
        // テスト用にオーバーライドする
        return date;
    }

    @Test
    public void test(){
        testWithHour(0,0,0,"こんばんわ");
        testWithHour(4,59,59,"こんばんわ");
        testWithHour(5,0,0,"おはようございます");
        testWithHour(8,0,0,"おはようございます");
        // ...以下略...
    }

    private void testWithHour(int hour,int minute,int second,String answer){
        Calendar now=Calendar.getInstance();
        //テスト用の時間をセット
        now.set(Calendar.HOUR_OF_DAY, hour);
        now.set(Calendar.MINUTE, minute);
        now.set(Calendar.SECOND, second);
        date=now.getTime();
        Assert.assertEquals(new GreeterTest().greet(),answer);
    }
}

GreeterTest が Greeter を継承できるのは、 JUnit4 がテスト用の親クラスを持たないからですね。特定の親クラスを継承せずともテストを書けるテスティングフレームワークであれば、 Self Shunt アプローチが可能です。

このアプローチのメリット

このアプローチのメリットは、テストクラス自身がテスト対象クラスを継承するので、テストから対象クラスの内部状態にアクセスしやすくなることです。テストクラスがテスト対象クラスのサブクラスなので、対象の任意のメソッドをオーバーライドしたり、テスト対象内部のインスタンス変数の状態を見たりするテストを書きやすくなります。

このアプローチのデメリット

このアプローチのデメリットは基本的には「対象クラスのテスト用サブクラスをテスト内で作成」アプローチと同様ですが、 Self Shunt はメリットとデメリットが更にハッキリしています。テストクラス自身がテスト対象クラスを継承するので、テストコードとプロダクトコードの結合度が更に高くなるのです。ゆえに、脆いテスト(Fragile Test)に陥る危険性も高まります。

また、プログラミング言語やテスティングフレームワークによってはテスト対象を継承できないので、そもそも Self Shunt アプローチを選択できない点にも注意が必要ですね。

このコードの改善できる点

このコードには、大きく三つの改善できる点があります。

  • Assertion Roulette になっている
  • テスト内の date 変数が static である必要はない
  • Greeter#getDate が public である必要はない

まず、ひとつのテストメソッドの中にアサーション(を内包したメソッド)が縦に並んでいて、典型的な Assertion Roulette になっているので、テストデータ毎にテストメソッドを分けましょう。また、テストのバリエーションをループ等で表現したい場合は、 Java はテスト用の制御構造をテストメソッド外に書けるような柔軟性は持っていないので、一足飛びに理想状態、パラメータ化テスト(Parameterized Test)にするのが良いでしょう。

[ポイント] static を避け、テストメソッド間の依存関係を断つ

次に、テスト内の date 変数が static である必要もありません。static にすると、テストメソッド間に暗黙の依存関係を生んでしまいます。暗黙の依存関係は、ユニットテストは互いに独立しているべき(Independent)という原則に反しており、Interacting Testsの原因になります。

テスティングフレームワークの多くは、テストメソッド毎にテストクラスのインスタンス化を行います。つまり static 領域はテストメソッド間で共有されますが、インスタンス変数は共有されません。テストに使うデータは毎回生成/破棄されるインスタンス変数を使いましょう。

テスティングフレームワークがなぜテストメソッド毎にテストクラスのインスタンス化を行うのかは、JUnit新インスタンスを読んでみてください。

[ポイント] テストだけに使う部分の可視性を下げる

最後に、 Greeter#getDate が public である必要はありません。Self Shunt を使っているので public でなくともオーバーライドできますし、getDate メソッドは Greeter クラスの責務とは直接は関係がありません。テストのためだけに使う部分は、なるべく可視性を下げておきましょう

テストからオーバーライドする対象の可視性をどうするかは、Java の場合は protected や default (package private) と呼ばれる可視性にしておくのが妥当でしょう。private メソッドに対してリフレクションを使用して介入する手もありますが、これは更に不必要に実装に依存してしまうことにもなるので、頭が痛いところです。ここから、次のポイントにつながります。

[ポイント] private メソッドとテスト

ユニットテストを書いていると、 private メソッドをどうするか、という問題によく出会います。リフレクション等を使って private メソッドをテストしたい場合や、テストのために private メソッドを呼び出したい場合は、なにかおかしいことの予兆と考えた方が良いでしょう。 private に触れたくなるのは、テスト対象が責務を持ちすぎていることのサインです。無理にそのままリフレクションを使うのではなく、リファクタリングによって解決した方が、良い結果を生むことが多いでしょう。プライベートメソッドとテストの議論に関しては、プライベートメソッドのユニットテストは書かないもの? - QA@IT も読んでみてください。

アプローチ6: 対象クラスの特定メソッド定義をテストで書き換える

対象クラスを継承するアプローチのバリエーションとして、動的型付き言語の特性を活用して、対象クラス定義へ介入するというアプローチもあります。言語本来の機能やテスティングフレームワークのテストダブル機能を使い、テスト対象クラスの定義をテスト毎に少し書き換えて実行するというものです。

解答者の皆様の中では ushiboy さんがこのアプローチで問題を解いています。プロダクトコード (JavaScript) を見てみましょう。

// ...略...
/**
 * 現在時刻の挨拶を返す
 *
 * @return {String} 挨拶
 */
Greeter.prototype.greet = function() {
    // 現在時刻
    var now = this.getNow(),
        // 午前の開始タイムスタンプ
        amForm = (new Date(now)).setHours(0, 0, 0, 0) + 5 * 60 * 60 * 1000,
        // 午後の開始タイムスタンプ
        pmFrom = amForm + 7 * 60 * 60 * 1000,
        // 夕方の開始タイムスタンプ
        nightFrom = pmFrom + 6 * 60 * 60 * 1000;
    if (now >= amForm && now < pmFrom) {
        return this.messages.am;
    } else if (now >= pmFrom && now < nightFrom) {
        return this.messages.pm;
    }
    return this.messages.night;
};

/**
 * 現在時刻をタイムスタンプで返す
 *
 * @private
 * @return {Number} 現在時刻
 */
Greeter.prototype.getNow = function() {
    return Date.now();
};
// ...略...

getNow メソッドが現在時刻を取得するメソッドですね。次にテストコード (Jasmine) を見てみましょう。

describe('#greet', function() {
    it('朝の場合おはようございますを返す', function() {
        var date = new Date();
        spyOn(Greeter.prototype, 'getNow')
          .andReturn(date.setHours(5, 0, 0, 0))
          .andReturn(date.setHours(11, 59, 59, 999));
        var greeter = new Greeter();
        expect(greeter.greet()).toBe('おはようございます');
        expect(greeter.greet()).toBe('おはようございます');
    });
    it('昼の場合こんにちはを返す', function() {
        var date = new Date();
        spyOn(Greeter.prototype, 'getNow')
          .andReturn(date.setHours(12, 0, 0, 0))
          .andReturn(date.setHours(17, 59, 59, 999));
        var greeter = new Greeter();
        expect(greeter.greet()).toBe('こんにちは');
        expect(greeter.greet()).toBe('こんにちは');
    });
    // ...以下略...
});

Jasmine のテストダブル機能 spyOn メソッドを使用して Greeter のインスタンス化前に Greeter#getNow の定義に介入し、テストで指定された Date を返すように仕向けています。「対象クラスのテスト用サブクラスをテスト内で作成」アプローチの動的型付き言語版という位置づけと言ってもいいかもしれません。

このテストコードは、テストメソッドの中にアサーションが2行ずつあります。なぜ2行あるのか少し見ただけでは意図が読み取りづらいかもしれませんが、実はモックライブラリの一般的な機能と関係があります。

spyOn(Greeter.prototype, 'getNow')
  .andReturn(date.setHours(12, 0, 0, 0))
  .andReturn(date.setHours(17, 59, 59, 999));

という部分は、 Greeter#getNow というメソッドの定義を、getNow が1回目に呼び出されたときは date.setHours(12, 0, 0, 0) を、2回目に呼び出されたときは date.setHours(17, 59, 59, 999) を返すように書き換えています。メソッドの戻り値を1回目と2回目で変えるようにしているのですね。呼び出し毎に戻り値を変えられる機能は、モックライブラリの強力な部分でもあります。

このアプローチのメリット

このアプローチのメリットは、サブクラスを作る必要が無く、継承をつかったアプローチに比べると手軽であることです。また、これは継承をつかったアプローチでも無名内部クラス等を使えば行えることですが、テストメソッド毎に振る舞いを変更できます。

このアプローチのデメリット

このアプローチのデメリットは、基本的に「対象クラスのテスト用サブクラスをテスト内で作成」と同様です。加えて、クラス定義を実行時に書き換えているので、テスト後に定義を元に戻すような後始末が必要です。テスティングフレームワークやモックライブラリが定義の復元を自動的に行なってくれるかを調べ、復元してくれない場合は自前で元に戻す作業が必要でしょう。

また、直接はこのアプローチのデメリットではありませんが、今回のコードはオーバーライド対象のメソッド名 'getNow' が文字列で記述されています。変更対象のメソッドを文字列で記述するスタイルのライブラリは、テスト対象の変更に追随させることを忘れやすい点に注意してください。

このコードの改善できる点

今回の対象に関して、モックの呼び出し回数に応じて戻り値を変更する機能を使うのは、少々やり過ぎかもしれません。テストコードの中に重複やノイズが発生する原因にもなっています。テストコードのノイズを減らすリファクタリングを行い、朝の場合、昼の場合、夜の場合等のサブコンテキストに分けて、それぞれの中で一件ずつのアサーションを行うテストメソッドが複数ある状態を作れるのではないでしょうか。

アプローチ7: 現在時刻へのアクセスを行うインターフェイスを抽出

さて、本稿で紹介する最後のアプローチが「現在時刻へのアクセスを行うインターフェイスを抽出する」です。これまでのアプローチは基本的にプロダクトコードがひとつ、テストコードがひとつのクラスから構成されていました。しかし、このアプローチでは、 Greeter クラスは自分で現在時刻を取得するのではなく、現在時刻を取得するために別のオブジェクト(コラボレータ)とやりとりを行います。

このアプローチの代表的なコードとして、 tdoi さんのコードを見てみましょう。

まず tdoi さんは現在時刻へのアクセスを行うインターフェイスを Environment と名付け、問1に必要十分な getHour メソッドだけを定義しています。

public interface Environment {
    public int getHour();
}

さらに、 Environment インターフェイスと対になるデフォルト実装 DefaultEnvironment を作成しています。この DefaultEnvironment が現在時刻から時間部分を返す実装になっているわけですね。

import java.util.Calendar;

public class DefaultEnvironment implements Environment {
    public int getHour() {
        Calendar calendar = Calendar.getInstance();
        return calendar.get(Calendar.HOUR_OF_DAY);
    }
}

このアプローチでは、 Greeter クラスは現在時刻を知りません。だれが現在時刻を知っているのかだけを知っています。つまり、Greeter は Environment インターフェイスを実装したコラボレータから現在時刻を取得して挨拶を返すという責務だけを担います。

public class Greeter {

    Environment environment = new DefaultEnvironment();

    public void setEnvironment(Environment environment) {
        this.environment = environment;
    }

    public String greet() {
        int hour = this.environment.getHour();
        if (0 <= hour && hour < 12) {
            return "おはようございます";
        } else if (12 <= hour && hour < 18) {
            return "こんにちは";
        } else {
            return "こんばんは";
        }
    }
}

インスタンス変数 environment にはデフォルトで DefaultEnvironment クラスのインスタンスが設定されているので、テスト等から差し替えられることがなければ Greeter は DefaultEnvironment から現在時刻を取得し、現在時刻に合わせた挨拶を返すというわけですね。

ではテストコードを見てみましょう。

import static org.junit.Assert.*;
import org.junit.*;
import org.jmock.Mockery;
import org.jmock.Expectations;

public class GreeterTest {

    Mockery context = new Mockery();

    Greeter greeter = null;
    Environment environment = null;

    @Before
    public void setUp() {
        environment = context.mock(Environment.class);
        greeter = new Greeter();
        greeter.setEnvironment(environment);
    }

    @Test
    public void responseShouldBeOhayogozaimasuAt0500() {
        responseShouldBeExpectedWord(5, "おはようございます");
    }

    @Test
    public void responseShouldBeKonnichiwaAt1200() {
        responseShouldBeExpectedWord(12, "こんにちは");
    }

    @Test
    public void responseShouldBeKonbanwaAt1800() {
        responseShouldBeExpectedWord(18, "こんばんは");
    }

    private void responseShouldBeExpectedWord(int hour, String expected) {
        final int currentHour = hour;
        context.checking(new Expectations() {{
            oneOf (environment).getHour(); will (returnValue(currentHour));
        }});

        String response = greeter.greet();

        context.assertIsSatisfied();
        assertEquals(expected, response);
    }
}

ここでテストに使われているライブラリは『実践テスト駆動開発』の著者二人 (Steve Freeman, Nat Pryce) も開発に関わっている jMock2 というモックライブラリです。実はこの二人はモックオブジェクトの発見にも関わっています。

テストの前準備部分 (@Before アノテーションの付いたメソッドはテストメソッド毎に実行されます) で context.mock(Environment.class) を呼び出し、 Environment インターフェイスのテスト用の偽物(モックオブジェクト)を作成し、Greeter の setEnvironment メソッドを使用して Greeter が使う Environment インスタンスをあらかじめテスト用のモックオブジェクトに差し替えておきます。

各テストではモックオブジェクトが返す値をそれぞれ設定し、時刻に対応した返答があるかどうかをテストしているわけですね。

プロダクトコードもテストコードも適切な粒度のクラス/メソッドに切り分けられていて、バランスが良く穏当なコードになっていると思います。かなり好印象です。

なお、ゲスト解答者の後藤さんも、「現在時刻へのアクセスを行うインターフェイスを抽出する」アプローチを使っています。後藤さんはどのような設計を行ったのか、ぜひ読んでみてください。

このコードの改善できる点

一見してわかるのですが、ややテスト件数が不足しています。 Assertion Roulette に注意しながら、足りないテストを足していきましょう。また、現時点で十分可読性の高いテストコードになっていますが、日本語テストメソッドを使えばさらに可読性の高いコードになるでしょう。

なお、 jMock2 は強力ですが少々トリッキーな記述を必要とするモックライブラリなので、他のモックライブラリと比較検討した上で導入するのが良いでしょう。例えば Java で同じ「現在時刻へのアクセスを行うインターフェイスを抽出する」アプローチで解いた Touchu さんは mockito というライブラリを使用しています。モックライブラリの記述性はテストの可読性や変更容易性に直接影響しますので、ライブラリの選択は非常に重要です。

このアプローチのメリット

このアプローチのメリットは、実はテストのための仕組みを導入しているわけではない、という点です。テクニックではなく設計変更によって、裏口(実装の上書き)ではなく表玄関(コラボレータの差し替え)から、テストを行えるようになっています。

テスト対象の中身を書き換えるのではなく、テスト対象とやり取りを行うコラボレータをすり替えています。テスト対象は普段と変わらず Environment オブジェクトと話しているだけですから、テストのためにテスト対象を上書きするという強引さはありません。テスト対象の内部実装ではなく外部から見た振る舞いに応じたテストができている、というところがポイントです。

Environment の責務は現在時刻の時間部分を int で返すことです。そして Greeter の責務は Environment に現在時刻の数値を聞き、その値に応じて挨拶の内容を返すことです。それぞれ責務がはっきりしています。登場人物は二人に増えましたが、やり取りする情報は int だけになりました。

DefaultEnvironment の方も個別にテストを行う必要がありますが、こちらは現在時刻の時部分を返すことだけをテストすればよいので、テストの難易度は下がります。責務が単純になるので、特に本稿の他のアプローチを使わずともテストが書けるのではないでしょうか。

[ポイント] 外部環境との界面にインターフェイスを作成し、テストダブルで置き換える

現在時刻に関する設計判断は、実はテストだけの問題ではありません。その時刻は、どう使われるのでしょうか。仕様に「現在時刻を保存する」などと書かれている場合に、本当にそれは厳密な現在時刻を必要としているのかどうか、各所で Time.now 等で取得するように実装すべきなのかどうか、よく考える必要があります。処理のタイミングによって現在時刻はもちろん変わります。処理に時間がかかったときには、現在時刻に依存したロジックがある場合には動作不良の原因になることがありますし、後からデータを絞り込む際には、時刻の完全一致で調べられなくなります。

例えば Web システムの場合には、仕様にある「現在時刻」とは、実はリクエストのタイムスタンプだったことが後から分かることもあるでしょう。コードのあちこちに現在時刻を取得するメソッドが散らばってしまっている場合には、それらをひとつひとつ検証して変更していかなくてはなりません。 tdoi さんのようにインターフェイスに抽出してあれば、リクエスト時刻を返す Environment 実装を新たに作成して使えば良くなるので、変更箇所はクラスの追加と利用設定部分だけで済むことになります。

私はコードの世界、オブジェクト達の世界から外部環境にアクセスする界面の部分にインターフェイスを作成し、テストダブルで置き換えられるようにするという設計判断をよく行います。現在時刻も外部との界面だと考えています。現在時刻は、最近では強力なライブラリの出現で制御しやすくなってはきましたが、その強力さ故に副作用もあるので、やはり手強い相手です。他にも外部ネットワークに依存した部分、たとえば外部の Web API の呼び出し部分などは、テストダブルを使ってテスト可能にしておいた方が、外部の状況に依存しないテストを記述できます。

現在時刻の設計判断に関しては、詳しくはコード内で「現時刻」を気軽に取得してはいけない | Nekoya pressを読んでみてください。

このアプローチのデメリット

このアプローチのデメリットは、場合によっては「やりすぎ」を招きやすいことです。

他のアプローチに比べてクラス数やファイル数が増えていることからもわかるように、このアプローチは責務の分離、再配置と設計変更によって問題を解決しています。

テストのための設計が責務の再配置を促し、全体として責務がバランスしてシンプルになるのであれば、それはテストが良い設計へ導いてくれたということです。しかし、もしもテストのための設計がさらなる複雑さを招くなら、それは「やりすぎ」でしょう。

[ポイント] テストを設計ツールとして使う

テストを書くと、必要十分な設計に気づきやすくなります。

設計は終わりのない世界です。そして、設計について考えすぎると、終わりがない世界に踏み込んでいることに気づきにくくなります。このようなとき、テストの結果、つまり具体例なゴールから考えることで「あれもできる、これもできるかも、ああいうやりかたもあるな」というフワフワとした状態から、ピントのあった状態に戻れます。

テストコードと共に設計や実装を行うと、コードを書くことと、その書いたコードをすぐ使うことから、設計に対するフィードバックが発生します。実際にテストとコードを書きはじめると、設計だけしていたときは考え過ぎていたことが、実際にはもっとシンプルで良いと気づくことが多々あります。または逆に設計時には考えが足りておらず、実際にコードを書いたり具体的な値でテストするとすぐに考慮不足がわかる、という状況にもよく出会います。

テストを書くことは、終わりのない設計の世界から現実に戻ってくるきっかけのひとつになります。良い設計は状況によって変わります。必要十分でシンプルな設計から、次のシンプルな設計へと不安なく移るために、テストを活用してください

まとめ1: 参加言語とアプローチの内訳

ここまでで、今回のすべてのアプローチを見てきました。今回のお題では、テスト容易性のためのアプローチは、大きくは4つに分かれました。

  • 現在時刻を引数で渡す
  • 時刻ライブラリに介入
  • テスト対象の部分オーバーライド
  • 時刻アクセス用コラボレータ導入

参加者の皆様のアプローチを、以下の表にまとめてみました。

名前 言語 テスティングフレームワーク アプローチ アプローチ詳細
ciel Ruby 2.0 RSpec 2.14 現在時刻を引数で渡す greet メソッドへ日付を引数渡し
こねこねこ PHP 5.5 テスト用 main 現在時刻を引数で渡す greet メソッドへ日付を引数渡し
ganchiku PHP 5.4 phpspec 2.0 現在時刻を引数で渡す greet への引数渡し & 渡す日付をモック
tbpgr Ruby 1.9.3 RSpec 2.14 + Timecop 時刻ライブラリに介入 標準ライブラリ戻り値を Timecop で固定
ynak Ruby 1.9.3 Rspec 2.14 時刻ライブラリに介入 標準ライブラリを RSpec で stub
ishiduca JavaScript QUnit & qunit-tap 時刻ライブラリに介入 標準ライブラリを自前 stub
TatsushiD Perl Test::More 時刻ライブラリに介入 標準関数をテスト内で上書き
きんきん C#4.0 Visual Studio 2010 時刻ライブラリに介入 標準ライブラリを Moles で stub
antimon2 Ruby 2.0 test::unit 時刻ライブラリに介入 標準ライブラリを自前サブクラスでstub
kencharos Java7 JUnit 4.4 テスト対象の部分オーバーライド 自作メソッド current をテスト内サブクラスでオーバーライド
anagotan Java7 JUnit 4 テスト対象の部分オーバーライド テスト対象クラスを Self Shunt
ushiboy JavaScript Jasmine テスト対象の部分オーバーライド テスト対象のメソッド getNow を stub/spy
tdoi Java JUnit 4 + JMock 2.6 時刻アクセス用コラボレータ導入 Environment オブジェクトを作成してモック & セッターインジェクション
Touchu Java7 JUnit 4.11 + Mockito 1.9.5 時刻アクセス用コラボレータ導入 現在時刻の Factory をモック & コンストラクタインジェクション

これらのアプローチには、どれかが絶対の正解、というものはありません。本稿では、これらのアプローチには、すべてメリットとデメリットがあることを説明してきました。プログラミング言語の動的/静的の性格やテスティングフレームワーク、モックライブラリの能力によってテスト容易性設計も異なります。大事なのは、状況に合わせてシンプルで適切なアプローチを選択することです。

まとめ2: テストコードのポイント

最後に、各改善点の部分などで都度説明してきた「ポイント」をまとめておきます。これらのポイントを考えながらテストコードを書くことで、テスト容易性を考慮した設計が見えてくるはずです。本稿を参考にして、読者の皆様もぜひ自分のコードのテスト容易性設計を考えてみてください。

良いユニットテストは Repeatable (繰り返し可能、再現可能)

  • テストダブルを使いこなす
  • 外部環境との界面にインターフェイスを作成し、テストダブルで置き換える

良いユニットテストは Independent (独立している)

  • 後始末を忘れずに行い、テストを独立させる
  • static を避け、テストメソッド間の依存関係を断つ

アサーションルーレット(Assertion Roulette)に注意する

  • 目指すのは「テストメソッド毎にアサーションひとつ」(しかし、やりすぎは禁物)
  • カスタムアサーションを使う
  • パラメータ化テスト(Parameterized Test)を使いこなす

脆いテスト(Fragile Test)に注意する

  • テストだけに使う部分の可視性を下げる
  • private メソッドを扱いたくなったら要注意

テストを設計ツールとして使う

  • テストコードのノイズを減らす
  • 日本語テストメソッドを試してみる
  • シンプルなコードとテスト失敗時の情報のバランスを考える

参考文献

xUnit Test Patterns: Refactoring Test Code (Addison-Wesley Signature Series (Fowler))

xUnit Test Patterns: Refactoring Test Code (Addison-Wesley Signature Series (Fowler))

テスト駆動開発

テスト駆動開発

実践テスト駆動開発 (Object Oriented SELECTION)

実践テスト駆動開発 (Object Oriented SELECTION)

JUnit実践入門 ~体系的に学ぶユニットテストの技法 (WEB+DB PRESS plus)

JUnit実践入門 ~体系的に学ぶユニットテストの技法 (WEB+DB PRESS plus)

クリエイティブ・コモンズ — 表示 4.0 国際 — CC BY 4.0 この文章は クリエイティブ・コモンズ — 表示 4.0 国際 — CC BY 4.0 の下に公開されています。