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

持久化保存 - Cucumber 的文件測試法

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

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

建立資料庫

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

修改 Gemfile 加入所需的套件。

 1# ...
 2
 3gem 'activemodel', require: 'active_model'
 4gem 'activerecord', require: 'active_record'
 5
 6# ...
 7group :test do
 8  # ...
 9  gem 'database_rewinder'
10end

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

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

 1# frozen_string_literal: true
 2
 3DATABASE_CONFIG = Bundler.root.join('config/database.yml')
 4ActiveRecord::Base.configurations = YAML.safe_load(DATABASE_CONFIG.read, aliases: true)
 5ActiveRecord::Base.establish_connection(ENV['RACK_ENV'].to_sym)
 6
 7ActiveRecord::Schema.verbose = false
 8ActiveRecord::Schema.define do
 9  create_table :products, if_not_exists: true do |t|
10    t.string :name, null: false
11    t.integer :price, default: 0
12  end
13
14  create_table :carts, if_not_exists: true
15  create_table :cart_items, if_not_exists: true do |t|
16    t.bigint :cart_id, null: false
17    t.bigint :product_id, null: false
18    t.integer :amount, default: 0
19
20    t.index %i[cart_id product_id], unique: true
21  end
22end

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

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

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

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

 1default: &default
 2  adapter: sqlite3
 3  database: ':memory:'
 4
 5development:
 6  <<: *default
 7  database: db/development.sqlite3
 8
 9test:
10  <<: *default

重構模型

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

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

1# :nodoc:
2class Product < ActiveRecord::Base
3end

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

1# :nodoc:
2class Cart < ActiveRecord::Base
3  has_many :items, class_name: 'CartItem', autosave: true
4
5  def update_amount(product_id, count)
6    item = items.find { |i| i.product_id == product_id } || items.build(product_id:)
7    item.adjust(count)
8  end
9end

因為我們這次加入了 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 來實現購物車品項的數量調整機制。

1# :nodoc:
2class CartItem < ActiveRecord::Base
3  def adjust(count)
4    self.amount = [amount + count, 0].max
5  end
6end

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

調整寫入行為

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

開啟 app.rbPOST /api/cart 的實作進行調整。

 1# ...
 2require_relative 'database'
 3Dir['models/*.rb'].sort.each { |path| require_relative path }
 4
 5# ...
 6module Shop
 7  class API < Grape::API
 8    # ...
 9    namespace :api do
10      # ...
11      post '/cart' do
12        @cart = Cart.find_or_initialize_by(id: 1)
13        @cart.update_amount(params[:id], params[:amount])
14        @cart.save
15
16        @cart.items.map do |item|
17          [item.product_id, item.amount]
18        end.to_h
19      end
20    end
21  end
22end

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

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

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

1# ...
2
3BeforeAll do
4  DatabaseRewinder.clean_all
5end
6
7Before do
8  DatabaseRewinder.clean
9end

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

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

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

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