---
title: "優雅的 RSpec 測試 - 內容匹配"
date: 2023-02-24T00:00:00+08:00
publishDate: 2023-02-24T00: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/02/24/elegant-rspec-content-matcher/"
language: "zh-tw"
---


我們了解了常見的匹配方式之後，接下來我們要進一步善用這些匹配器來做到更加優雅的比對，讓我們的測試看起來更容易閱讀。

<!--more-->

## Has Attributes

在 RSpec 的測試中我們會設定「主旨（Subject）」來定義測試的對象，假設想要確認某個屬性是否符合預期，就可以用 `has_attributes` 匹配器來處理。

一般來說，大多數人會用這樣的方式進行驗證。

```ruby
RSpec.describe User do
  subject(:user) { User.new }

  # Assertation Style
  it { expect(user.permissions).to eq(%i[read write]) }

  # Describe Style
  describe '#permissions' do
    subject { user.permissions }

    it { is_expected.to eq(%i[read write]) }
  end
end
```

然而這兩個方式都有其缺點，使用斷言的時候因為我們用了 `user.permissions` 作為預測對象，這就破壞了主旨。使用 `describe` 切分出測試群組後，雖然主旨沒有問題，但 `describe` 比較適合做用於「動作」而非屬性。

也因此，最為適合的方式應該是透過 `has_attributes` 進行匹配

```ruby
RSpec.describe User do
  subject(:user) { User.new }

  it { is_expected.to has_attributes(permissions: %i[read write]) }
end
```

這樣就可以很清晰的表示 User 物件具備了 `#permissions` 這一個屬性可以被讀取。

> Has Attributes 匹配器是允許同時匹配多個屬性，然而這樣在發生錯誤時時我們就需要同時檢查多個屬性，因此我會偏好一次只檢查一個屬性。

## Include

Include 匹配器可以檢查陣列這類物件是否包含某個元素，跟 Be 匹配器類似的地方在於，他需要該物件具備 `#include?` 方法才可以生效，因此並不一定限於陣列物件。

```ruby
RSpec.describe Reader do
  subject(:reader) { Reader.new }

  describe '#items' do
    subject { reader.items }
    before { reader.read! }

    it { is_expected.to include('First Line') }
  end
end
```

在 Has Attributes 的例子中，我們會檢查 `#permissions` 屬性回傳了一個陣列，並且同時包含了 `read` 和 `write` 兩個符號（Symbol）來表示權限，然而這個設計會讓我們在針對不同權限的檢查時需要撰寫非常冗長的驗證，我們也可以利用 Include 匹配器來處理。

```ruby
RSpec.describe User do
  subject { User.new }

  it { is_expected.to have_attributes(permissions: include(:read)) }

  context 'when role is admin' do
    it { is_expected.to have_attributes(permissions: include(:write)) }
  end
end
```

這是 RSpec 匹配器非常強大的一個部分，我們被允許巢狀（Nested）的使用大部分匹配器，如此一來就能在不破壞「可讀性」的狀況下做出更加精細的斷言。

```ruby
RSpec.describe Order do
  subject(:order) { Order.new }

  it { is_expected.to have_attributes(items: include(be_discountable)) }
end
```

以上面例子來看，我們可以檢查商品中包含可折扣的品項，因此巢狀匹配也不限於單一層級，不過使用上還是要根據狀況判斷，如果是非常複雜的結構也該考慮設計是否還能在進行改進。

## Match

還有一些情況我們會希望知道「部分文字訊息」而不是完整的內容，可能像是發送給會員的通知信，裡面正確的套用了「會員暱稱」這類情況，我們就可以用 Match 匹配器來進行處理。

```ruby
RSpec.describe UserSMSNotification do
  subject(:notification) { UserSMSNotification.new }

  describe '#shipping_started' do
    subject { notification.shipping_started(user: user) }

    let(:user) { User.new(name: 'Aotokitsuruya') }

    it { is_expected.to have_attributes(body: include('Hello Aotokitsuruya')) }
    it { is_expected.to have_attributes(body: match(/Shipping Fee: $\d+/)) }
  end
end
```

因為字串同時實作了 `#include` 和 `#match` 兩個方法，因此我們可以針對「固定」的字串使用 `#include` 直接檢查包含某段文字，並且用 `#match` 來比對某段文字以及動態的內容。

如此一來我們就能非常彈性的去檢查一個物件所包含的狀態，以及經過一系列動作後所呈現的狀態。

---

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

