単体テストでは、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
をテスト実行時に差し替えられるようにすれば、テストは格段にしやすくなる。
発想としてはこうだ。CouponIssuer
でnew DateTime
する代わりに、CouponIssuer
は現在時刻を提供するサービスを利用し、現在時刻提供サービスからnew DateTime
相当の結果を受け取るようにする。この現在時刻提供サービスはSystemClock
と呼ぶ。
このような設計にしておけば、テストのときにSystemClock
をモックに差し替えることができるので、現在時刻を固定することができるわけだ。
それでは、テストがしやすい形にリファクタリングしてみよう。
CouponIssuer
はSystemClock
を利用するので、コンストラクタでそれを受け取れるようにする。そのうえで、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;
}
次にテストコードを対応させる。CouponIssuer
はSystemClock
をコンストラクタで受け取るようになったので、テストでは、そのモックオブジェクトを渡すようにする必要がある。
先のテストコードでは、クーポンの有効期限の期待値を、現在時刻から相対的に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
の機能を持たせるので十分だ。
CouponIssuerTest
はSystemClock
をimplements
するようにし、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
パターンは、こうした問題を解決し、テストのしやすさを向上させるパターンだ。現在時刻を返すインターフェイスを作ることで、テスト時は時刻を任意に固定することができる。