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

用測試資料驗證邏輯 - Rails 開發實踐

因為我們還沒有完善所有的邏輯,因此儲存到資料庫將狀態持久化的機制可以先不處理,接下來我們需要建構不同的「測試資料」讓訂閱狀態有不同的呈現,而不是像現在這樣只有寫死的訊息。

加入新的測試

現在訂閱狀態只會有「30 天後到期」這樣的訊息,然而實際上應該要隨著天數改變才合理,因此這次我們會加入新的測試讓天數有不同的變化。

1# features/subscription.feature
2#language:zh-TW
3# ...
4  場景: 假設 Aotoki 已經訂閱,並且在 15 天後到期
5    假設 會員 Aotoki 已經訂閱,並且在 15 天後到期
6     我打開訂閱狀態頁面
7    那麼 我會看到 "15 天後到期"

這樣一來,除了訂閱當下的 30 天後到期 之外,我們還需要能夠紀錄不同的狀態,另一方面還要考慮到「已過期」的呈現方式,因此我們還要再加入一個在過去時間點到期的測試,永遠顯示「0 天後到期」作為目前的規格。

1# features/subscription.feature
2#language:zh-TW
3# ...
4  場景: 假設 Aotoki 已經訂閱,並且在 15 天前到期
5    假設 會員 Aotoki 已經訂閱,並且在 15 天前到期
6     我打開訂閱狀態頁面
7    那麼 我會看到 "0 天後到期"

一切都完成之後,我們可以加入新的步驟定義讓我們可以使用這兩個動作。

1# features/step_definitions/subscription.rb
2
3Given('會員 {word} 已經訂閱,並且在 {int} 天後到期') do |name, amount|
4  Subscription.create(user_id: @users[name], expired_at: amount.days.from_now)
5end
6
7Given('會員 {word} 已經訂閱,並且在 {int} 天前到期') do |name, amount|
8  Subscription.create(user_id: @users[name], expired_at: amount.days.ago)
9end

這裡我們讓 Subscription.create(...) 可以多接受一個 expired_at 參數讓我們可以設定過期時間,接下來就是要修改這些處理。

可能有人會注意到,現在的狀態是保存在記憶體中的,為什麼 Cucumber 使用 Capybara 模擬卻可以正常使用呢?這是因為在沒有特別設定的狀態下,我們並不會開啟真正的瀏覽器,而是模擬 Rack 請求來驗證,如果過早開始加入 JavaScript 或者前後端分離,就會失去這樣的優勢,也會讓測試時間拉長。

修改實作

原本的 SubscriptionController 是寫死的文字,現在我們會從 Subscription 來抓取資料,並且根據抓取到的資料呈現出不同的文字訊息。

1class SubscriptionsController < ApplicationController
2  def index
3    @subscription = Subscription.by_user(user_id: current_user.id).first
4
5    render plain: "#{@subscription.expired_in_days} 天後到期"
6  end
7
8  # ...
9end

這裡我們使用了跟 PlansController 相同的 .by_user 方法,並且呼叫 #expired_in_days 來取得到期時間的文字訊息。然而目前我們的 Model 是無法符合這樣的需求,因此還需要調整 Model 來反映這件事情。

 1class Subscription
 2  class << self
 3    # ...
 4
 5    def create(**attrs)
 6      subscribed << new(attrs)
 7    end
 8
 9    def by_user(user_id:)
10      subscribed.select { |subscription| subscription.user_id == user_id }
11    end
12  end
13
14  # Set 會用 #hash 或 #eql? 檢查是否重複,這裡希望每個使用者都只有一筆資料,因此用 user_id 來處理
15  delegate :hash, to: :user_id
16
17  include ActiveModel::Model
18  include ActiveModel::Attributes
19
20  attribute :user_id, :integer
21  attribute :expired_at, :datetime, default: -> { 30.days.from_now }
22
23  def expired_in_days
24    [0, ((expired_at - Time.zone.now) / 1.day).ceil].max
25  end
26end

修改過後的 Model 因為開始需要紀錄不同的屬性來反應狀態,因此我們將 ActiveModel 混合(Minix)到裡面,這也是為了配合 ActiveRecord 的行為,這樣在之後的重構會變得更加容易。

在這裡要注意的是 Rails 並沒有區分 Repository(倉庫)跟 Entity(實體)這兩個概念,我們會在後續仔細討論差異,在使用的時候要有意識的區分兩者,另一方面 #expired_in_days 實際上跟狀態沒有直接關係而是顯示的資訊處理,在未來會被重構抽離出來,放到 Helper 或者 Presenter(呈現物件)