蒼時弦也
蒼時弦也
資深軟體工程師

優雅的 RSpec 測試 - 耦合與依賴注入

雖然我們沒有使用依賴注入的技巧,或者實作中有耦合的問題存在,在 Ruby 中因為語言特性的關係,我們還是有非常多方式可以進行測試,然而這也會讓我們的測試變得複雜。

什麼是耦合

耦合的情況有蠻多種類型的,不論是哪一種類型都會讓測試不容易處理。舉例來說,有一個物件需要另一個物件協助才能處理某件事情,這個時候就會產生依賴就會提高耦合的程度。

比較常見的就是 API 相關的行為,假設我們有一系列查詢社群網站貼文的功能。

 1class FacebookPost
 2  def initialize
 3   @api = Facebook::Client.new(...)
 4  end
 5
 6  def find(id)
 7    @api.posts.find(id)
 8  end
 9end
10
11class TwitterPost
12  def initialize
13    @api = Twitter::Client.new(...)
14  end
15
16  def find(id)
17    @api.posts.find(id)
18  end
19end

這個例子我們的 FacebookPostTwitterPost 各自跟對應的 API 客戶端耦合,當我們要進行測試時,就需要像這樣處理。

 1RSpec.describe FacebookPost do
 2  # ...
 3
 4  describe '#find' do
 5    # ...
 6    before do
 7      posts = instance_double(Array)
 8      post = instance_double(Facebook::Post)
 9      api = instance_double(Facebook::Client, posts: posts)
10
11      allow(posts).to receive(:find).with(1).and_return(post)
12      allow(Facebook::Client).to receive(:new).and_return(api)
13    end
14
15
16    it { is_expected.to include(/Fake Content/) }
17  end
18end

像這樣,我們需要去定義非常多測試替身相關的處理才能夠驗證這件事情,假設我們希望統一抓取到社群網站的介面,情況又會更加麻煩。

 1class SocialPost
 2  def initialize(type = :facebook)
 3    @service =
 4      if type == :facebook
 5        Facebook::Post.new
 6      else
 7        Twitter::Post.new
 8      end
 9  end
10
11  def find(id)
12    @service.find(id)
13  end
14end

像這樣的狀況,我們除了會有非常多判斷之外,還需要在測試時建構原本 Facebook::Post 測試相關的依賴才能夠正確的測試,最後測試就會變得越來越複雜。

依賴注入

想要解決這類問題,善用依賴注入(Dependency Injection)和設計模式(Design Pattern)來進行重構,某種程度上就能夠改善這樣的狀況。

以上面的例子來看,因為 Facebook::ClientTwitter::Client 的行為都是 @api.posts.find(id) 的,我們可以直接將這兩個物件當作是 SocialPost 的依賴,直接「注入」到裡面。

1class SocialPost
2  def initialize(source)
3    @source = source
4  end
5
6  def find(id)
7    @source.posts.find(id)
8  end
9end

如此一來我們除了可以消除 FacebookPostTwitterPost 這兩個物件,還能夠將多餘的判斷消除,在撰寫測試也不需要先製作 FacebookPost 替身,再製作 Facebook::Client 替身,而是直接將 Facebook::Client 的替身作為依賴傳入即可。

另一方面,假設 Twitter 使用的是 @api.tweets.find(id) 而不是 @api.posts.find(id) 的話,就可以利用 Adapter 這類設計模式,我們先統一介面再來處理這些問題。

 1class Adapter
 2  def initialize(api)
 3    @api = api
 4  end
 5end
 6
 7class FacebookAdapter < Adapter
 8  def find_post(id)
 9    @api.posts.find(id)
10  end
11end
12
13class TwitterAdapter < Adapter
14  def find_post(id)
15    @api.tweets.find(id)
16  end
17end
18
19# ...
20class SocialPost
21  def initialize(adapter)
22    @adapter = adapter
23  end
24
25  def find(id)
26    @adapter.find_post(id)
27  end
28end

調整之後,我們對 Adapter 的測試就是注入 Facebook::Client 或者 Twitter::Client 來驗證,而 SocialPost 只需要製作一個符合 Adapter#find_post 的替身來驗證後續的行為即可,這樣也讓我們測試的前置準備變得相對單純許多,也讓我們更容易擴充、修改和測試。


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