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

預期外狀況的檢查 - Rails 開發實踐

有一些情況,並不會在規格上被描述出來,我們該去測試嗎?舉例來說,目前的實作在建立訂閱的時候,並不會檢查「重複訂閱」的狀況,雖然在介面上使用者是無法進行這樣的操作,我們應該去測試這樣的情況嗎?

驗收測試

到目前為止,我們使用 Cucumber 的技巧叫做 ATDD(Acceptance Testing Driven Development,驗收測試驅動開發)會以使用者實際能夠進行的操作來進行測試,這類型的測試是以「使用者可以操作」為前提的。

也因此,像是下面這個測試是無法實現的。

1#language:zh-TW
2# ...
3  場景: 假設 Aotoki 已經訂閱,嘗試訂閱時會看到「已有訂閱」
4    假設 會員 Aotoki 已經訂閱,並且在 15 天後到期
5     我打開訂閱頁面
6    並且 點選 "訂閱"
7    並且 我打開訂閱狀態頁面
8    那麼 我會看到 "已有訂閱"

我們已經有一個測試是在已經訂閱的狀況下,在訂閱頁面會看到「已經訂閱」的訊息,在這樣的狀況下,從一開始就不會看到訂閱的按鈕,也因此這個測試是無法成立,正常的使用者也無法自己發出一個請求來實行這件事情。

但是,我們仍會有這樣的狀況發生,該如何進行測試呢?

細分模型

使用單元測試可能會是一個不錯的方式,我們先思考一下這個問題「已有訂閱無法訂閱」的情況,這個檢查該放在哪裡?是 Subscription 的 Model 還是 Controller 呢?

我們之前有提到 Rails 中的 Model 實例(Instance)是 Entity(實體)的觀念,從職責的界定來說,Entity 的職責是維護狀態,不應該具有商業邏輯(Business Logic)的概念,那麼這個邏輯應該是由 Service Object(服務物件)來負責。

在 Rails 中 Model 泛指商業邏輯相關的物件,也因此過去有 Fat Model 的說法,實際上我們可以再細分成數種不同類型的物件,像是 Value Object(值物件)、Entity(實體)、Aggregate(聚合)、Service(服務)等等不同形式,而商業邏輯最適合透過服務來處理。

基於這樣的邏輯,我們可以重構成像這樣的 Controller 實作。

 1class SubscriptionsController < ApplicationController
 2  # ...
 3
 4  def create
 5    service = CreateSubscriptionService.new(user_id: current_user.id)
 6    service.ensure_unsubscribed!
 7
 8    subscription = service.subscribe_for(amount: 30)
 9    subscription.save!
10
11    redirect_to subscriptions_path
12  end
13end

在這個版本的 Controller 中,我們會透過 CreateSubscriptionService 這個 Service Object 來處理建立訂閱的邏輯,並且經過以下幾個步驟:

  • 確認沒有訂閱
  • 建立訂閱(指定時間)
  • 儲存訂閱

在 Controller 使用拋出例外的處理方式,可以讓邏輯更清晰易懂,同時也表示了「如果失敗表示有例外(Exception)情況」發生的狀況,也更符合 Ruby 中例外的定義。

加入服務

接下來我們要將 Service Object 的實作加入到程式裡面,首先要上 Model 支援 save! 方法來配合 ActiveRecord 的介面,方便我們未來處理持久化的功能。

1class Subscription
2  # ...
3  def save!
4    Subscription.create(**attributes)
5  end
6end

實際上處理並不困難,只需要直接呼叫 Subscription.create(...) 來將當下這個物件的數值儲存進去。

接下來加入 Service Object 來讓 Controller 可以正常的運作。

 1# app/services/create_subscription_service.rb
 2class CreateSubscriptionService
 3  class DuplicatedSubscription < StandardError; end
 4
 5  def initialize(user_id:)
 6    @user_id = user_id
 7  end
 8
 9  def ensure_unsubscribed!
10    return if Subscription.by_user(user_id: @user_id).empty?
11
12    raise DuplicatedSubscription
13  end
14
15  def subscribe_for(amount:)
16    Subscription.new(user_id: @user_id, expired_at: amount.days.from_now)
17  end
18end

在這裡的實作基本相比之前的處理沒有太多的差異,只有在 ensure_unsubscribed! 的方法中加入了 DuplicatedSubscription 這個例外,並且暫時性的在這個物件下定義這個例外。

重新使用 bundle exec cucumber 對這個功能做測試,一切的功能都正常運作,這表示我們的使用者操作並沒有被影響,我們已經順利完成一次重構,然而目前還沒有完善的處理好例外狀況。