🐘

PHPで現在時刻を扱うならChronosを使うとテストが楽です

2020/03/21に公開

PHPで現在時刻に依存した処理を書くとき、 new DateTime() をハードコードしてしまうと、テストを実行するタイミングによって処理の内容が変わってしまうため、その処理は実質テスト不可能になってしまいます。

こんなときに、簡単に現在時刻をモックできるようにしてくれる Chronos というライブラリが便利なので、簡単にご紹介します。

Chronosとは

Chronos は、PHPのDateTimeを拡張した機能を提供してくれるライブラリです。

色々と便利なメソッドが提供されていたりもするようですが、何と言っても一番の目玉機能は、テストの際に現在時刻を自由に固定できる というものです。

具体的には以下のような感じです。

Chronos::setTestNow(new Chronos('1975-12-25 00:00:00'));

$time = new Chronos(); // 1975-12-25 00:00:00
$time = new Chronos('1 hour ago'); // 1975-12-24 23:00:00

実例

実際のコードを見たほうが分かりやすいと思うので、簡単なサンプルコードを書いてみました。

https://github.com/ttskch/chronos-sample

「明日の日付を表示する」という簡単なアプリです。

このアプリのコードを見ながら、Chronosがどう便利なのかを説明していきます。

1. テスト不可能なコード

まず、何も考えずに new Datetime() して実装したコードが以下です。

class Tomorrow
{
    public function show(): void
    {
        echo (new \DateTime('+1 day'))->format('Y/m/d') . PHP_EOL;
    }
}

このコードをテストしようと思っても、以下のように「特定の日付にテストを実行しないとパスしない」ようなテストしか書けません。

public function testShow(): void
{
    ob_start();
    $this->tomorrow->show();
    $output = trim(ob_get_clean());

    // 2020/03/21 以外の日にテストを実行すると失敗する.
    $this->assertEquals('2020/03/22', $output);
}

2. 自作のサービスから現在時刻を取得するようにしたコード

処理の中に new DateTime() をハードコードするのをやめて、別のサービスから現在時刻の DateTime オブジェクトを取得するような設計にしておけば、テストのときだけそのサービスをモックすることで、「現在時刻」を固定した状態でテストできるようになります。

class Tomorrow
{
    /**
     * @var Clock
     */
    private $clock;

    public function __construct(Clock $clock)
    {
        $this->clock = $clock;
    }

    public function show(): void
    {
        $now = $this->clock->getDateTime();
        $tomorrow = $now->modify('+1 day');

        echo $tomorrow->format('Y/m/d') . PHP_EOL;
    }
}
class Clock
{
    public function getDateTime(): \DateTime
    {
        return new \DateTime();
    }
}

例えばこんな実装にしておくと(かなり雑ですが)、以下のように現在時刻を固定してテストすることが可能になります。

protected function setUp() : void
{
    $clock = $this->prophesize(Clock::class);
    $clock->getDateTime()->willReturn(new \DateTime('2020-03-21'));

    $this->tomorrow = new Tomorrow($clock->reveal());
}

public function testShow(): void
{
    ob_start();
    $this->tomorrow->show();
    $output = trim(ob_get_clean());

    // 「現在」を「2020-03-21」でモックしているので、いつテストを実行してもパスする.
    $this->assertEquals('2020/03/22', $output);
}

3. Chronosを使って簡単に現在時刻を固定できるようにしたコード

上記のように DateTime オブジェクトを自作サービスから取得するようにしてテスト時のみモックで差し替えるという方法は、現在時刻に依存した処理を書くときの定石だと思います。

ですが、わざわざ現在時刻を取得するためだけにサービスを用意しなければならず、面倒ではありますね。

Chronosを使えば、以下のように最初のテスト不可能だったコードとほぼ同じコード( new DateTime() の代わりに new Chronos() を使うだけ)で、テスト時に現在時刻を固定することができます。

class Tomorrow
{
    public function show(): void
    {
        echo (new Chronos('+1 day'))->format('Y/m/d') . PHP_EOL;
    }
}
public function testShow(): void
{
    Chronos::setTestNow('2020-03-21');

    ob_start();
    $this->tomorrow->show();
    $output = trim(ob_get_clean());

    // 「現在」を「2020-03-21」でモックしているので、いつテストを実行してもパスする.
    $this->assertEquals('2020/03/22', $output);
}

めっちゃ便利!

ちなみに、ドキュメントにも書いてありますがChronos 以外のクラスも用意されていて、それぞれでミュータブル・イミュータブルの性質が違っているので、気をつけて使い分ける必要があります。

Chronos クラスのオブジェクトで ->modify('+1 day') とかを実行しても、イミュータブルなのでオブジェクト自身は変更されず 、変更後のオブジェクトが戻り値として返ってくる形になります。

この辺、注意していないと思わぬバグを生み出しかねないので、気をつけましょう。

まとめ

  • PHPで現在時刻に依存した処理を書くときは、Clockサービスなどモック可能な中間サービスを作って現在時刻を取得するのが定石ではある
  • けど、Chronos を使えばそんなことしなくても new DateTime() の代わりに new Chronos() を使うようにするだけでテスト時に現在時刻を偽って固定することができるのでめっちゃ楽
  • ミュータブルなオブジェクトとイミュータブルなオブジェクトが提供されているので、気をつけて使い分けないとバグの元になる(イミュータブルなオブジェクトで modify() メソッドを実行しても自身の時刻は変わらないとか)
GitHubで編集を提案

Discussion