---
title: "預期外狀況的測試 - Rails 開發實踐"
date: 2023-09-08T00:00:00+08:00
publishDate: 2023-09-08T00: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/08/rails-in-practice-the-exception-test/"
language: "zh-tw"
---


我們對在不明原因重複訂閱的情況加入了 `DuplicatedSubscription` 的例外，然而這種情況是無法在驗收測試的狀況下被重現出來，因為使用者無法在正常狀況做到這件事情，在這種狀況下單元測試就非常適合。

<!--more-->

## 限縮範圍{#scope}

我們是採用 E2E Testing 的方式模擬使用者真實操作來進行測試，這會需要我們去建立一個幾乎是完整的測試環境，現在處理訂閱邏輯的實作都集中在 `CreateSubscriptionService` 物件上，透過單元測試集中驗證這個物件，就可以確認「重複訂閱」的邏輯有正常實現。

透過這樣一層一層的加入細節，我們就可以很快速的將功能的雛形建構出來，然後慢慢的完善。如果有學過素描，他就類似於我們想要繪製一個蘋果，會先從大致的外型開始，以初學者來說會繪製一個方塊輔助，然後切去菱角讓形狀逐漸接近圓形，接著區分出明暗的區塊，在透過多次的疊加來做出光影產生立體感。

## 實作測試{#implementate-test}

既然我們已經有初步的概念，那麼就可以開始著手於實作這個單元測試，在單元測試中就可以使用 Stub 和 Mock 的技巧來輔助我們驗證這些行為。

```ruby
# spec/services/create_subscription_service.rb

require 'rails_helper'

RSpec.describe CreateSubscriptionService do
  include ActiveSupport::Testing::TimeHelpers

  subject(:service) { described_class.new(user_id: 1) }

  describe '#ensure_unsubscribed!' do
    subject(:ensure_unsubscribed!) { service.ensure_unsubscribed! }

    it { is_expected.to be_nil }

    context 'when user is subscribed' do
      before { Subscription.create(user_id: 1) }

      it { expect { ensure_unsubscribed! }.to raise_error(CreateSubscriptionService::DuplicatedSubscription) }
    end
  end

  describe '#subscribe_for' do
    subject(:subscribe) { service.subscribe_for(amount: amount) }

    let(:amount) { 30 }

    around do |example|
      travel_to Time.zone.local(2023, 1, 1, 0, 0, 0) do
        example.run
      end
    end

    it { is_expected.to be_a(Subscription) }
    it { is_expected.to have_attributes(user_id: 1) }
    it { is_expected.to have_attributes(expired_at: Time.zone.local(2023, 1, 31, 0, 0, 0)) }
  end
end
```

在這個測試中，我們分別針對 `#ensure_unsubscribed!` 和 `#subscrib_for` 兩個方法驗證，前者我們透過建立一筆資料來確認會正確觸發錯誤，後者則是驗證建立後內容是我們預期的結果。

> 因為現代的資料庫的速度不像以往那麼緩慢，因此在單元測試的時候我們不一定需要製作假的資料庫連線，因此我們並沒有對 `Subscription` 做額外的處理，一方面是目前我們也沒有使用真正的資料庫，另一方面是 Rails 沒有區分 Repository 的狀況下，也不容易進行造假。

## Controller 的處理{#controller}

雖然我們已經對 Service 做了測試，然而 Controller 並沒有處理這個錯誤，因此我們還需要加入一些測試處理這個情境，我們現讓 Controller 可以處理這個例外。

```ruby
class SubscriptionsController < ApplicationController
  rescue_from CreateSubscriptionService::DuplicatedSubscription do
    render plain: '重複訂閱', status: :bad_request
  end
  # ...
end
```

這裡捕捉了錯誤並且簡單的呈現對應的訊息，之後可以統一成特定的錯誤頁面，畢竟正常狀況下使用者不應該出現這個狀況。

然後加入 Request 類型的測試，雖然我們也可以針對 Controller 來測試，然而效益不大，因此近年來都會使用 Request 測試為主。

```ruby
# spec/requests/subscriptions_spec.rb

require 'rails_helper'

RSpec.describe 'Subscriptions', type: :request do
  include Devise::Test::IntegrationHelpers

  describe 'POST /subscriptions' do
    let(:user) { User.create(email: 'user@example.com', password: Devise.friendly_token[0..20]) }

    before { sign_in user }

    it 'is expected redirect to /subscriptions' do
      post subscriptions_path
      expect(response).to redirect_to(subscriptions_path)
    end

    context 'when user is subscribed' do
      before { Subscription.create(user_id: user.id) }

      it 'is expected to got bad request' do
        post subscriptions_path
        expect(response).to have_http_status(:bad_request)
      end

      it 'is expected to see 重複訂閱' do
        post subscriptions_path
        expect(response).to have_attributes(body: include('重複訂閱'))
      end
    end
  end
end
```

這個測試我們同樣的針對正常訂閱、重複訂閱的狀況做驗證，在這個測試的保護下也可以選擇先不進行 Service Object 的測試，然而這兩個測試涵蓋的情況不太一樣，如果能都實現的話對軟體的保護會更好。

> Service Object 可能會在其他地方被使用，像是在後台管理員可以直接為使用者建立訂閱，而且能夠「無視重複訂閱」假設沒有針對 Service Object 檢查，仍然有可能發生預期外的問題。

