預期外狀況的測試 - 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 檢查,仍然有可能發生預期外的問題。
如果對這篇文章有興趣,可以透過以下連結繼續閱讀這系列的其他文章。
- 前言 - Rails 開發實踐
- 將需求實現的準備 - Rails 開發實踐
- 獲取規格的技巧 - Rails 開發實踐
- 可以測試的規格 - Rails 開發實踐
- 快速通過測試的方法 - Rails 開發實踐
- 用測試完善規格 - Rails 開發實踐
- 資料跟資訊的差異 - Rails 開發實踐
- 用測試資料驗證邏輯 - Rails 開發實踐
- 預期外狀況的檢查 - Rails 開發實踐
- 預期外狀況的測試 - Rails 開發實踐
- 聚合多筆資料 - Rails 開發實踐
- 重構與修正邏輯 - Rails 開發實踐
- 加入聚合實體 - Rails 開發實踐
- 持久化資料 - Rails 開發實踐
- 實體與倉庫 - Rails 開發實踐
- 聚合與邊界 - Rails 開發實踐
- 使用案例與服務 - Rails 開發實踐
- 結語 - Rails 開發實踐