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

結帳處理 - Cucumber 的文件測試法

這篇文章是 Cucumber 的文件測試法 系列的一部分。

我們現在處理好保存狀態的機制,目前還剩下 POST /api/checkout 的實作還沒加入到裡面,除此之外每次開啟前端時也無法看到最新的購物車狀態,我們要來將這些情境處理到可用的情形。

結帳機制

我們目標的結帳機制是實作「清空購物車」來實現目標,並且在購物車沒有商品或者不存在時顯示錯誤,因此實作上並不困難。

先針對結帳的行為加入對應的 Cucumber 測試,開啟 features/cart.feature

 1#language:zh-TW
 2# ...
 3場景: 購物車沒有商品時,當我嘗試結帳會看到「結帳失敗」
 4 進行結帳
 5    那麼 我會看到 JSON 物件包含 "text" 為 "結帳失敗" 的文字
 6
 7  場景: 購物車有一項商品時,當我嘗試結帳會看到「結帳成功」
 8 把商品編號 1 加入購物車
 9    並且  進行結帳
10    那麼 我會看到 JSON 物件包含 "text" 為 "結帳成功" 的文字

因為這些是新的步驟,修改 features/step_definitions/common.rb 增加新的步驟定義

 1# ...
 2When('進行結帳') do
 3  post '/api/checkout'
 4end
 5
 6Then('我會看到 JSON 物件包含 {string} 為 {string} 的文字') do |key, value|
 7  json = JSON.parse(last_response.body)
 8
 9  expect(json).to include(key => value)
10end

完成測試的實作後,修改 app.rb 加入結帳的邏輯。

 1# ...
 2module Shop
 3  class API < Grape::API
 4    CHECKOUT_SUCCESS = { text: '結帳成功' }.freeze
 5    CHECKOUT_FAILED = { text: '結帳失敗' }.freeze
 6    # ...
 7    namespace :api do
 8      # ...
 9      post '/checkout' do
10        @cart = Cart.find(1)
11        next CHECKOUT_FAILED if @cart.empty?
12
13        @cart.destroy
14        CHECKOUT_SUCCESS
15      rescue ActiveRecord::RecordNotFound
16        CHECKOUT_FAILED
17      end
18    end
19  end
20end

因為我們沒有要串接金流或者進行其他的處理,只需要呼叫 #destory 方法即可,然而在預設的行為下 ActivRecord 不一定會幫我們移除 CartItem 的內容,因此我們還需要修改 Cart 的實作,開啟 models/cart.rb 進行調整。

1class Cart < ActiveRecord::Base
2  has_many :items, class_name: 'CartItem', dependent: :destroy, autosave: true
3
4  # ...
5
6  def empty?
7    items.sum(&:amount) == 0
8  end
9end

我們對 has_many 增加了 dependent: :destroy 選項,這樣在移除購物車的時候就會自然的將 CartItem 一起清理掉,除此之外我們在購物車品項都為 0 的時候要拒絕結帳,因此還需要額外實作一個 #empty? 方法並且統計品項的數量,用來在不正確的數量時回傳錯誤訊息。

最後運行 bundle exec cucumber 確認實作沒有破壞原有的行為,也能夠正常通過 Cucumber 文件所撰寫的要求。

購物車資訊

解決了結帳的行為後,我們需要讓原本的購物車可以重現後端保存的狀態,不然可能會在不知情的狀況下對錯誤的商品進行結帳,因此我們還需要加入 GET /api/cart 來回傳最後的狀態,開啟 app.rb 加入實作。

 1module Shop
 2  class API < Grape::API
 3    CHECKOUT_SUCCESS = { text: '結帳成功' }.freeze
 4    CHECKOUT_FAILED = { text: '結帳失敗' }.freeze
 5    # ...
 6    namespace :api do
 7      # ...
 8      get '/cart' do
 9        Cart.find_or_initialize_by(id: 1).status
10      end
11
12      # ...
13      post '/cart' do
14        # ...
15        @cart.save
16        @cart.status
17      end
18    end
19  end
20end

因為我們都需要將 { 1 => 1 } 這樣的 Hash 回傳,因此統一封裝成一個 #status 方法來反應這個要求,修改 models/cart.rb 擴充行為。

1class Cart < ActiveRecord::Base
2  # ...
3
4  def status
5    items.map do |item|
6      [item.product_id, item.amount]
7    end.to_h
8  end
9end

接著要修改前端的實作,讓購物車載入時就能夠讀取到上一次的狀態,先修改 src/stores/cart.ts 讓我們可以使用 refresh() 方法刷新購物車。

 1// ...
 2
 3export const useCartStore = defineStore('cart', () => {
 4  // ...
 5
 6  return {
 7    items,
 8    hasItem,
 9    add,
10    remove,
11    clear,
12    totalItems,
13    refresh,
14  }
15})

接著修改 src/api.ts 加入新的 API 呼叫。

1// ...
2export const fetchProducts = async () => await fetch(`${API_SERVER}/api/products`).then(res => res.json())
3export const getCart = async() => await fetch(`${API_SERVER}/api/cart`).then(res => res.json())
4// ...

最後到 src/components/Cart.vue 裡面將 getCart() 呼叫來載入新的購物車資訊。

 1<script setup lang="ts">
 2import { ref, reactive, computed } from 'vue'
 3import { useProductStore } from '../stores/product'
 4import { useCartStore } from '../stores/cart'
 5import { getCart, checkout } from '../api'
 6
 7// ...
 8const cart = useCartStore()
 9
10// ...
11getCart().then(items => cart.refresh(items))
12</script>

這樣在開啟前端預覽時就會正常,然而我們的測試並沒有區分是 GET /api/cart 還是 POST /api/cart 因此會失敗,我們還需要修改 features/support/world.ts 裡面對 /api/cart 的定義,在 GET 狀態下回傳空的購物車。

 1// ...
 2Before(async function() {
 3  const state: Record<number, number> = {}
 4
 5  await this.page.route('/api/cart', async (route: playwright.Route) => {
 6    if(route.request().method() === 'GET') {
 7      return await route.fulfill({ json: {} })
 8    }
 9
10    const data = await route.request().postDataJSON()
11
12    state[Number(data.id)] = state[data.id] || 0
13    state[Number(data.id)] += Number(data.amount)
14
15    await route.fulfill({ json: state })
16  })
17})

最後運行 yarn run cucumber-js 就可以看到測試順利通過。

這裡並沒有在舉出「購物車已有物品」的例子,透過前面一系列的實作後,可以挑戰看看如何實現對應的測試來驗證前端的行為是否正常。