---
title: "優雅的 RSpec 測試 - 輔助方法"
date: 2023-03-24T00:00:00+08:00
publishDate: 2023-03-24T00:00:00Z
lastmod: 2023-09-03T14:30:07+08:00
tags: ["RSpec","教學","測試","輔助","Helper"]
series: "elegant-rspec"
toc: true
permalink: "https://blog.aotoki.me/posts/2023/03/24/elegant-rspec-test-helper/"
language: "zh-tw"
---


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

<!--more-->

## 使用方式{#usage}

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

```ruby
RSpec.describe Order do
  # ...
  def there_have_some_products_in_order(product_ids:)
    product_ids.each do |id|
      order.add_product!(product_id: id)
    end
  end

  subject(:order) { create(:order) }

  before do
   there_have_some_products [
     { id: 1, name: 'PS5', price: 15000 },
     { id: 2, name: 'Switch', price: 9000 }
   ]
   there_have_some_products_in_order(product_ids: [1, 2])
  end

  it { is_expected.to have_attributes(subtotal: 24000) }
end
```

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

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

## 使用模組{#use-module}

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

```ruby
module ProductHelper
  def there_have_some_products(*products)
    @products = products.map do |attributes|
      create(:product, **attributes)
    end
  end
end

module OrderItemHelper
  def there_have_some_products_in_order(*product_names)
    product_names.each do |name|
     order.add_product(@products[name])
    end
  end
end
```

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

```ruby
RSpec.describe Order do
  include ProductHelper
  include OrderItemHelper

  subject(:order) { create(:order) }

  before do
   there_have_some_products [
     { id: 1, name: 'PS5', price: 15000 },
     { id: 2, name: 'Switch', price: 9000 }
   ]
   there_have_some_products_in_order('PS5', 'Switch')
  end

  it { is_expected.to have_attributes(subtotal: 24000) }
end
```

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

## 自動載入{#autoload}

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

```ruby
RSpec.configure do |config|
  # ...
  config.include ProductHelper, feature: :order
  config.include OrderItemHelper, feature: :order
end
```

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

```ruby
RSpec.describe Order, feature: :order do
  subject(:order) { create(:order) }

  before do
   there_have_some_products [
     { id: 1, name: 'PS5', price: 15000 },
     { id: 2, name: 'Switch', price: 9000 }
   ]
   there_have_some_products_in_order('PS5', 'Switch')
  end

  it { is_expected.to have_attributes(subtotal: 24000) }
end
```

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

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

## 擴充測試{#extend-test}

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

```ruby
module SessionHelper
  def login_as(type)
    before do
      token = generate_auth_token_from(send(type))
      request.headers['Authorization'] = "Bearer #{token}"
    end
  end
end
```

```ruby
RSpec.describe MyAPI do
  include SessionHelper

  let(:user) { create(:user) }
  login_as(:user)

  describe 'GET /' do
    subject { get '/' }

    it { is_expected.to be_successful }
  end
end
```

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

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

---

如果想在第一時間收到更新，歡迎[訂閱弦而時習之](https://mailchi.mp/aotoki/graceful-rspec)在這系列文章更新時收到通知，如果有希望了解的知識，可以利用[優雅的 RSpec 測試回饋表單](https://us4.list-manage.com/survey?u=dd3d68032c0510041f1302539&id=5ddf86cae1&attribution=false)告訴我。

