---
title: "加入資料模型 - Cucumber 的文件測試法"
date: 2024-04-12T00:00:00+08:00
publishDate: 2024-04-12T00:00:00+08:00
lastmod: 2024-04-12T09:45:19+08:00
tags: ["Cucumber","教學","測試","後端","Ruby","Grape","ActiveModel"]
series: "test-with-cucumber"
toc: true
permalink: "https://blog.aotoki.me/posts/2024/04/12/test-with-cucumber-add-data-model/"
language: "zh-tw"
---


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

<!--more-->

## 使用 ActiveModel {#use-activemodel}

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

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

```ruby
# ...
gem 'activemodel'
```

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

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

```ruby
require 'bundler/setup'
Bundler.require(:default, ENV['RACK_ENV'].to_sym)

# 因為 active_model 跟 activemodel 沒有對應，Bundler 有可能偵測不到
require 'active_model'
Dir['models/*.rb'].sort.each { |path| require_relative path }

# ...
```

## 商品模型{#product-model}

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

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

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

```ruby
# ...

Given('這裡有一些商品') do |table|
  table.hashes.each { |item| Product.create!(**item) }
end
```

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

```ruby
Before do
  Product.reset
end
```

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

```ruby
# frozen_string_literal: true

# :nodoc:
class Product
  class << self
    def items
      @items ||= []
    end

    alias all items

    def create!(**attributes)
      items << new(**attributes)
    end

    def reset
      @items = []
    end
  end

  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::Serializers::JSON

  attribute :id, :integer
  attribute :name, :string
  attribute :price, :integer
end
```

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

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

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

```ruby
# ...
module Shop
  # :nodoc:
  class API < Grape::API
    # ...
    namespace 'api' do
      get '/products' do
        Product.all
      end
      # ...
    end
  end
end
```

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

## 購物車模型{#cart-model}

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

```ruby
class Cart
  class << self
    def find(_id)
      @find ||= new
    end

    def reset
      @find = nil
    end
  end

  def items
    @items ||= Hash.new { 0 }
  end

  def update_amount(id, count)
    items[id] = [items[id] + count, 0].max
  end
end
```

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

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

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

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

```ruby
# ...

module Shop
  # :nodoc:
  class API < Grape::API
    # ...

    namespace 'api' do
      # ...

      params do
        requires :id, type: Integer, desc: 'Product ID'
        optional :amount, type: Integer, default: 1
      end
      post '/cart' do
        @cart = Cart.find(1)
        @cart.update_amount(
          params[:id],
          params[:amount]
        )
        @cart.items
      end
    end
  end
end
```

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

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

```ruby
# ...
Before do
  Product.reset
  Cart.reset
end
```

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

