« [ブログ紹介] やる夫で学ぶ TDD | トップページ | [お知らせ] 記事追加 「VB2010 Express + NUnit 2.5 で、 初めてのTDD Step by Step」 »

2011年2月 5日 (土)

[コラム] システム時刻に依存するメソッドをテストする 3 + 1 通りの方法

ユニットテストの天敵は、 外部に依存しているプログラムです。
依存しているものからの応答によってプログラムの動作が変わるとすると、 ユニットテストが困難になります。 たとえば、 データベース、 あるいは何らかの通信など。 もっとも身近なのは、 システム時刻でしょう。

次のようなメソッドを作るとします。

  • 現在時刻が午前だったら、 "Good morning!" と返す。
  • 午後だったら、 "Hello!" と返す。

このメソッドのテストケースは、 どう書きましょうか? 製品コードは、 どのようにしたらいいでしょう?
なにも工夫をしなければ、 1日に 1回しかテストが出来ないか、 PC の時計を変えてはテストを実行することになるでしょう。 何か工夫をして、 テスト実行時には PC の時計に依存しないようにする必要があります。 .NET Framework に用意されている DateTime.Now を製品コードで直接読み出すのはやめて、 テストコードから設定した時刻が読み出せるようにします。

その方法には幾通りもありますが、 私がよく使っている 3通りの方法を紹介します。

  1. メソッド内でシステム時刻を使わない
  2. #if ディレクティブを使って切り替え
  3. フェイクオブジェクト

※ C# 2010 Express Edition と NUnit 2.5.9 用のサンプルコード
  ⇒ TestDepnedingSystemClockMethod.zip (32,795 バイト)


◆ 1. メソッド内でシステム時刻を使わない

考え方は単純です。 メソッドを呼び出す側から現在時刻を渡してあげるようにします。 メソッドの挙動は引数だけに依存して、 外部には依存しません。

【テストケース】

[TestFixture()]
public class GreetingTest {

    [Test()]
    public void SayTest1_午前中_GoodMorning() {
        DateTime now = new DateTime(2011, 2, 5, 9, 0, 0);
        Assert.That(
            (new Greeting()).Say(now),
            Is.EqualTo("Good morning!")
        );
    }

    [Test()]
    public void SayTest2_午後_Hello() {
        DateTime now = new DateTime(2011, 2, 5, 15, 0, 0);
        Assert.That(
            (new Greeting()).Say(now),
            Is.EqualTo("Hello!")
        );
    }
}

【製品コード】

public class Greeting {

    public string Say(DateTime now) {
        if (now.Hour < 12)    //DateTime.Now を直接使わない。呼び出し側から与えてもらう。
            return "Good morning!";
        else
            return "Hello!";
    }

}

※ 実際の製品コードでは、ロジックの最初で DateTime.Now を now へ格納し、それを引き回す格好になります。


◆ 2. #if ディレクティブを使って切り替え

DateTime.Now を直接使わないよう、 SystemClock クラスを作り、 システム時刻が欲しいときは SystemClock の Now プロパティを読みだすようにします。 SystemClock クラスの中では DateTime.Now を読み出します。 共通関数を作って、 DateTime.Now の読み出しをそこだけに限定するわけです。 あとは、 SystemClock.Now が返す時刻を自由に設定できるようにすればよいのですが、 製品コードにはその機能は不要ですから、 #if ディレクティブを使ってユニットテストするときだけそのコードを利用できるようにします。

【テストケース】

[TestFixture()]
public class GreetingTest {

    [Test()]
    public void SayTest1_午前中_GoodMorning() {
        SystemClock.Test_SetTime( new DateTime(2011,2,5,9,0,0));
        Assert.That(
            (new Greeting()).Say(),
            Is.EqualTo("Good morning!")
        );
    }

    [Test()]
    public void SayTest2_午後_Hello() {
        SystemClock.Test_SetTime( new DateTime(2011, 2, 5, 15, 0, 0));
        Assert.That(
            (new Greeting()).Say(),
            Is.EqualTo("Hello!")
        );
    }

}


【製品コード】

public class SystemClock {

    public static DateTime Now {
        get {
#if UnitTest
            if (test_nowTime.HasValue)
                return test_nowTime.Value;
#endif

            return DateTime.Now;
        }
    }


#if UnitTest
    private static DateTime? test_nowTime;

    public static void Test_SetTime(DateTime testTime) {
        test_nowTime = testTime;
    }
#endif

}

public class Greeting {

    public string Say() {
        if (SystemClock.Now.Hour < 12)    //DateTime.Nowを直接使わない。 SystemClock で一度ラップして使う。
            return "Good morning!";
        else
            return "Hello!";
    }
}

※ 条件付きコンパイルシンボルに UnitTest を定義したときだけ、テストが出来る。
※ この方法は、 受け入れテストの自動化にも対応できる。


◆ 3. フェイクオブジェクト

前述の #if ディレクティブを使った方法は、 お手軽ではありますが、 データベースアクセスなど複雑なものになってくると、 #if ~ #endif の中身も膨らんでしまい、 実用に耐えなくなってきます。
そこで、 ちょっと手間を掛けて SystemClock のインターフェイスを定義し、 ユニットテスト時にはそのインターフェイスを実装したニセモノ (フェイクオブジェクト) を使うようにします。

【テストケース】

/// <summary>
/// これは、 わんくま名古屋勉強会#13 (2010.06.12) にて
/// bleis 氏と a_hisame 氏のペアにより作成されたコードです
/// (一部改変)
/// ※ 製品コードも同じ。
/// </summary>
class SystemClockFake : ISystemClock {
    public DateTime Now { get; set; }
}

[TestFixture()]
public class GreetingTest {

    [Test()]
    public void SayTest1_午前中_GoodMorning() {
        var greeting = new Greeting(new SystemClockFake() {
            Now = new DateTime(2010, 6, 12, 5, 0, 0)
        });
        Assert.That(
            greeting.Say(),
            Is.EqualTo("Good morning!")
        );
    }

    [Test()]
    public void SayTest2_午後_Hello() {
        var greeting = new Greeting(new SystemClockFake() {
            Now = new DateTime(2010, 6, 12, 13, 0, 0)
        });
        Assert.That(
            greeting.Say(),
            Is.EqualTo("Hello!")
        );
    }
}



【製品コード】

public interface ISystemClock {
    DateTime Now { get; }
}

public class SystemClock : ISystemClock {

    static internal ISystemClock theInstance
        = new SystemClock();

    static public ISystemClock Instance {
        get { return theInstance; }
    }

    public DateTime Now {
        get { return DateTime.Now; }
    }
}

public class Greeting {
    private readonly ISystemClock clock;

    public Greeting() {
        clock = SystemClock.Instance;
    }

    public Greeting(ISystemClock clock) {
        this.clock = clock;
    }

    public string Say() {
        if (this.clock.Now.Hour < 12) {
            return "Good morning!";
        }
        return "Hello!";
    }
}

いかがでしょうか。 ようするに DataTime.Now の呼び出しを、 テストコード側から制御できればよいわけですから、 この他にも違った方法があると思います。

なお、 フェイクオブジェクトは、 モックを使って書くことも出来ます。 SystemClockFake をモックに置き換えるだけですので、 製品コードは変わりません。 NUnit 2.x 付属の DynamicMock を使う例を、 最後に紹介しておきます。


【テストケース】

[TestFixture()]
public class GreetingTest {

    private DynamicMock mock;

    [SetUp()]
    public void Setup() {
        this.mock = new DynamicMock(typeof(ISystemClock));
    }

    [Test()]
    public void SayTest1_午前中_GoodMorning() {
        mock.SetReturnValue("get_Now", new DateTime(2010, 6, 12, 5, 0, 0));

        var greeting = new Greeting(mock.MockInstance as ISystemClock);
        Assert.That(
            greeting.Say(),
            Is.EqualTo("Good morning!")
        );
    }

    [Test()]
    public void SayTest2_午後_Hello() {
        mock.SetReturnValue("get_Now", new DateTime(2010, 6, 12, 13, 0, 0));

        var greeting = new Greeting(mock.MockInstance as ISystemClock);
        Assert.That(
            greeting.Say(),
            Is.EqualTo("Hello!")
        );
    }
}

|

« [ブログ紹介] やる夫で学ぶ TDD | トップページ | [お知らせ] 記事追加 「VB2010 Express + NUnit 2.5 で、 初めてのTDD Step by Step」 »

*コラム」カテゴリの記事

<NUnit>」カテゴリの記事

コメント

コメントを書く



(ウェブ上には掲載しません)


コメントは記事投稿者が公開するまで表示されません。



トラックバック


この記事へのトラックバック一覧です: [コラム] システム時刻に依存するメソッドをテストする 3 + 1 通りの方法:

« [ブログ紹介] やる夫で学ぶ TDD | トップページ | [お知らせ] 記事追加 「VB2010 Express + NUnit 2.5 で、 初めてのTDD Step by Step」 »