---
title: "持久化保存 - Cucumber 的文件測試法"
date: 2024-04-19T00:00:00+08:00
publishDate: 2024-04-19T00:00:00+08:00
lastmod: 2023-12-06T15:50:15+08:00
tags: ["Cucumber","教學","測試","後端","Ruby","Grape","ActiveRecord"]
series: "test-with-cucumber"
toc: true
permalink: "https://blog.aotoki.me/posts/2024/04/19/test-with-cucumber-persistent-data/"
language: "zh-tw"
---


使用 ActiveModel 將資料轉換成模型物件後，我們可以繼續利用 ActiveRecord 來跟資料庫整合，達到持久化資料的效果。接下來我們會修改現有的實作，讓資料可以持久化的被保存起來。

> ActiveRecord 是基於 ActiveModel 所以製作的，因此我們只需要少量的重構就可以實現持久化保存的效果。

<!--more-->

## 建立資料庫{#create-database}

要使用 ActiveRecord 持久化保存資料，我們需要先建立資料庫已經定義資料結構。在這之前，我們需要先將 ActiveRecord 加入到專案中。

修改 `Gemfile` 加入所需的套件。

```ruby
# ...

gem 'activemodel', require: 'active_model'
gem 'activerecord', require: 'active_record'

# ...
group :test do
  # ...
  gem 'database_rewinder'
end
```

因為 DatabaseRewinder 會需要 `ActiveRecord` 的存在，因此我們在 `Gemfile` 中提示要載入 `active_record` 而不是在 `app.rb` 中手動撰寫 `require 'active_record'` 來引用，我們後續會在測試階段使用 DatabaseRewinder 來清楚資料確保測試環境乾淨。

接下來加入 `database.rb` 進行資料庫相關的定義。

```ruby
# frozen_string_literal: true

DATABASE_CONFIG = Bundler.root.join('config/database.yml')
ActiveRecord::Base.configurations = YAML.safe_load(DATABASE_CONFIG.read, aliases: true)
ActiveRecord::Base.establish_connection(ENV['RACK_ENV'].to_sym)

ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
  create_table :products, if_not_exists: true do |t|
    t.string :name, null: false
    t.integer :price, default: 0
  end

  create_table :carts, if_not_exists: true
  create_table :cart_items, if_not_exists: true do |t|
    t.bigint :cart_id, null: false
    t.bigint :product_id, null: false
    t.integer :amount, default: 0

    t.index %i[cart_id product_id], unique: true
  end
end
```

我們先將 `config/database.yml` 的內容載入進來作為資料庫的設定，並且根據 `RACK_ENV` 來選擇要使用的資料庫環境。

接下來我們利用 ActiveRecord 的 `ActiveRecord::Schema` 機制直接定義資料表來用比較簡易的方法定義開發環境，為了避免開發環境因為重複啟動而衝突，在 `create_table` 的定義中加入了 `if_not_exists: true` 的檢查。

> 在正式的專案中，還是要使用 Migration 來維護 Schema 的變更會更好。

最後我們補上 `config/database.yml` 的內容，讓測試環境直接使用 SQLite 的記憶體模式進行測試。

```yaml
default: &default
  adapter: sqlite3
  database: ':memory:'

development:
  <<: *default
  database: db/development.sqlite3

test:
  <<: *default
```

## 重構模型{#refactor-model}

既然現在可以串連資料庫，那麼原本在 Model 中直接保存在記憶體的實作就可以移除，並且改為繼承 `ActiveRecord::Base` 來使用資料庫保存這些資料。

首先，開啟 `models/product.rb` 清除掉原本的實作，直接繼承自 `ActiveRecord::Base`

```ruby
# :nodoc:
class Product < ActiveRecord::Base
end
```

接下來開啟 `models/cart.rb` 調整實作配合資料庫的版本。

```ruby
# :nodoc:
class Cart < ActiveRecord::Base
  has_many :items, class_name: 'CartItem', autosave: true

  def update_amount(product_id, count)
    item = items.find { |i| i.product_id == product_id } || items.build(product_id:)
    item.adjust(count)
  end
end
```

因為我們這次加入了 `CartItem` 來記錄品項的數量，不能像之前使用 Hash 的做法直接修改，需要改為從 `#items` 裡面找出商品 ID 相同的品項，並且使用 `item.adjust(count)` 來調整數量。

> `items.build(product_id:)` 是 Ruby 3 的縮寫機制，這裡我們不會做 `item.save` 是為了避免 Side Effect（副作用）和 N + 1 Query 的原因，實際保存的處理應該要在 Controller 層級處理。

最後，增加 `models/cart_item.rb` 來實現購物車品項的數量調整機制。

```ruby
# :nodoc:
class CartItem < ActiveRecord::Base
  def adjust(count)
    self.amount = [amount + count, 0].max
  end
end
```

隨著重構跟拆分物件，每個物件負責的功能越來越明確並且單一方法的行數也漸漸的減少變得更容易維護。

## 調整寫入行為{#adjust-write-behavior}

最後我們要對 `app.rb` 做調整，將原本的寫入行為調整成配合資料庫處理的版本，並且運行 Cucumber 來驗證修改前後的行為是一致的。

開啟 `app.rb` 對 `POST /api/cart` 的實作進行調整。

```ruby
# ...
require_relative 'database'
Dir['models/*.rb'].sort.each { |path| require_relative path }

# ...
module Shop
  class API < Grape::API
    # ...
    namespace :api do
      # ...
      post '/cart' do
        @cart = Cart.find_or_initialize_by(id: 1)
        @cart.update_amount(params[:id], params[:amount])
        @cart.save

        @cart.items.map do |item|
          [item.product_id, item.amount]
        end.to_h
      end
    end
  end
end
```

因為我們在原本的設計，購物車會回傳 `{ "1" => 10 }` 這樣的資料結構，因此需要將變成 Array 的 `@cart.items` 重新處理為 Hash 的型態進行回傳。

> 這段在 MVC 框架中大多是 View 的任務，我們也可以使用像是 Serializer 的套件來定義最終要輸出的資料型態。

在運行測試之前，我們需要將 `features/support/env.rb` 原本呼叫 `Product.rest` 的段落，改為使用 DatabaseRewinder 來對資料庫做清理的處理。

```ruby
# ...

BeforeAll do
  DatabaseRewinder.clean_all
end

Before do
  DatabaseRewinder.clean
end
```

最後就可以運行 `bundle exec cucumber` 來確認修改後的成果符合我們的預期。

如果想要搭配前端測試，也可以利用 SQLite 命令，手動插入資料來進行確認。

```bash
$ sqlite3 db/development.sqlite3
> INSERT INTO products(name, price) VALUES ('Ruby 秘笈', 100), ('RSpec 秘笈', 150);
> .exit
$ bundle exec rackup
# 另一個 Shell
$ curl localhost:9292/api/products
[{"id":1,"name":"Ruby 秘笈","price":100},{"id":2,"name":"RSpec 秘笈","price":150}]
```

開啟前端的專案，就能看到商品列表現在可以依照我們對資料庫內容的調整做出不同的反應。

