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

優雅的 RSpec 測試 - 物件的可測試性

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

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

難以測試

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

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

理想的物件

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

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

 1class Worker
 2  attr_accessor :stopped
 3
 4  def initialize(stopped: true)
 5    @stopped = stopped
 6  end
 7
 8  def start
 9    @stopped = false
10  end
11
12  def stop
13    @stopped = true
14  end
15
16  def stop?
17    @stopped == true
18  end
19end

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

 1RSpec.describe Worker do
 2  subject(:worker) { Worker.new }
 3
 4  it { is_expected.to be_stop }
 5
 6  describe '#start' do
 7    before { worker.start }
 8
 9    it { is_expected.not_to be_stop }
10  end
11
12  describe '#stop' do
13    subject(:worker) { Worker.new(stoppted: false) }
14
15    before { worker.stop }
16
17    it { is_expected.to be_stop }
18    it { expect { worker.stop }.to change(worker, :stopped).from(false).to(true) }
19  end
20end

私有方法

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

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

 1class Subscription
 2  attr_reader :services
 3
 4  def active(service_name)
 5    return false if activated?
 6
 7    append_service(name: service_name)
 8  end
 9
10  private
11
12  def append_service(name:)
13    return @services if @services.include?(name)
14
15    @services += [name]
16  end
17end

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

 1RSpec.describe Subscription do
 2  # ...
 3  describe '#active' do
 4    subject(:active) { subscription.active('unlimited_reader') }
 5
 6    it { is_expected.to be_falsy }
 7
 8    context 'when subscription is actived' do
 9      # ...
10
11      it { is_expected.to include('unlimited_reader') }
12    end
13
14    context 'when subscription is actived and activated unlimited_reader' do
15      before { subscription.active('unlimited_reader') }
16
17      it { is_expected.to include('unlimited_reader') }
18    end
19  end
20end

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

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

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


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