⏰「現在時刻」のテストを容易にするSystemClockパターン


単体テストでは、new DateTime('now')は厄介者だ。テスト対象が現在時刻を基準にした仕様を持っていたとして、それを単体テストでチェックしたいとする。しかし、現在時刻はテストを実行するたびに変わってしまう。ときには、テスト実行タイミングが1秒ずれただけで、テストがFailになることすらある。現在時刻を扱う単体テストは壊れやすく厄介なものになる。

本稿では、現在時刻を固定するSystemClockパターンを使い、現在時刻に関わるテストを壊れにくく、テストがしやくなる手法を紹介する。

なお、本稿で扱ったソースコードはGitHubで公開している。

現在時刻を扱う上での問題点

ここにクーポンというオブジェクトがある。このオブジェクトは、有効期限を持っている。

final class Coupon
{
    /**
     * @var DateTime
     */
    private $expirationDate;

    /**
     * @param DateTime $expirationDate クーポンの有効期限
     */
    public function __construct(DateTime $expirationDate)
    {
        $this->expirationDate = $expirationDate;
    }

    public function getExpirationDate(): DateTime
    {
        return $this->expirationDate;
    }
}

更に、クーポンを発行するサービスがある。このサービスは、現在時刻から1週間有効なクーポンを発行する責務を負っている。それが、次のCouponIssuerクラスだ。

/**
 * クーポン発行サービス
 */
final class CouponIssuer
{
    /**
     * 新しいクーポンを発行する
     */
    public function issueNewCoupon(): Coupon
    {
        return new Coupon(new \DateTime('+1 week')); // 有効期限が1週間のクーポン
    }
}

このCouponIssuerを単体テストにかけようとするなら、おおかた次のようなコードになるだろう。

use PHPUnit\Framework\TestCase;

final class CouponIssuerTest extends TestCase
{
    public function testIssueNewCoupon(): void
    {
        $issuer = new CouponIssuer();
        $coupon = $issuer->issueNewCoupon();
        self::assertEquals(new \DateTime('+1 week'), $coupon->getExpirationDate());
    }
}

このテストコードは一見するとPASSするように見える。しかし、実際は、CouponIssuerクラス内で作られるDateTimeと、テストクラスで作られるそれとでは、マイクロ秒の差が生じてPASSになることはない。

テスト結果
Failed asserting that two DateTime objects are equal.
Expected :2018-12-27T01:48:35.565456+0000
Actual   :2018-12-27T01:48:35.565409+0000

では、どうしたらいいか?

アイディアとして、マイクロ秒を無視するテストを書くことが考えられる。実際、クーポンの有効期限にマイクロ秒の精度は必要ないだろう。そうした改修を済ませれば、実際にテストは多くの場合PASSになる。多くの場合というのは、タイミングによっては運悪く、1秒差が生まれてテストがFAILになる可能性がゼロではないということだ。そして、万が一、それでFAILになったときは再現性が低いため、「よくわからないがFAILになった。その後何度か実行したが、PASSが続いたので、おそらく問題ないと思う」というように気持ちの悪い経験をすることになる。

SystemClockパターン

この問題の原因と言えるのが、CouponIssuerクラスのissueNewCouponメソッドにnew DateTimeがハードコーディングされている点である。

final class CouponIssuer
{
    public function issueNewCoupon(): Coupon
    {
        return new Coupon(new \DateTime('+1 week')); // 原因箇所
    }
}

逆に言えば、issueNewCouponメソッド内のnew DateTimeをテスト実行時に差し替えられるようにすれば、テストは格段にしやすくなる。

発想としてはこうだ。CouponIssuernew DateTimeする代わりに、CouponIssuerは現在時刻を提供するサービスを利用し、現在時刻提供サービスからnew DateTime相当の結果を受け取るようにする。この現在時刻提供サービスはSystemClockと呼ぶ。

このような設計にしておけば、テストのときにSystemClockをモックに差し替えることができるので、現在時刻を固定することができるわけだ。

それでは、テストがしやすい形にリファクタリングしてみよう。

CouponIssuerSystemClockを利用するので、コンストラクタでそれを受け取れるようにする。そのうえで、issueNewCouponメソッドにハードコーディングされていた、new DateTimeのロジックはSystemClockに移譲するように変える。

/**
 * クーポン発行サービス
 */
final class CouponIssuer
{
    /**
     * @var SystemClock
     */
    private $systemClock;

    public function __construct(SystemClock $systemClock)
    {
        $this->systemClock = $systemClock;
    }

    /**
     * 新しいクーポンを発行する
     */
    public function issueNewCoupon(): Coupon
    {
        return new Coupon($this->systemClock->now()->modify('+1 week')); // 有効期限が1週間のクーポン
    }
}

SystemClockはインターフェイスにする。そうしておけば、テストでモックを作るのが容易になる。

interface SystemClock
{
    public function now(): \DateTime;
}

次にテストコードを対応させる。CouponIssuerSystemClockをコンストラクタで受け取るようになったので、テストでは、そのモックオブジェクトを渡すようにする必要がある。

先のテストコードでは、クーポンの有効期限の期待値を、現在時刻から相対的に7日後にする必要があったため、new \DateTime('+1 week')とコーディングしていたが、SystemClockでは時刻を固定できるので、現在時刻との相対時間に頼る必要はもはや無い。したがって、好きな日付から7日後であれば、期待値は何でも良くなる。

use PHPUnit\Framework\TestCase;

final class CouponIssuerTest extends TestCase
{
    public function testIssueNewCoupon(): void
    {
        // CouponIssuerはSystemClockをコンストラクタで受け取るようになったので、テストで
        // は、そのモックオブジェクトを渡すようにする。
        $issuer = new CouponIssuer($this->getSystemClock());
        $coupon = $issuer->issueNewCoupon();
        self::assertEquals(
            new \DateTime('2018-01-08 00:00:00'), // 時刻を固定できるようになったので
                                                  // 期待する「クーポンの有効期限」は
                                                  // 好きな日時からの7日後にすることが
                                                  // できる。
            $coupon->getExpirationDate()
        );
    }

    /**
     * SystemClockのモックオブジェクトを返す
     */
    private function getSystemClock(): SystemClock
    {
        // todo: 実装する
    }
}

次に、SystemClockのモックオブジェクトを返すメソッドgetSystemClockを実装する。ここで、PHPUnitのモック機能Mockeryを使ってもいいが、そこまで大掛かりにする必要はない。シンプルにCouponIssuerTest自体にSystemClockの機能を持たせるので十分だ。

CouponIssuerTestSystemClockimplementsするようにし、getSystemClockメソッドは$thisを返すようにし、nowメソッドをCouponIssuerTestで実装してやる。

final class CouponIssuerTest extends TestCase implements SystemClock
{
    public function testIssueNewCoupon(): void { /* ... * / }

    /**
     * SystemClockのモックオブジェクトを返す
     */
    private function getSystemClock(): SystemClock
    {
        return $this;
    }

    /**
     * SystemClockインターフェイスの実装
     */
    public function now(): \DateTime
    {
        return new \DateTime('2018-01-01 00:00:00');
    }
}

完成したテストコードは次のようになる。先のテストでは現在時刻となっていたクーポンの有効期限の起点となる時刻は2018-01-01に、期待するクーポンの有効期限は2018-01-08に固定できているのが分かる。

use PHPUnit\Framework\TestCase;

final class CouponIssuerTest extends TestCase implements SystemClock
{
    public function testIssueNewCoupon(): void
    {
        $issuer = new CouponIssuer($this->getSystemClock());
        $coupon = $issuer->issueNewCoupon();
        self::assertEquals(
            new \DateTime('2018-01-08 00:00:00'),
            $coupon->getExpirationDate()
        );
    }

    /**
     * SystemClockのモックオブジェクトを返す
     */
    private function getSystemClock(): SystemClock
    {
        return $this;
    }

    /**
     * SystemClockインターフェイスの実装
     */
    public function now(): \DateTime
    {
        return new \DateTime('2018-01-01 00:00:00');
    }
}

テストのリファクタリングはこれでおしまいだ。最後に、本番稼働時に使うSystemClockを用意すれば、すべての工程は完了になる。次のDefaultSystemClockは実際に現在時刻を返す実装を持つ。このクラスは、本番環境では、CouponIssuerにDependency InjectionされるようにDIコンテナを設定する。

final class DefaultSystemClock implements SystemClock
{
    public function now(): \DateTime
    {
        return new \DateTime();
    }
}

このDefaultSystemClockクラスもテストすることを考えると、現在時刻を扱う同様の問題が発生する。しかし、ここまでシンプルな実装であればわざわざテストする必要はあるだろうか? 加えて、テストするとなっても、あちこちにnew DateTime()が散りばめられ、テストが壊れる原因があちこちにある状態よりも、DefaultSystemClockに集中していたほうがいくぶんかはマシなテストになる。

結論

現在時刻を扱うテストは壊れやすく、しかも再現性が低い壊れ方をする。テスト対象のクラスが内部でnew DateTime()していると、テストもしにくくなる。

SystemClockパターンは、こうした問題を解決し、テストのしやすさを向上させるパターンだ。現在時刻を返すインターフェイスを作ることで、テスト時は時刻を任意に固定することができる。