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

加入資料模型 - Cucumber 的文件測試法

這篇文章是 Cucumber 的文件測試法 系列的一部分。

我們已經初步的完成可以給前端使用的 API 實作,然而在這個狀態下後端並沒有實際保存資料的能力,也有一些不好的實作方式(如:@@items 的 Class Variable)因此接下來我們要整合 SQLite 來作為持久化儲存的機制。

使用 ActiveModel

平常除了直接使用 Ruby on Rails 開發外,也有非常多 Ruby 專案會利用構成 Rails 的套件,其中 ActiveModel 就是一套能讓我們非常輕鬆建構資料與物件映射(ORM,Object Relation Mapping)的套件。

先修改 Gemfile 加入 ActiveModel 讓我們可以繼續後續的實作。

1# ...
2gem 'activemodel'

修改完畢後,運行 bundle install 安裝套件。

接著修改 app.rbBundle.require() 下方引用 ActiveModel 和 Model 目錄下的檔案。

1require 'bundler/setup'
2Bundler.require(:default, ENV['RACK_ENV'].to_sym)
3
4# 因為 active_model 跟 activemodel 沒有對應,Bundler 有可能偵測不到
5require 'active_model'
6Dir['models/*.rb'].sort.each { |path| require_relative path }
7
8# ...

商品模型

接下來,我們要用商品模型來管理原本寫死的作法,也讓我們可以根據情況測試對應的實作。修改 features/products.feature 加入商品資料的描述。

1#language:zh-TW
2功能: 商品
3  背景:
4    假設 這裡有一些商品
5      | id | name       | price |
6      | 1  | Ruby 秘笈  | 100   |
7      | 2  | RSpec 秘笈 | 150   |
8# ...

繼續更新 features/step_definitions/common.rb 將建立商品資料的行為實作進去。

1# ...
2
3Given('這裡有一些商品') do |table|
4  table.hashes.each { |item| Product.create!(**item) }
5end

我們還需要修改 features/support/env.rb 在每次測試前重設商品資料,以免造成互相干擾。

1Before do
2  Product.reset
3end

接著加入 models/product.rb 實作一個以 ActiveModel 定義的商品模型。

 1# frozen_string_literal: true
 2
 3# :nodoc:
 4class Product
 5  class << self
 6    def items
 7      @items ||= []
 8    end
 9
10    alias all items
11
12    def create!(**attributes)
13      items << new(**attributes)
14    end
15
16    def reset
17      @items = []
18    end
19  end
20
21  include ActiveModel::Model
22  include ActiveModel::Attributes
23  include ActiveModel::Serializers::JSON
24
25  attribute :id, :integer
26  attribute :name, :string
27  attribute :price, :integer
28end

上面利用 ActiveModel 定義了商品應該要具備 idnameprice 三個屬性,並且我們利用 class << self 在 Product 這個類別上定義了簡單的商品資訊紀錄行為,有了 ActiveModel 的加成,我們也可以用 Product.new(id: 123, name: 'Ruby 秘笈') 這樣的方式建立物件,就會比自己實作容易不少。

在這裡我們隱性的遵照 Ruby on Rails 習慣的做法,如果未來要轉換到同類型的框架,就可以省下許多修改的時間。

那麼,我們在 app.rb 中的 /api/products 就可以從寫死的版本修改為這個版本。

 1# ...
 2module Shop
 3  # :nodoc:
 4  class API < Grape::API
 5    # ...
 6    namespace 'api' do
 7      get '/products' do
 8        Product.all
 9      end
10      # ...
11    end
12  end
13end

因為我們已經在 Product 中引用了 ActiveModel::Serializers::JSON 這個模組,因此 Grape 也能順利的呼叫 #to_json 轉換成正確的 JSON 內容回傳。

購物車模型

根商品一樣,在購物車的部分我們也可以建立一個購物車模型來保存狀態。繼續加入 models/cart.rb 來實作購物車的行為。

 1class Cart
 2  class << self
 3    def find(_id)
 4      @find ||= new
 5    end
 6
 7    def reset
 8      @find = nil
 9    end
10  end
11
12  def items
13    @items ||= Hash.new { 0 }
14  end
15
16  def update_amount(id, count)
17    items[id] = [items[id] + count, 0].max
18  end
19end

目前購物車沒有自己的屬性,反而是有一個類似於 CartItem 的概念存在,因此可以先不用引用 ActiveModel 的模組來處理屬性。

另一方面,我們目前也沒有根據使用者區分購物車的需求,因此在 Cart.find(1) 的實作中,永遠回傳一個 Cart 的實例,這樣後續的邏輯就可以統一在購物車中進行。

在這邊我們用了 @items ||= Hash.new { 0 } 來初始化一個 Hash 並且讓預設值永遠是 0 這樣就不需要額外再做一次初始化,那麼在 #update_amount 方法,我們就可以直接用 items[id] + count 的方式計算更新後的數量。

為了避免負數的狀況出現,可以利用 [x, y].max 的方式取出大於等於 0 的數值。接下來將 app.rb 進行修改,改為使用 Cart 模型來處理購物車的內容。

 1# ...
 2
 3module Shop
 4  # :nodoc:
 5  class API < Grape::API
 6    # ...
 7
 8    namespace 'api' do
 9      # ...
10
11      params do
12        requires :id, type: Integer, desc: 'Product ID'
13        optional :amount, type: Integer, default: 1
14      end
15      post '/cart' do
16        @cart = Cart.find(1)
17        @cart.update_amount(
18          params[:id],
19          params[:amount]
20        )
21        @cart.items
22      end
23    end
24  end
25end

因為我們最終要看到的是 { '1': 1 } 這樣的內容,剛好是 @cart.items 保存的資料,因此在呼叫完畢 #update_amount 後直接以 @cart.items 作為回傳。

在運行 Cucumber 測試之前,我們還需要把 features/support/env.rb 原本把 @@items 重設掉的實作,改為 Cart.reset 讓每次運行測試時可以還原到初始狀態。

1# ...
2Before do
3  Product.reset
4  Cart.reset
5end

最後,運行 bundle exec cucumber 就能通過測試,並且透過重構將原本的實作抽離出來以 Model 物件進行管理跟維護。