Laravelで日時を含むモジュールをユニットテストする(Carbon in the PHPUnit)


やりたいこと

  public function doSomethingIfToday( $date_string )
  {
    if( Date( 'Y-m-d', strtotime( $date_string ) ) == Date( 'Y-m-d' ) ){
      // Do something...
    }
  }

たとえばこんな、日時を条件にしているメソッドをユニットテストしようとした場合どうするか?
せめて今日とそれ以外の日でどんな結果が返ってくるかチェックしないと……
あぁ、でもテストを実行した日によって結果が変わっちゃうので……
システムの時計を変えて……。

なんてことは、しないでくださいね。

結論

Carbonを使います

CarbonはPHPで日時周りの操作をうまいことやってくれるライブラリで、Laravelにも標準で組み込まれているものです(なので今回はLaravelに限った話ではなく、Carbonをインストールできる環境であればどんなプロジェクトやフレームワークにでも適用できます)。このCarbonのすごいところは、きちんとそうした「テスト利用」を考えられているところで、setTestNowメソッドを使うと、Carbonのインスタンスが返す「今日」を変更することができます。

上記のコードはこうなります。

  public function doSomethingIfToday( $date_string )
  {
    $now      = \Carbon\Carbon::now();
    $thistime = \Carbon\Carbon::parse( $date_string ); 

    if( $now->diffInDays( $thistime, false ) == 0 ){ // 第2引数をfalseにすると差がマイナスで返る 省略すると絶対値
      // Do something...
    }
  }

そしてテストコードはこう。

  public function doSomethingIfTodayTest(  )
  {
    // Carbonで得られる「今日」を変更する
    Carbon::setTestNow(new Carbon('2017-01-02 09:59:59'));

    $result = $tested_class->doSomethingIfToday( '2017-01-02' );
    $this->assertTrue( $result, '今日なのでTrue' );
    $result = $tested_class->doSomethingIfToday( '2017-01-03' );
    $this->assertFalse( $result, '今日じゃないのでfalse' );
  }

このように、日付に「依存」するコードを書くときにはCarbonを使用しておけば、あとで外から「今日」を与えられる(inject)ので、うまいこと Dependency Injection を解決してテストをスムーズに書くことができます。

Carbonは使いたくない

すみません、そうですよね、そういう案件もありますよね。

Carbon使えないプロジェクトで日時を含むモジュールを作ったときには、「今日」を外から与えるようにしていました。

  // グローバル値でも良いと思うけどわかりやすさのためコンストラクタ
  public function __construct( $today_int )
  {
    $this->today = $today_int ;
  }

  public function doSomethingIfToday( $date_string )
  {
    if( Date( 'Y-m-d', strtotime( $date_string ) ) == Date( 'Y-m-d', $this->today ) ){
      // Do something...
    }
  }

ちなみに「今日」に限らず「時刻」を取得する手段は「アプリ専用の個別のモジュールにしてそれをコールするようにする」というのは常套手段で、Ray.IdentityValueModule にエレガントに実装されていたのでご紹介。
Laravelに強く依存しない(Carbonに依存しない)プロジェクトを目指すならこのような実装をするといいかもしれません。

NowInterface.php
namespace Ray\IdentityValueModule;

interface NowInterface
{
    public function __toString() : string;
    public function iso8601() : string;
}

ただし、toString でどんなフォーマットで返ってくるかがInterface見ててもわからない(制限できない)のが要注意ですね。
標準だとMySQLフォーマット Y-m-d H:i:s だけどサーバーによってタイムゾーンが違って日付がズレた(経験則)というハマリポイントに要注意です。

感想

Carbonすげぇ。もうこれを使わない理由がない。と思ったんですが、Qiitaに記事がなかったので僭越ながら穴埋め係させていただきました。

「時刻を取得」という機能は言語標準で一撃必殺な関数(date)がありますが、
だからこそ逆にそれを禁止してアプリ固有の手法を開発者全員に守らせるのがもっとも重要
ということですねー(遠い目)。