---
title: "用測試資料驗證邏輯 - Rails 開發實踐"
date: 2023-08-25T00:00:00+08:00
publishDate: 2023-08-25T00: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/08/25/rails-in-practice-use-test-data-verify-logic/"
language: "zh-tw"
---


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

<!--more-->

## 加入新的測試{#add-new-test}

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

```gherkin
# features/subscription.feature
#language:zh-TW
# ...
  場景: 假設 Aotoki 已經訂閱，並且在 15 天後到期
    假設 會員 Aotoki 已經訂閱，並且在 15 天後到期
    當 我打開訂閱狀態頁面
    那麼 我會看到 "15 天後到期"
```

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

```gherkin
# features/subscription.feature
#language:zh-TW
# ...
  場景: 假設 Aotoki 已經訂閱，並且在 15 天前到期
    假設 會員 Aotoki 已經訂閱，並且在 15 天前到期
    當 我打開訂閱狀態頁面
    那麼 我會看到 "0 天後到期"
```

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

```ruby
# features/step_definitions/subscription.rb

Given('會員 {word} 已經訂閱，並且在 {int} 天後到期') do |name, amount|
  Subscription.create(user_id: @users[name], expired_at: amount.days.from_now)
end

Given('會員 {word} 已經訂閱，並且在 {int} 天前到期') do |name, amount|
  Subscription.create(user_id: @users[name], expired_at: amount.days.ago)
end
```

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

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

## 修改實作{#modify-implementation}

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

```ruby
class SubscriptionsController < ApplicationController
  def index
    @subscription = Subscription.by_user(user_id: current_user.id).first

    render plain: "#{@subscription.expired_in_days} 天後到期"
  end

  # ...
end
```

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

```ruby
class Subscription
  class << self
    # ...

    def create(**attrs)
      subscribed << new(attrs)
    end

    def by_user(user_id:)
      subscribed.select { |subscription| subscription.user_id == user_id }
    end
  end

  # Set 會用 #hash 或 #eql? 檢查是否重複，這裡希望每個使用者都只有一筆資料，因此用 user_id 來處理
  delegate :hash, to: :user_id

  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :user_id, :integer
  attribute :expired_at, :datetime, default: -> { 30.days.from_now }

  def expired_in_days
    [0, ((expired_at - Time.zone.now) / 1.day).ceil].max
  end
end
```

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

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

