弦而時習之

2022 年的 RSpec 測試還需要 Mock 嗎?

前幾天在 Facebook 社團 Ruby on Rails 新手村看到有人提問關於 Mock 和 Stub 的問題,其實問題本身很簡單,不過也讓我發現雖然我們都知道該寫「測試」但很多時候是不清楚怎麼撰寫的,這篇文章會先來分享一下我對 Mock 的看法,更詳細的 RSpec 測試系列請期待明年的「優雅的 RSpec 測試」連載。

Mock 與 Stub

簡單來說,Mock 的情況比較像是製作出一個「物件」,而 Stub 則是製作出一個「資料」的感覺。以 RSpec 為例子,我們只能在 RSpec Mocks 的文件找到 Stub 的使用方式,卻無法找到 Mock 的情況。

假設我們希望改變某個行為的時候會使用 Mock 的方式,如果只是要改變回傳值則可以直接使用 Stub 來處理。

比較簡單的區分方式,就是 Stub 是對一個物件的「行為」抽換的方式,而 Mock 則是準備一個假的物件來處理。

Test Double 的必要性

基本上 Mock 和 Stub 都屬於 Test Double(測試替身)的一種,在我們撰寫測試的時候,使用 Test Double 可以讓我們在非常多地方「可控」來得到想要的測試結果,然而這樣也會造成「測試」沒辦法正確的驗證預期的效果。

從我的角度來看,在情況允許的前提下,盡可能的減少這類物件會讓測試的品質更高。如果真的不得已要進行造假的處理,也盡量的控制在對底層的依賴(例如:資料庫、硬體)這類難以處理的外部依賴。

功能測試跟單元測試的差異,是「測試規模」的差異,以功能測試的角度來看一個完整的功能可能牽涉到大量的物件,因此執行起來比較慢。然而單元測試是以構成一個功能以小單元的方式驗證,因此就能大量快速地進行。

一旦加入了「測試替身」到測試中,我們會開始難以注意到邏輯、資料上的異常,因為我們會讓這些動作直接回傳預期的數值,假設套件更新後改變了回傳的結構,也可能會因此遺漏而沒有被注意到。

使用時機

既然會有造成測試無法很好發揮的狀況,那麼為什麼還會有需要造假的狀況,這是因為像是硬體、不可控的第三方服務可能會影響我們驗證「商業邏輯」因此暫時的造假讓「功能驗證」可以大量的執行,再搭配 QA(Quality Assurance,品質保證)和整合測試等不同層級的驗證,來完善整個測試的流程。

硬體、外部依賴

適合使用 Mock 的時機點,就是我們無法在測試伺服器上隨時「連接外部硬體」因此透過製作假物件的方式就很適合來處理這樣的情境。

舉例來說,我們有一套跑在樹莓派(Raspberry Pi)上的軟體,會在收到伺服器的事件後呼叫熱感應列表機來列印發票。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class PrinterService
  COMMAND_RESPONSE_SIZE = 1

  def initialize(io)
    @io = io
  end

  def print(event)
    command = PrinterCommand.build(
      serial_number: event.serial_number,
      items: event.items
    )

    @io.write command.to_bytes
    @io.read COMMAND_RESPONSE_SIZE
  end
end

class PurchaseEventHandler < EventHandler
  def initialize(printer_service)
    @printer_service = printer_service
  end

  def on_completed(event)
    success = @printer_service.print(event)
    return if success == 1

    raise PrinterError, 'unable to printe receipt'
  end
end

在正常的使用情境下,我們應該會是像這樣呼叫

1
2
3
4
5
usb = IO.open('/dev/ttyUSB0')
printer_service = PrinterService.new(usb)
purchase_event_handler = PurchaseEventHandler.new(printer_service)
# ...
purchase_event_handler.on_complated(event)

如果我們想要對他做測試,那麼就必須要有一個裝置剛好接在 /dev/ttyUSB0 上面,但測試本身幾乎是沒有機會這樣做的,因此我們可以利用製造假物件的技巧來處理。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
RSpec.describe PurchaseEventHandler do
  subject(:handler) { PurchaseEventHandler.new(printer_service) }

  let(:printer_service) { PrinterService.new(io) }
  let(:io) { StringIO.new }

  describe '#on_completed' do
    subject(:on_completed) { handler.on_completed(PurchaseEvent.new(...)) }

    before { allow(io).to receive(:read).and_return(0) }

    it { expect { on_completed }.to raise_error(PrinterError) }

    context 'when printer is satisified' do
      before { allow(io).to receive(:read).and_return(1) }

      it { is_expected.to be_nil }
    end
  end
end

我們可以觀察到,在這個情境下我們幾乎不需要透過 Mock 處理,只需要利用 Stub 讓某個物件直接回傳預期的數值即可。

這是因為語言特性的關係,讓 Ruby 在大多數的情況下都能夠使用 Stub 的方式處理。

控制測試範圍

假設我們希望做小規模的單元測試,那麼就可能會希望限制測試呼叫的物件數量,這個時候就很適合利用測試替身進行。

假設我們有一個 ReportService 會聚合整個系統中的各種資料並且產生報告,然而這些資料來源跟相關的物件非常多,但我們只希望確認報告是否有被正確產生。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class ReportService
  def initialize(user_repository, product_repository, ...)
    @user_repository = user_repository
    # ...
  end

  def generate(order)
    sales = user_repository.sales_from(id: order.sale_ids)
    # ...
	report_generator.to_csv(rows)
  end
end
1
2
service = ReportService.new(user_repository, product_repository, ...)
service.generate(order)

這個時候我們就可以直接對物件依賴的 Repository 查詢方法進行 Mock 的處理。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
RSpec.describe ReportService do
  subject(:service) { ReportService.new(user_repo, ...) }

  let(:user_repo) { instance_double(UserRepository) }
  # ...

  before do
    allow(player_repo).to receive(:sales_from).and_return([...])
    # ...
  end

  describe '#generate' do
    subject { service.generate(order) }

    it { is_expected.to include('1,Aotoki,100') }
    # ...
  end
end

在這裡我們直接的將 UserRepository 以及其他依賴的物件都直接造假,並且無視其原本的行為(例如:#sales_from)直接回傳我們所需要的結果,因此我們是製造了一個假物件(Mock)來反應某些行為,並且對這個物件做 Stub 的處理,將 #sales_from 方法客製化成我們所期望的回傳值。

如此一來,我們就能夠控制測試的範圍在一個特定的範圍內,進而更針對性地對特定物件做測試。

不過以現代大多軟體的架構來看,像是資料庫這類依賴其實已經沒有慢到需要解決依賴,同時我們也不是設計需要不斷燒錄到硬體內因而難以調整的軟體,很多時候是不需要對底層依賴限制以及將資料庫這類服務用替身的方式處理。

也就是說,大多數的狀況下我們不太需要使用 Mock 和 Stub 的技巧進行測試,反而需要多注意我們是否在物件的封裝、依賴的處理上有問題,而使我們需要進行大量的 Mock 和 Stub 才能對一個物件進行測試。

以 Ruby on Rails 專案常用的 FactoryBot 套件,就負責處理掉大多數跟資料庫同時測試的物件生成問題,也不需要花費力氣思考如何製作測試用物件,直接透過資料庫驗證即可。

測試相關的技巧當然不只這些,一篇文章的篇幅很難完整的描述這些內容,剩下的部分會在明年的連載詳細跟大家分享 RSpec 的測試該怎麼寫,才能讓其他人容易讀懂、更好維護。

電子報

留言