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

優雅的 RSpec 測試 - 自訂匹配器

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

我們已經知道在 RSpec 中有強大的匹配器可以使用,除此之外也可以將經常重複的測試案例設計成共用案例來使用,那麼自訂匹配器則是用來針對我們系統中物件常見的回傳來處理。

使用方式

大多數情況下內建的匹配器足以滿足我們的需求,然而當我們的系統中出現 Value Object(數值物件)的時候,就會出現難以用內建的匹配器處理的狀況。

以電商網站常見的「貨幣」來作為例子,我們在沒有自訂匹配器的狀況下需要像這樣判斷。

 1RSpec.describe Cart do
 2  # ...
 3  describe '#subtotal' do
 4    # ...
 5
 6    # By Attributes
 7    it { is_expected.to have_attributes(currency: 'TWD') }
 8    it { is_expected.to have_attributes(amount: 1000) }
 9
10    # By Object
11    it { is_expected.to eq(Money.new('TWD', 10000)) }
12  end
13end

這類情況在針對比較單純的物件還算容易處理,然而遇到比較複雜的物件(例如:HTTP 回應)就不一定那麼方便,使用共用案例針對屬性驗證,似乎也不太符合「語境」這時就可以利用自訂匹配器來處理。

 1RSpec.describe Cart do
 2  matcher :be_money do |amount, currency: 'USD'|
 3    match { |actual| actual.currency == currency && actual.amount == amount }
 4  end
 5  # ...
 6  describe '#subtotal' do
 7    # ...
 8
 9    it { is_expected.to be_money(1000, currency: 'TWD') }
10  end
11end

透過自訂匹配器的機制,像是 Capybara 就能夠針對整個解析過的 HTML 物件,使用 have_selector('.bg-red-500') 這樣的匹配器來找到一個複雜物件中符合條件的情況。

流暢介面

有時候單一個匹配器可能不足以解決我們的需求,以我們作為例子的 be_money(1000, currency: 'TWD') 在閱讀上其實是不太直覺,因此 RSpec 也提供了我們快速定義 Chain Method 的方式。

 1RSpec.describe Cart do
 2  matcher :have_amount do |amount|
 3    match do |actual|
 4      res = actual.amount == amount
 5      next res if @currency.nil?
 6
 7      res && (actual.currency == (@currency || 'USD'))
 8    end
 9
10    chain :and_currency do |currency|
11      @currency = currency
12    end
13  end
14  # ...
15
16  describe '#subtotal' do
17    # ...
18
19    it { is_expected.to have_amount(1000) }
20    it { is_expected.to have_amount(1000).and_currency('TWD') }
21  end
22end

雖然匹配器的定義變的複雜,但我們也可以觀察到匹配器的使用更加彈性,可以針對情境決定是否要增加檢查幣種。

全域定義

我們前面的例子都是針對特定測試定義,然而更實用的情境是針對 RSpec 進行定義這類專案特有的匹配器。

 1RSpec::Matchers.define :have_amount do |amount|
 2  match do |actual|
 3    res = actual.amount == amount
 4    next res if @currency.nil?
 5
 6    res && (actual.currency == (@currency || 'USD'))
 7  end
 8
 9  chain :and_currency do |currency|
10    @currency = currency
11  end
12end

基本上我們只需要改為使用 RSpec::Matchers.define 來定義匹配器即可,同時如果這個匹配器只適用在某些情況,像是 API 相關的測試,我們還可以定義成模組,只在特定的類型進行啟用。

1module APIMatcher
2  extend RSpec::Matchers::DSL
3
4  matcher :be_valid_response do
5    match do |actual|
6      actual[:code] == '00'
7    end
8  end
9end

假設我們的 API 成功時都會回傳類似這樣的 JSON 結構 { "code": "00", ... } 因此可以定義一個 be_valid_response 來當作匹配器,此時在 RSpec 中可以像這樣使用。

1RSpec.describe 'User API', type: :api do
2  include APIMatcher
3
4  # ...
5  describe 'GET /users' do
6    # ...
7    it { is_expected.to be_valid_response }
8  end
9end

或者在 RSpec 設定直接定義要自動引用這個模組。

1# spec/spec_helper.rb
2
3RSpec.configure do |config|
4  # ...
5  config.include APIMatcher, type: :api
6end

這樣在所有設定 type: :api 的測試中,都可以直接使用這個類型的匹配器。


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