阻止不稳定测试:在 Laravel 测试中冻结时间
Source: Dev.to

问题
我在本机上运行的这个测试本来很顺利,但在 CI 上会随机失败:
public function test_order_item_cancel(): void
{
$user = UserFixture::createUser();
$this->actingAsFrontendUser($user);
$order = OrderFixture::create($user);
$orderItem = OrderItemFactory::new()->for($order)->for($user)->create();
$response = $this->put(route('api-v2:order.order-items.cancel', ['uuid' => $orderItem->uuid]));
$response->assertNoContent();
$this->assertDatabaseHas(OrderItem::class, [
'uuid' => $orderItem->uuid,
'canceled_at' => Date::now(),
]);
}
有时测试会出现以下错误:
Failed asserting that a row in the table [order_items] matches the attributes {
"canceled_at": "2026-01-09T10:24:52.008406Z"
}.
Found: [
{
"canceled_at": "2026-01-09 12:24:51"
}
].
起初我只是重新运行,但在阅读了 The Flaky Test Chronicles VI 后,我意识到需要调查这到底是实际的 bug 还是不稳定的测试。
为什么会这样
Date::now() 被调用了两次:
- 控制器设置
canceled_at时。 - 测试检查该值时。
即使相差毫秒,时间戳也会不相等。CI 环境通常更慢,所以这种差异在 CI 上更常出现。
解决方案
在发起请求之前冻结时间,这样控制器和测试使用相同的时间戳。
// Option 1
$this->freezeTime();
// Option 2
$now = Date::now();
Date::setTestNow($now);
$response = $this->put(route('api-v2:order.order-items.cancel', ['uuid' => $orderItem->uuid]));
$this->assertDatabaseHas(OrderItem::class, [
'uuid' => $orderItem->uuid,
'canceled_at' => $now,
]);
$this->freezeTime() 是 Date::setTestNow() 的便利包装,作用域限定在测试生命周期内。时间被冻结后,时间戳相匹配,测试变得确定性。
另一种方式
如果你只需要确保该字段不为空,可以断言 canceled_at 不是 null:
$this->assertDatabaseMissing(OrderItem::class, [
'uuid' => $orderItem->uuid,
'canceled_at' => null,
]);
最后思考
当测试依赖时间时,要对时间进行控制。如果测试在本地通过但在 CI 失败,使用 Date::setTestNow() 或 $this->freezeTime() 冻结时间。让你的测试具备确定性,才能保持可靠和可信。