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

預期外狀況的測試 - Rails 開發實踐

這篇文章是 Rails 開發實踐 系列的一部分。

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

限縮範圍

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

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

實作測試

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

 1# spec/services/create_subscription_service.rb
 2
 3require 'rails_helper'
 4
 5RSpec.describe CreateSubscriptionService do
 6  include ActiveSupport::Testing::TimeHelpers
 7
 8  subject(:service) { described_class.new(user_id: 1) }
 9
10  describe '#ensure_unsubscribed!' do
11    subject(:ensure_unsubscribed!) { service.ensure_unsubscribed! }
12
13    it { is_expected.to be_nil }
14
15    context 'when user is subscribed' do
16      before { Subscription.create(user_id: 1) }
17
18      it { expect { ensure_unsubscribed! }.to raise_error(CreateSubscriptionService::DuplicatedSubscription) }
19    end
20  end
21
22  describe '#subscribe_for' do
23    subject(:subscribe) { service.subscribe_for(amount: amount) }
24
25    let(:amount) { 30 }
26
27    around do |example|
28      travel_to Time.zone.local(2023, 1, 1, 0, 0, 0) do
29        example.run
30      end
31    end
32
33    it { is_expected.to be_a(Subscription) }
34    it { is_expected.to have_attributes(user_id: 1) }
35    it { is_expected.to have_attributes(expired_at: Time.zone.local(2023, 1, 31, 0, 0, 0)) }
36  end
37end

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

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

Controller 的處理

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

1class SubscriptionsController < ApplicationController
2  rescue_from CreateSubscriptionService::DuplicatedSubscription do
3    render plain: '重複訂閱', status: :bad_request
4  end
5  # ...
6end

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

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

 1# spec/requests/subscriptions_spec.rb
 2
 3require 'rails_helper'
 4
 5RSpec.describe 'Subscriptions', type: :request do
 6  include Devise::Test::IntegrationHelpers
 7
 8  describe 'POST /subscriptions' do
 9    let(:user) { User.create(email: '[email protected]', password: Devise.friendly_token[0..20]) }
10
11    before { sign_in user }
12
13    it 'is expected redirect to /subscriptions' do
14      post subscriptions_path
15      expect(response).to redirect_to(subscriptions_path)
16    end
17
18    context 'when user is subscribed' do
19      before { Subscription.create(user_id: user.id) }
20
21      it 'is expected to got bad request' do
22        post subscriptions_path
23        expect(response).to have_http_status(:bad_request)
24      end
25
26      it 'is expected to see 重複訂閱' do
27        post subscriptions_path
28        expect(response).to have_attributes(body: include('重複訂閱'))
29      end
30    end
31  end
32end

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

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