![蒼時弦也](/images/avatar_huf49a82458f53859c626adf20da3bee9f_18804_200x200_resize_q90_h2_box_3.webp)
預期外狀況的檢查 - 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
對這個功能做測試,一切的功能都正常運作,這表示我們的使用者操作並沒有被影響,我們已經順利完成一次重構,然而目前還沒有完善的處理好例外狀況。