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

重現後端實作 - Cucumber 的文件測試法

經過調整成 ViteRuby 的專案結構後,我們已經讓 Vite 所撰寫的前端恢復基本的功能。然而我們使用 Grape 所撰寫的後端行為還無法正常運作,因此接下來我們要用類似的方法將後端重新實現,並且通過所有的 Cucumber 測試。

還原資料表

在使用 Grape 的實作中,我們是利用 ActiveRecord 來實現資料持久化的處理,透過相同的方式,我們這次利用 Rails 的 Database Migration(資料庫遷移)來管理我們的資料表,使用以下命令建構基礎樣板。

1rails generate model product name:string price:integer
2rails generate model cart
3rails generate model cart_item cart:references product_id:bigint amount:integer

因為 CartItem 會需要限制同一個購物車中只能放入一次相同的商品,因此我們需要在 db/migrations 目錄下找到對應的 Migration 檔案,並且使用 t.index 增加索引。

 1class CreateCartItems < ActiveRecord::Migration[7.0]
 2  def change
 3    create_table :cart_items do |t|
 4      t.references :cart, null: false, foreign_key: true
 5      t.bigint :product_id, null: false
 6      t.integer :amount, default: 0
 7
 8      t.timestamps
 9
10      t.index %i[cart_id product_id], unique: true
11    end
12  end
13end

接下來運行 rails db:migrate 將資料表建立起來,我們要先讓 Cucumber 的步驟定義能夠還原測試資料的建立行為。

修改 features/step_definitions/common.rb 加入新的步驟定義,產生商品。

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

還原行為

我們在 Model 上也有一些行為的實作,繼續修改產生出來的 app/model/cart_item.rb 來增加對應的行為。

1class CartItem < ApplicationRecord
2  belongs_to :cart
3
4  def adjust(count)
5    self.amount = [amount + count, 0].max
6  end
7end

繼續調整 app/model/cart.rb 將之前實作的行為整合進來。

 1class Cart < ApplicationRecord
 2  has_many :items, class_name: 'CartItem', dependent: :destroy, autosave: true
 3
 4  def update_amount(product_id, count)
 5    item = items.find { |i| i.product_id == product_id } || items.build(product_id:)
 6    item.adjust(count)
 7  end
 8
 9  def empty?
10    items.sum(&:amount) == 0
11  end
12
13  def status
14    items.map do |item|
15      [item.product_id, item.amount]
16    end.to_h
17  end
18end

因為本身就是繼承 ActiveRecord 的實作當作基礎,我們基本上不太需要做太多的修改就能夠讓 Model 的行為恢復,接下來修改 Controller 實際使用資料庫的資料。

打開 app/controllers/api/products_controller.rb 從資料庫載入商品資訊。

1module Api
2  class ProductsController < ActionController::API
3    def index
4      render json: Product.all
5    end
6  end
7end

接著修改 app/controllers/api/carts_controller.rb 讓購物車的商品可以正確的被更新以及計算。

 1module Api
 2  class CartsController < ActionController::API
 3    def show
 4      render json: Cart.find_or_initialize_by(id: 1).status
 5    end
 6
 7    def create
 8      @cart = Cart.find_or_initialize_by(id: 1)
 9      @cart.update_amount(params[:id], params[:amount])
10      @cart.save
11
12      render json: @cart.status
13    end
14  end
15end

最後是 app/controllers/api/checkouts_controller.rb 實現結帳的行為。

 1module Api
 2  class CheckoutsController < ActionController::API
 3    CHECKOUT_SUCCESS = { text: '結帳成功' }.freeze
 4    CHECKOUT_FAILED = { text: '結帳失敗' }.freeze
 5
 6    def create
 7      @cart = Cart.find(1)
 8      return render json: CHECKOUT_FAILED if @cart.empty?
 9
10      @cart.destroy
11      render json: CHECKOUT_SUCCESS
12    rescue ActiveRecord::RecordNotFound
13      render json: CHECKOUT_FAILED
14    end
15  end
16end

基本上跟 Grape 的「核心邏輯」沒有太大的差異,主要是我們需要調整為使用 render json: @cart.status 的方式來指定使用 JSON 渲染內容,假設我們有額外切分一個 Use Case 類型的物件,還能夠更清楚地做出區隔。

重現測試步驟

因為原本的前端測試是用 TypeScript 的 Playwright 框架所實現,因此我們需要將這些步驟轉換成 Rails 常用的 Capybara 方式實作,才能夠正確的讓測試進行。

修改 features/step_definitons/common.rb 補齊缺少的測試。

 1# ...
 2
 3When('把 {string} 加入購物車') do |product_name|
 4  within '[data-testid^="product"]', text: product_name do
 5    click_on '加入購物車'
 6  end
 7end
 8
 9When('把 {string} 移出購物車') do |product_name|
10  within '[data-testid^="product"]', text: product_name do
11    click_on '移出購物車'
12  end
13end
14
15When('進行結帳') do
16  click_on '結帳'
17end
18
19Then('可以看見購物車商品數為 {int}') do |amount|
20  expect(page.find('[data-testid="cart-amount"]')).to have_text("一共 #{amount} 項商品")
21end
22
23Then('可以看見購物車總金額為 {string}') do |subtotal|
24  expect(page.find('[data-testid="cart-subtotal"]')).to have_text("總金額為 #{subtotal}")
25end
26
27Then('可以看見結帳結果為 {string}') do |result|
28  expect(page).to have_text(result)
29end

實現的邏輯跟使用 Playwright 的概念類似,我們先利用 Finder 類型的操作,找出我們想要的元素,並且以此為基礎來進行互動,或者檢查文字內容。

Capybara 的 Finder 可以用 within 來限定範圍,或者 page.find(...) 來選定特定元素。

最後,我們確保 ./bin/dev 的模式下有重新建置前端的 Assets 後,運行 bundle exec cucumber 來確定我們已經將原本的行為全部都重現完畢。

到此為止,我們可以看到 Cucumber 除了能夠呈現文件的效果之外,在轉換語言、框架的前提也能夠變成驗證擁有相同行為的工具,這讓我們在開發的選擇上也獲得更大的彈性,更容易根據當下的需要調整架構。