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

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

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

なお、本稿で扱ったソースコードは[GitHub](https://github.com/suin/php-playground/tree/master/HowToMockTheDateTime)で公開している。

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

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

```php
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`クラスだ。

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

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

```php
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になることはない。

```text:テスト結果
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`がハードコーディングされている点である。

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

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

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

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

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

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

```php
/**
 * クーポン発行サービス
 */
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`はインターフェイスにする。そうしておけば、テストでモックを作るのが容易になる。

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

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

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

```php
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のモック機能](https://phpunit.de/manual/6.5/ja/test-doubles.html)や[Mockery](http://docs.mockery.io/en/latest/)を使ってもいいが、そこまで大掛かりにする必要はない。シンプルに`CouponIssuerTest`自体に`SystemClock`の機能を持たせるので十分だ。

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

```php
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に固定できているのが分かる。

```php
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コンテナを設定する。

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

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

## 結論

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

![Problem.png](https://qiita-image-store.s3.amazonaws.com/0/889/8f741d25-e699-cdfc-9023-607f31412a02.png)

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

![Solution.png](https://qiita-image-store.s3.amazonaws.com/0/889/0f54eb52-85db-fa50-deb2-d4cedb66484c.png)