---
title: "預期外狀況的檢查 - Rails 開發實踐"
date: 2023-09-01T00:00:00+08:00
publishDate: 2023-09-01T00:00:00+08:00
lastmod: 2023-09-03T17:33:12+08:00
tags: ["重構","經驗","心得","Rails","Rails 開發實踐"]
series: "rails-in-practice"
toc: true
permalink: "https://blog.aotoki.me/posts/2023/09/01/rails-in-practice-the-exception-check/"
language: "zh-tw"
---


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

<!--more-->

## 驗收測試{#acceptance-test}

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

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

```gherkin
#language:zh-TW
# ...
  場景: 假設 Aotoki 已經訂閱，嘗試訂閱時會看到「已有訂閱」
    假設 會員 Aotoki 已經訂閱，並且在 15 天後到期
    當 我打開訂閱頁面
    並且 點選 "訂閱"
    並且 我打開訂閱狀態頁面
    那麼 我會看到 "已有訂閱"
```

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

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

## 細分模型{#detail-model}

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

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

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

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

```ruby
class SubscriptionsController < ApplicationController
  # ...

  def create
    service = CreateSubscriptionService.new(user_id: current_user.id)
    service.ensure_unsubscribed!

    subscription = service.subscribe_for(amount: 30)
    subscription.save!

    redirect_to subscriptions_path
  end
end
```

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

* 確認沒有訂閱
* 建立訂閱（指定時間）
* 儲存訂閱

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

## 加入服務{#add-service}

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

```ruby
class Subscription
  # ...
  def save!
    Subscription.create(**attributes)
  end
end
```

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

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

```ruby
# app/services/create_subscription_service.rb
class CreateSubscriptionService
  class DuplicatedSubscription < StandardError; end

  def initialize(user_id:)
    @user_id = user_id
  end

  def ensure_unsubscribed!
    return if Subscription.by_user(user_id: @user_id).empty?

    raise DuplicatedSubscription
  end

  def subscribe_for(amount:)
    Subscription.new(user_id: @user_id, expired_at: amount.days.from_now)
  end
end
```

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

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

