---
title: "優雅的 RSpec 測試 - 物件的可測試性"
date: 2023-05-05T00:00:00+08:00
publishDate: 2023-05-05T00:00:00Z
lastmod: 2023-09-03T14:30:07+08:00
tags: ["RSpec","教學","測試","物件"]
series: "elegant-rspec"
toc: true
permalink: "https://blog.aotoki.me/posts/2023/05/05/elegant-rspec-the-testability/"
language: "zh-tw"
---


在這系列的文章中，我們已經對使用 RSpec 進行測試的技巧有一定的理解，接下來我們來討論一下「可測試性」這件事情。

<!--more-->

## 難以測試{#hard-to-test}

在理想的狀況下，我們會認為所有的程式都能可以被測試。但為什麼會出現「難以測試」這樣的情況呢？其實原因有非常多，像是過度的「耦合」造成我們無法控制物件依賴，進而無法使用測試替身之類的方式來輔助測試。

簡單來說，假設我們的物件設計不好，可能會造成在撰寫測試上的困難。不過「容易測試」的物件，並不一定是一個好用、合理的物件，一個良好的物件設計需要考量的地方是非常多的。

## 理想的物件{#the-ideal-object}

假設有一個物件是非常容易被測試的，大多是我們可以很容易的觀察到內部狀態（Instance Variable，實例變數）的狀況，並且每個行為都能獨立的動作而不需要依賴其他前置動作。

舉例來說，像是下面這樣的物件非常好測試，基本上就類似於 Rails 框架的 Model，也因此大多數專案通常都會優先加入 Model 類型的測試。

```ruby
class Worker
  attr_accessor :stopped

  def initialize(stopped: true)
    @stopped = stopped
  end

  def start
    @stopped = false
  end

  def stop
    @stopped = true
  end

  def stop?
    @stopped == true
  end
end
```

對應的測試可以寫成這樣，非常簡單且直覺。

```ruby
RSpec.describe Worker do
  subject(:worker) { Worker.new }

  it { is_expected.to be_stop }

  describe '#start' do
    before { worker.start }

    it { is_expected.not_to be_stop }
  end

  describe '#stop' do
    subject(:worker) { Worker.new(stoppted: false) }

    before { worker.stop }

    it { is_expected.to be_stop }
    it { expect { worker.stop }.to change(worker, :stopped).from(false).to(true) }
  end
end
```

## 私有方法{#private-method}

雖然在 Ruby 中我們可以利用 `#send` 方法呼叫私有方法（Private Method），然而在測試中我們應該專注於公有方法（Public Method）的呼叫，要覆蓋到這些私有方法必須透過公有方法來涵蓋到，這也改善了我們程式中存在 Dead Code（沒有任何人使用）的比例。

假設我們有一個 Subscription 物件管理了可用的服務，只有在「已訂閱」的狀態下才能啟用服務，因此我們可能會提供 `#active` 方法替我們處理這件事情，同時加入一個私有的 `#append_service` 方法來協助我們增加可用的服務。

```ruby
class Subscription
  attr_reader :services

  def active(service_name)
    return false if activated?

    append_service(name: service_name)
  end

  private

  def append_service(name:)
    return @services if @services.include?(name)

    @services += [name]
  end
end
```

在撰寫對應的測試時，我們並不需要去測試 `#append_service` 是否正確，而是要驗證 `#active` 方法是否可以正常使用，以及結果是否符合我們預期的最終結果。

```ruby
RSpec.describe Subscription do
  # ...
  describe '#active' do
    subject(:active) { subscription.active('unlimited_reader') }

    it { is_expected.to be_falsy }

    context 'when subscription is actived' do
      # ...

      it { is_expected.to include('unlimited_reader') }
    end

    context 'when subscription is actived and activated unlimited_reader' do
      before { subscription.active('unlimited_reader') }

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

像這樣子，我們透過 Subscription 的單元測試涵蓋了 `#active` 方法相關的情境（訂閱未啟用、啟用後第一次加入服務、服務已存在）來完整和蓋所有私有方法的行為。

> 要特別注意的地方是，這樣雖然能提高「覆蓋率」然而很可能是「為測試而測試」而不是真正的去測試所有真正被使用到的情境，在這種情境下透過觀察 E2E Testing 的涵蓋率以及比對功能定義，會比使用單元測試的覆蓋率更準確。

想要更近一步的解決複雜的問題，接下來會討論「依賴注入」來減少耦合，同時也能讓我們更容易測試到一些難以測試的依賴。

---

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

