---
title: "重現後端實作 - Cucumber 的文件測試法"
date: 2024-05-17T00:00:00+08:00
publishDate: 2024-05-17T00:00:00+08:00
lastmod: 2023-12-06T15:50:15+08:00
tags: ["Cucumber","教學","測試","後端","Rails","ViteRuby","Vite"]
series: "test-with-cucumber"
toc: true
permalink: "https://blog.aotoki.me/posts/2024/05/17/test-with-cucumber-import-reimplement-backend/"
language: "zh-tw"
---


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

<!--more-->

## 還原資料表{#restore-database}

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

```bash
rails generate model product name:string price:integer
rails generate model cart
rails generate model cart_item cart:references product_id:bigint amount:integer
```

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

```ruby
class CreateCartItems < ActiveRecord::Migration[7.0]
  def change
    create_table :cart_items do |t|
      t.references :cart, null: false, foreign_key: true
      t.bigint :product_id, null: false
      t.integer :amount, default: 0

      t.timestamps

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

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

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

```ruby
# ...
Given('這裡有一些商品') do |table|
  table.hashes.each do |attrs|
    Product.create!(**attrs)
  end
end
```

## 還原行為{#recovery-behavior}

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

```ruby
class CartItem < ApplicationRecord
  belongs_to :cart

  def adjust(count)
    self.amount = [amount + count, 0].max
  end
end
```

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

```ruby
class Cart < ApplicationRecord
  has_many :items, class_name: 'CartItem', dependent: :destroy, 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

  def empty?
    items.sum(&:amount) == 0
  end

  def status
    items.map do |item|
      [item.product_id, item.amount]
    end.to_h
  end
end
```

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

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

```ruby
module Api
  class ProductsController < ActionController::API
    def index
      render json: Product.all
    end
  end
end
```

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

```ruby
module Api
  class CartsController < ActionController::API
    def show
      render json: Cart.find_or_initialize_by(id: 1).status
    end

    def create
      @cart = Cart.find_or_initialize_by(id: 1)
      @cart.update_amount(params[:id], params[:amount])
      @cart.save

      render json: @cart.status
    end
  end
end
```

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

```ruby
module Api
  class CheckoutsController < ActionController::API
    CHECKOUT_SUCCESS = { text: '結帳成功' }.freeze
    CHECKOUT_FAILED = { text: '結帳失敗' }.freeze

    def create
      @cart = Cart.find(1)
      return render json: CHECKOUT_FAILED if @cart.empty?

      @cart.destroy
      render json: CHECKOUT_SUCCESS
    rescue ActiveRecord::RecordNotFound
      render json: CHECKOUT_FAILED
    end
  end
end
```

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

## 重現測試步驟{#recovery-step-definitons}

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

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

```ruby
# ...

When('把 {string} 加入購物車') do |product_name|
  within '[data-testid^="product"]', text: product_name do
    click_on '加入購物車'
  end
end

When('把 {string} 移出購物車') do |product_name|
  within '[data-testid^="product"]', text: product_name do
    click_on '移出購物車'
  end
end

When('進行結帳') do
  click_on '結帳'
end

Then('可以看見購物車商品數為 {int}') do |amount|
  expect(page.find('[data-testid="cart-amount"]')).to have_text("一共 #{amount} 項商品")
end

Then('可以看見購物車總金額為 {string}') do |subtotal|
  expect(page.find('[data-testid="cart-subtotal"]')).to have_text("總金額為 #{subtotal}")
end

Then('可以看見結帳結果為 {string}') do |result|
  expect(page).to have_text(result)
end
```

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

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

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

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

