---
title: "優雅的 RSpec 測試 - 自訂匹配器"
date: 2023-03-17T00:00:00+08:00
publishDate: 2023-03-17T00:00:00Z
lastmod: 2023-09-03T14:30:07+08:00
tags: ["RSpec","教學","測試","匹配","Matcher"]
series: "elegant-rspec"
toc: true
permalink: "https://blog.aotoki.me/posts/2023/03/17/elegant-rspec-customize-matcher/"
language: "zh-tw"
---


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

<!--more-->

## 使用方式{#usage}

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

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

```ruby
RSpec.describe Cart do
  # ...
  describe '#subtotal' do
    # ...

    # By Attributes
    it { is_expected.to have_attributes(currency: 'TWD') }
    it { is_expected.to have_attributes(amount: 1000) }

    # By Object
    it { is_expected.to eq(Money.new('TWD', 10000)) }
  end
end
```

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

```ruby
RSpec.describe Cart do
  matcher :be_money do |amount, currency: 'USD'|
    match { |actual| actual.currency == currency && actual.amount == amount }
  end
  # ...
  describe '#subtotal' do
    # ...

    it { is_expected.to be_money(1000, currency: 'TWD') }
  end
end
```

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

## 流暢介面{#fluent-interface}

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

```ruby
RSpec.describe Cart do
  matcher :have_amount do |amount|
    match do |actual|
      res = actual.amount == amount
      next res if @currency.nil?

      res && (actual.currency == (@currency || 'USD'))
    end

    chain :and_currency do |currency|
      @currency = currency
    end
  end
  # ...

  describe '#subtotal' do
    # ...

    it { is_expected.to have_amount(1000) }
    it { is_expected.to have_amount(1000).and_currency('TWD') }
  end
end
```

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

## 全域定義{#defein-global}

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

```ruby
RSpec::Matchers.define :have_amount do |amount|
  match do |actual|
    res = actual.amount == amount
    next res if @currency.nil?

    res && (actual.currency == (@currency || 'USD'))
  end

  chain :and_currency do |currency|
    @currency = currency
  end
end
```

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

```ruby
module APIMatcher
  extend RSpec::Matchers::DSL

  matcher :be_valid_response do
    match do |actual|
      actual[:code] == '00'
    end
  end
end
```

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

```ruby
RSpec.describe 'User API', type: :api do
  include APIMatcher

  # ...
  describe 'GET /users' do
    # ...
    it { is_expected.to be_valid_response }
  end
end
```

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

```ruby
# spec/spec_helper.rb

RSpec.configure do |config|
  # ...
  config.include APIMatcher, type: :api
end
```

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

---

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

