iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Software Development

同一套 Laravel 系統,測試怎麼寫才不會說謊系列 第 24 篇

Day 24:時間相依的 Job 怎麼測——用 Carbon::setTestNow() 鎖定測試當下時間

  • 分享至 

  • xImage
  •  

前言

「這支測試昨天明明是綠燈的,今天怎麼突然紅了?」

如果你的測試依賴「現在幾點」來決定行為——例如抓「過去兩天」的資料、判斷某筆紀錄是不是「已經超過一週沒更新」——卻沒有特別處理,你的測試結果就會偷偷跟著系統時鐘一起漂移。今天要拆一個真實案例:一支負責抓外部資料的 Job,怎麼把「現在幾點」這個變數鎖死,讓測試結果不再看日曆臉色。

今日目標

  • 理解「測試會不會通過」跟「今天是幾號」綁在一起,是一種容易被忽略的隱形風險
  • 學會用 Carbon::setTestNow() 把測試裡的「現在」鎖定成固定時間點
  • 看一個完整的 Job 測試怎麼組合測試替身(Fake HTTP Client)、資料庫種子(Seeder)跟時間鎖定
  • 理解為什麼「驗證資料庫裡的最終狀態」比「只驗證 Job 有沒有跑完」更可信

一個依賴「現在」的 Job 長什麼樣

這個系統有一支負責定期抓外部新聞資料的 Job,它的核心邏輯是「抓某個時間區間內的新聞」——呼叫端傳入 start、end 兩個時間參數,Job 內部再用這兩個時間去跟外部服務要資料。乍看之下,這支 Job 的行為只跟傳進去的參數有關,不該跟「現在幾點」扯上關係。

但實際上,很多依賴時間區間的邏輯,內部還是會用「現在」做一些隱性判斷——例如某筆資料的「有效期限」、某個排程任務「下一次該抓的區間」,這些計算如果沒有鎖定測試當下的時間,測試結果就會隨著執行的日期悄悄改變。

❌ 沒有鎖定時間,測試結果看日曆臉色

it('handle', function () {
    $client = new FakeHttpClient(fixture_path('vcr/jobs/fetch_news.yaml'));
    $crawler = new Crawler(client: $client, baseUrl: 'http://internal.example.com/proxy');

    swap(Crawler::class, $crawler);

    $start = Carbon::parse('2023-08-14');
    $end = Carbon::parse('2023-08-16');
    FetchNewsJob::dispatchSync(start: $start, end: $end);

    assertDatabaseCount('news', 13);
});

如果 Job 內部有任何一段邏輯間接依賴 now()(例如判斷「這筆資料是不是還在有效期內」、記錄「這次抓取的時間戳記」),這支測試在不同天執行,可能會得到不同的結果——今天綠燈,過幾天卻莫名其妙紅燈,而你完全不知道為什麼,因為程式碼跟測試資料看起來都沒變。

✅ 鎖定測試當下時間,結果永遠可預測

beforeEach(function () {
    Carbon::setTestNow(Carbon::parse('2023-08-22'));
});

it('handle', function () {
    $client = new FakeHttpClient(fixture_path('vcr/jobs/fetch_news.yaml'));
    $crawler = new Crawler(client: $client, baseUrl: 'http://internal.example.com/proxy');

    swap(Crawler::class, $crawler);

    seed(NewsCategorySeeder::class);
    seed(NewsAssignerSeeder::class);

    $start = Carbon::parse('2023-08-14');
    $end = Carbon::parse('2023-08-16');
    FetchNewsJob::dispatchSync(start: $start, end: $end);

    assertDatabaseCount('news', 13);
    assertDatabaseHas('news', [
        'id' => 1,
        'news_category_id' => 8,
        'published' => false,
    ]);
});

在 beforeEach 裡呼叫 Carbon::setTestNow(),把整個測試執行期間 Laravel 的「現在」固定成一個明確的日期。這樣一來,不管 Job 內部有沒有隱性依賴 now(),測試結果都不會因為「今天是幾號」而改變——這支測試明年、後年重跑,結果都會跟今天一模一樣,因為它已經跟真實世界的時鐘脫鉤了。

這支測試還做對了什麼

除了時間鎖定,這支測試還有兩個值得注意的細節:

第一,它用 FakeHttpClient 搭配一份 YAML 格式的 cassette 檔案,把 Crawler 依賴的 HTTP client 換成假的(透過 swap() 綁定容器),讓整支測試完全不會真的打外部服務——這正是前面 Day 16/17 拆過的測試替身機制在這裡的實際應用。

第二,也是最容易被忽略的一點:它不是只驗證「Job 執行完沒有丟例外」,而是用 assertDatabaseCount() 跟 assertDatabaseHas() 直接檢查資料庫裡的最終狀態——不只驗證「有 13 筆新聞被寫入」,還驗證了其中一筆的欄位內容是不是真的符合預期(分類 ID、發布狀態)。Job 跑完沒有報錯,不代表它把資料寫對了;只有真的去檢查資料庫裡的最終狀態,才知道這個 Job 真正做了什麼。

今日思考題

回想你維護的專案裡,有沒有一支測試依賴 now()、today() 或任何跟「現在」有關的判斷,卻沒有用類似 Carbon::setTestNow() 的機制鎖定?如果有,下次你的測試在奇怪的時間點突然變紅,這可能就是原因。

今日重點回顧

  • 依賴「現在幾點」的邏輯,如果測試沒有鎖定時間,結果會隨著執行日期悄悄改變
  • Carbon::setTestNow() 能把測試期間的「現在」固定成明確時間點,讓測試結果永遠可預測
  • 一支完整的 Job 測試,通常會組合測試替身(隔絕外部依賴)+時間鎖定(隔絕時間依賴)+資料庫狀態驗證(確認真正的結果)
  • 只驗證「Job 有沒有跑完」不夠,要驗證資料庫裡的最終狀態是不是真的符合預期

明日預告

明天要看這個系統裡有 6 支結構高度相似的 SOAP 同步 Job,它們共用同一套測試模式——把這套模式拆開來看,理解「一套模式重複套用」跟「隔離每支測試互不干擾」是兩件不同的事。


上一篇
Day 23:PathGenerator 本身怎麼測——只驗證「這個 Model、這個 ID,該產生什麼路徑」
系列文
同一套 Laravel 系統,測試怎麼寫才不會說謊 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言