Datetime and Testing
Одна из основных головных болей в тестах это дата, а особенно “текущая” /
now()
в тестируемом коде.
Нельзя создать две даты со временем в разных местах, и чтобы они были одинаковыми. Из-за этого нельзя протестировать генерируемую дату в тестируемом коде:
public function testCreateObject(): void
{
$obj = new ObjectWithDateTime();
assertEquals(new DateTime(), $obj->createdAt);
}
Тест всегда будет падать, потому что, как минимум, будут отличаться микросекунды. При этом использование
now()
в самих тестах иногда приводят к их дестабилизации.
Первое, с чем нужно разобраться, это понять, как тестировать время, есть 3 варианта:
1️⃣ использовать
date_create
и
date_create_immutable
для создание текущий даты, это позволит замокать даты без помощи посторонних пакетов
<?php
namespace {
global $now;
$now = new DateTimeImmutable('2024-05-01 00:00:00');
$obj = new \Module\ObjectModule();
if ($obj->createdAt->getTimestamp() === $now->getTimestamp()) {
echo 'Даты равны' . PHP_EOL;
} else {
echo 'Даты разные' . PHP_EOL;
}
}
namespace Module {
final readonly class ObjectModule {
public \DateTimeImmutable $createdAt;
public function __construct() {
$this->createdAt = date_create_immutable();
}
}
}
namespace Module {
function date_create_immutable(string $datetime = 'now', ?DateTimeZone $timezone = null): \DateTimeImmutable|false {
global $now;
return $now;
}
}
2️⃣использовать пакет, которые позволяет заморозить время , например
slope-it/clock-mock
ClockMock::freeze(new \DateTime('1986-06-05'));
// Code executed in here, until ::reset is called, will use the above date and time as "current"
$nowYmd = date('Y-m-d');
ClockMock::reset();
$this->assertEquals('1986-06-05', $nowYmd);
3️⃣ Использовать пакеты/компоненты, которые уже предусматривают мок времени для тестирования
Один из популярных пакетов
briannesbitt/Carbon
// Don't really want this to happen so mock now
Carbon::setTestNow(Carbon::createFromDate(2000, 1, 1));
// Phew! Return to normal behaviour
Carbon::setTestNow();
И в прошлом году Symfony обзавелись собственным пакетом
symfony/clock
, но они предлагают использовать интерфейс
ClockInterface
как зависимоcть, что может быть непривычно на первый взгляд:
class MyClockSensitiveClass
{
public function __construct(
private ClockInterface $clock,
) {
// Only if you need to force a timezone:
//$this->clock = $clock->withTimeZone('UTC');
}
public function doSomething()
{
$now = $this->clock->now();
// [...] do something with $now, which is a \DateTimeImmutable object
$this->clock->sleep(2.5); // Pause execution for 2.5 seconds
}
}
$clock = new MockClock('2022-11-16 15:20:00');
$service = new MyClockSensitiveClass($clock);
$service->doSomething();
Чтобы не использовать внешние пакеты, можно пойти путем Symfony, и определить свой интерфейс
ClockInterface
Второе о чем стоит думать, это то, что нельзя делегировать создание времени в другие системы, чаще всего такое утекает в SQL конструкции по типу
UPDATE order SET status = 'delivered', updated_at = NOW() WHERE id = 1234;
SELECT * FROM order WHERE creaed_at > DATE_SUB(CURDATE(), INTERVAL -5 DAY);
Вместо таких SQL конструкций лучше генерировать дату с помощью приложения, и передавать в качестве параметра. Как минимум ваша логика не будет утекать в БД
“Другими системами” могут ещё выступать классы или модули в вашей системе. Например, иногда в реализации репозитория можно встретить создание даты, но возможно, стоит рассмотреть дату как входящий параметр.