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

優雅的 RSpec 測試 - 輔助方法

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

除了共用案例和自訂匹配器之外,我們可以像 Rails 的 View Helper 一樣定義輔助方法,假設我們的測試需要做非常多的準備,利用自訂輔助方法可以幫我們極大的改善測試案例的可讀性。

使用方式

因為 RSpec 是透過 DSL 來實現的,因此我們的測試本身也是一個 Ruby 物件,要定義輔助方法可以直接在測試中定義。

 1RSpec.describe Order do
 2  # ...
 3  def there_have_some_products_in_order(product_ids:)
 4    product_ids.each do |id|
 5      order.add_product!(product_id: id)
 6    end
 7  end
 8
 9  subject(:order) { create(:order) }
10
11  before do
12   there_have_some_products [
13     { id: 1, name: 'PS5', price: 15000 },
14     { id: 2, name: 'Switch', price: 9000 }
15   ]
16   there_have_some_products_in_order(product_ids: [1, 2])
17  end
18
19  it { is_expected.to have_attributes(subtotal: 24000) }
20end

原本我們在撰寫測時,會在 before 上將建立商品、訂單內品項的實作直接寫上去,然而這個情況會讓我們的測試在不同的區段有非常多的「重複」並且佔據非常大面積的測試內容,除了閱讀不方便之外,也很能讓人馬上理解「這裡有一些商品,而且都在訂單中」這樣的資訊。

透過輔助方法,我們就可以用 there_have_some_products 這樣的方式去呈現更加直觀的測試資訊。

使用模組

直接在測試中定義雖然方便,然而我們經常會有不同的測試會要用到類似的行為,這個時候抽離出來變成模組,就可以統一整個團隊在特定「動作」的行為,並且有著統一的方式去描述測試的行為。

 1module ProductHelper
 2  def there_have_some_products(*products)
 3    @products = products.map do |attributes|
 4      create(:product, **attributes)
 5    end
 6  end
 7end
 8
 9module OrderItemHelper
10  def there_have_some_products_in_order(*product_names)
11    product_names.each do |name|
12     order.add_product(@products[name])
13    end
14  end
15end

使用時,就可以直接用 include 加入到測試裡面。

 1RSpec.describe Order do
 2  include ProductHelper
 3  include OrderItemHelper
 4
 5  subject(:order) { create(:order) }
 6
 7  before do
 8   there_have_some_products [
 9     { id: 1, name: 'PS5', price: 15000 },
10     { id: 2, name: 'Switch', price: 9000 }
11   ]
12   there_have_some_products_in_order('PS5', 'Switch')
13  end
14
15  it { is_expected.to have_attributes(subtotal: 24000) }
16end

在這個案例中,我們可以適度的利用實例變數(Instance Variable)來儲存資訊輔助我們的輔助方法更加可讀,然而要注意實例變數可能會造成一些副作用(Side Effect)因此在使用上需要格外小心。

自動載入

除了手動引用模組來載入輔助方法之外,我們也可以善用 RSpec 的後設資料( Metada)來處理何時該載入哪些模組。

1RSpec.configure do |config|
2  # ...
3  config.include ProductHelper, feature: :order
4  config.include OrderItemHelper, feature: :order
5end

此時只需要在測試中加上 feature: :order 就能夠自動的使用到這些方法。

 1RSpec.describe Order, feature: :order do
 2  subject(:order) { create(:order) }
 3
 4  before do
 5   there_have_some_products [
 6     { id: 1, name: 'PS5', price: 15000 },
 7     { id: 2, name: 'Switch', price: 9000 }
 8   ]
 9   there_have_some_products_in_order('PS5', 'Switch')
10  end
11
12  it { is_expected.to have_attributes(subtotal: 24000) }
13end

如果希望要加入到所有測試中,則可以跳過 feature: :order 的設定,在 Rails 的測試中,大多會有像是 type: :model 這類後設資料,就是為了對應不同的測試情境,用來選定該載入哪些輔助方法。

當測試的數量變多時,RSpec 更建議自己用 require 載入輔助模組來處理,而不是透過自動載入所有檔案的方式,像這樣減少需要載入的檔案可以加入測試的運行。

擴充測試

除了引用之後讓 RSpec 的測試案例可以使用,我們也可以使用擴充的方式讓我們更容易進行一些測試情境的定義,然而這個機制存在一定的限制跟缺點,應該要視情況去使用會比較好。

1module SessionHelper
2  def login_as(type)
3    before do
4      token = generate_auth_token_from(send(type))
5      request.headers['Authorization'] = "Bearer #{token}"
6    end
7  end
8end
 1RSpec.describe MyAPI do
 2  include SessionHelper
 3
 4  let(:user) { create(:user) }
 5  login_as(:user)
 6
 7  describe 'GET /' do
 8    subject { get '/' }
 9
10    it { is_expected.to be_successful }
11  end
12end

雖然可以像是 let 這樣使用,然而同樣的會受到一些限制。舉例來說我們就無法用 login_as(user) 這樣的寫法,而需要用 login_as(:user) 再用 send(type) 來取得我們用 let 定義的 user 物件,相比之下,直接定義成可以在 before 中使用的方法更為實用。

也因此,會需要使用到擴充方式定義的輔助方法,我們可以預期是少數用來定義或者設定測試的情境,跟實際測試通常關聯會比較小。


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