現在時刻が関わるユニットテストを考える(Laravel)

きっかけ

単体テストの考え方/使い方」の第11章「単体テストのアンチ・パターン」で取り上げられていて参考になったので、Laravelで自分なりに実装してみました。

TL;DR

・現在時刻は引数で渡す(私の判断)
・現在時刻を取得するためのDateTimeServiceクラスを作成する。
・DateTimeServiceクラスはインターフェースに依存させて、Production/Test環境でDIするServiceクラスを分ける。

フォルダ構成

フォルダ構成はLaravelプロジェクト作成時の構成とほとんど変わりません。
※記事に必要なフォルダのみ記載しています。
違う箇所は
・DDDを採用しているため、packages/以下にドメイン・インフラ・サービスをまとめている
のみだと思います。

フォルダ構成

.
├── app
│   ├── Http
│   │   ├── Controllers
│   ├── Providers
│   │   ├── AppServiceProvider.php
│   │   └── ServiceProviders
│   │       ├── DevelopServiceProvider.php
│   │       ├── ProductionServiceProvider.php
│   │       ├── Provider.php
│   │       └── TestServiceProvider.php
├── packages
│   ├── Basic
│   │   ├── DateTime
│   │   │   └── DateTimeInterface.php
│   └── Main
│       ├── Domain
│       │   └── Models
│       ├── Infrastructure
│       │   ├── Eloquent
│       │   │   ├── Factory
│       │   │   └── Repository
│       │   └── Test
│       │       └── Repository
│       └── Service
│           ├── DateTimeService.php
│           ├── Test
│           │   └── DateTimeService.php
├── tests

現在時刻は引数で渡す

修正前はEntityのメソッド内で現在時刻を取得していたため、テストを実行する日時で結果が変わってしまっていたのですが
引数を渡すことで同じ引数に対して、毎回同じ結果を返すことができるようになりました。(Repetable)
こちらの記事では組み込みクラスの動作を変更するような方法もありました。

現在時刻を取得するためのDateTimeServiceクラスを作成する。

サービス層でProduction用とTest用に分けてDateTimeServiceクラスを作成します。
※私の場合はテストごとに取得したい日時が違うので、ユースケースごとにメソッドを作成しました。

Production

<?php
namespace Main\Service;

use Basic\DateTime\DateTimeInterface;

class DateTimeService implements DateTimeInterface {

    public function usecase1(): \DateTimeImmutable {
        return new \DateTimeImmutable();
    }

    public function usecase2(): \DateTimeImmutable {
        return new \DateTimeImmutable();
    }

}

Test

<?php
namespace Main\Service\Test;

use Basic\DateTime\DateTimeInterface;

class DateTimeService implements DateTimeInterface {
    public function usecase1(): \DateTimeImmutable {
        return \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2000-01-01 10:00:00');
    }

    public function usecase2(): \DateTimeImmutable {
        return \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2000-01-10 10:00:00');
    }
}

DateTimeServiceクラスはインターフェースに依存させて、Production/Test環境でDIするServiceクラスを分ける。

システムの環境に応じてDIの設定を分岐させます。

AppServiceProvider

<?php

namespace App\Providers;

use App\Providers\ServiceProviders\DevelopServiceProvider;
use App\Providers\ServiceProviders\ProductionServiceProvider;
use App\Providers\ServiceProviders\TestServiceProvider;
use Illuminate\Support\ServiceProvider;
use Illuminate\Pagination\Paginator;

class AppServiceProvider extends ServiceProvider
{
    public function register()
    {
        $provider = $this->provider();
        $provider->register();
    }

    public function boot()
    {
        $provider = $this->provider();
        $provider->boot();
        Paginator::defaultView('vendor.pagination.bootstrap-4');
    }

    private function provider()
    {
        $env = config("app.env");
        switch ($env) {
            case "local":
                return new DevelopServiceProvider($this->app);
            case "production":
                return new ProductionServiceProvider($this->app);
            case "testing":
                return new TestServiceProvider($this->app);
            default:
                throw new \OutOfBoundsException();
        }
    }
}

ProductionServiceProvider

<?php
namespace App\Providers\ServiceProviders;

use Illuminate\Contracts\Foundation\Application;
use Basic\DateTime\DateTimeInterface;
use Main\Service\DateTimeService;

class ProductionServiceProvider implements Provider {

    private $app;

    public function __construct(Application $app) {
        $this->app = $app;
    }

    public function register() {
        $this->app->bind(DateTimeInterface::class, DateTimeService::class);        
    }

    public function boot() {

    }
}

まとめ

これで
Entityのメソッドの引数にサービスクラスから取得したDateTimeImmutableを渡すことで
・Production環境では現在日時
・Test環境では定数の日時
を取得するようにできたため、ユニットテストの再現性を実現できたかなと思います。