蒼時弦也
蒼時弦也
資深軟體工程師
發表於

優雅的 RSpec 測試 - 測試替身

這篇文章是 優雅的 RSpec 測試 系列的一部分。

測試替身(Test Double)是在撰寫單元測試中經常會使用到的一項技巧,我們可以透過替換某個物件的特定行為或者製作替身來達到驗證某個行為的效果,然而如果濫用測試替身的話,則很容易無法正確的驗證物件行為。

使用時機

在大部分的時候,我會偏好減少使用,這樣我們比較能更有效的驗證實作。然而,我們還是會有一些情況很難避免,像是使用第三方的服務無法控制回傳結果、需要跟硬體相互整合,測試環境沒有對應硬體,以及我們想要限制測試影響的範圍。

另一方面,我們還需要區分「流程(Flow)」和「邏輯(Logic)」的情境,如果是流程(大多為 E2E 測試)的狀況,使用測試替身就很可能無法呈現真實狀況,至於邏輯部分則可以適當利用測試替身來控制輸入來驗證預期的輸出。

適用情境

這裡我列出了一些我通常會選擇製作替身的狀況給大家參考,實際上評估的時候還是需要根據當下的情境來決定是否要使用替身。

外部依賴

外部依賴通常是一些 API 類型的呼叫,因為不一定會有測試環境以及穩定的回傳,我們就可以考慮製作測試替身來驗證特定行為。

以 Ruby 常見的輔助工具,主要就是 Webmock 或者 VCR 這類可以幫助我們模擬 HTTP 回應的套件為主要會使用測試替身的情境。

舉例來說,我們想測試一個「匯率換算」的處理是否正常,然而匯率是會不斷變動的,這個時候就可以製作替身來進行處理。

1RSpec.describe ExchangeService do
2  describe '#to_twd' do
3    subject { service.to_twd(Money.new('USD', 1)) }
4    # ...
5    before { allow(api).to receive(:exchange_rate).and_return(29.0) }
6
7    it { is_expected.to be_money('TWD').with_amount(29) }
8  end
9end

像這樣子,我們就可以模擬一個美金轉換成台幣的計算,並且有正確的使用固定的匯率來處理,這樣在進行定義測試案例的時候才能夠避免「模糊」的案例。

關於測試替身的詳細使用方式會在後續文章說明,這裡會先以示範為主。

物理限制

假設我們現在要處理的情境是跟硬體相關,我們沒辦法直接在測試伺服器上連接硬體,那麼我們也可以製作一些測試替身,來模擬硬體上相關的行為並且進一步的處理。

1RSpec.describe Beeper do
2  describe '#beep' do
3    subject { beeper.beep(1) }
4
5    before { allow(gpio).to receive(:write).with(18).and_return(true) }
6
7    it { is_expected.to be_truthy }
8  end
9end

現實中上面的例子實際上並不會運作,不過這足以展示我們如何對硬體類型的測試進行替身的處理。

然而,因為硬體是以 I/O 相關為基礎互動的,也可以利用像是 Adapter 這類設計模式,實作虛擬硬體來對應測試,在設計得當的情況下我們是有機會避免使用替身,因此這個方式比較適合在使用第三方套件無法控制實作的情境下使用。

控制範圍

還有一些情況是我們希望控制一個單元測試影響的範圍,在比較大的系統中對一個物件測試可能會產生上百個相關物件,這時候我們可以利用測試替身在依賴的某個層級用替身代替,進而控制呼叫的範圍(一般來說約兩層)

假設有一個行為,會有這樣的依賴。

  • ChargeService
    • AccountRepository
      • Account
      • DatabaseAdapter
    • BillRepository
      • Bill
      • DatabaseAdapter

在這個例子中,我們可能希望專注在 ChargeService 上,並且不要產生過多的物件,避免影響整個測試的速度,就可以製作替身來處理。

 1RSpec.describe ChargeService do
 2  subject(:service) do
 3    ChargeService.new(account_repo, instance_double(BillRepository))
 4  end
 5
 6  let(:bill) { Bill.new(id: 1, amount: Money.new('TWD', 1000)) }
 7
 8  let(:account_repo) { instance_double(AccountRepository) }
 9
10  describe '#charge_with' do
11    subject(:charge) { service.charge_with(bill, account_id: 1) }
12
13	let(:account) { Account.new(id: 1, balance: Money.new('TWD', 2000) }
14
15    before do
16      allow(account_repo).to receive(:find).with(1).and_return(account)
17    end
18
19    it { is_expected.to be_truthy }
20
21    context 'when account balance insufficient' do
22      let(:account) { Account.new(id: 1) }
23
24      it { expect { charge }.to raise_error(ChargeService::InsufficientBalance) }
25    end
26  end
27end

像這樣,因為我們只會遇到 AccountRepository#find 的呼叫,因此只需要直接讓這個動作回傳我們需要的物件,就可以避免直切存取到資料庫,來加快測試。

在 Rails 中因為 ActiveRecord 基本上不需要透過層層呼叫就能夠處理,同時資料庫的速度也已經比以往快上許多,像這樣進行測試替身的製作可能沒有太大的效益,還是先以能快速撰寫「可讀的」測試為優先考量會更好。


如果想在第一時間收到更新,歡迎訂閱弦而時習之在這系列文章更新時收到通知,如果有希望了解的知識,可以利用優雅的 RSpec 測試回饋表單告訴我。