---
title: "優雅的 RSpec 測試 - 耦合與依賴注入"
date: 2023-05-12T00:00:00+08:00
publishDate: 2023-05-12T00: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/12/elegant-rspec-coupling-and-dependency-inject/"
language: "zh-tw"
---


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

<!--more-->

## 什麼是耦合{#what-is-coupling}

[耦合](https://zh.wikipedia.org/zh-tw/%E8%80%A6%E5%90%88%E6%80%A7_(%E8%A8%88%E7%AE%97%E6%A9%9F%E7%A7%91%E5%AD%B8))的情況有蠻多種類型的，不論是哪一種類型都會讓測試不容易處理。舉例來說，有一個物件需要另一個物件協助才能處理某件事情，這個時候就會產生依賴就會提高耦合的程度。

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

```ruby
class FacebookPost
  def initialize
   @api = Facebook::Client.new(...)
  end

  def find(id)
    @api.posts.find(id)
  end
end

class TwitterPost
  def initialize
    @api = Twitter::Client.new(...)
  end

  def find(id)
    @api.posts.find(id)
  end
end
```

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

```ruby
RSpec.describe FacebookPost do
  # ...

  describe '#find' do
    # ...
    before do
      posts = instance_double(Array)
      post = instance_double(Facebook::Post)
      api = instance_double(Facebook::Client, posts: posts)

      allow(posts).to receive(:find).with(1).and_return(post)
      allow(Facebook::Client).to receive(:new).and_return(api)
    end


    it { is_expected.to include(/Fake Content/) }
  end
end
```

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

```ruby
class SocialPost
  def initialize(type = :facebook)
    @service =
      if type == :facebook
        Facebook::Post.new
      else
        Twitter::Post.new
      end
  end

  def find(id)
    @service.find(id)
  end
end
```

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

## 依賴注入{#dependency-injection}

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

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

```ruby
class SocialPost
  def initialize(source)
    @source = source
  end

  def find(id)
    @source.posts.find(id)
  end
end
```

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

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

```ruby
class Adapter
  def initialize(api)
    @api = api
  end
end

class FacebookAdapter < Adapter
  def find_post(id)
    @api.posts.find(id)
  end
end

class TwitterAdapter < Adapter
  def find_post(id)
    @api.tweets.find(id)
  end
end

# ...
class SocialPost
  def initialize(adapter)
    @adapter = adapter
  end

  def find(id)
    @adapter.find_post(id)
  end
end
```

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

---

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

