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

更新購物車 API - Cucumber 的文件測試法

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

我們目前已經將商品資料的基礎 API 實現,接下來讓購物車新增、移除商品的行為也從前端轉移到後端,這段我們會需要加入相對多的調整來實現。

後端實作

在後端的部分,我們要加入一個 POST /api/cart 的端點,讓使用者可以透過商品編號增加或者減少品項,先將 Cucumber 的測試文件加入,用以描述增加或者減少的情境。

 1#language:zh-TW
 2功能: 購物車
 3  場景: 當我將商品 Ruby 秘笈放入購物車,會看到 1 的數量
 4    # 商品資料目前寫死
 5 把商品編號 1 加入購物車
 6    那麼 我會看到 JSON 物件包含 "1" 為 1 的整數
 7
 8  場景: 當我將商品 Ruby 秘笈移出購物車,會看到 0 的數量
 9    # 商品資料目前寫死
10 把商品編號 1 加入購物車
11    並且 把商品編號 1 移出購物車
12    那麼 我會看到 JSON 物件包含 "1" 為 0 的整數

在這邊我們會注意到,前述使用「看到 JSON 物件」這樣的方式會缺少泛用性,在 RSpec 是相對容易組合出測試的斷言(Assetion)然而在 Cucumber 裡面,我們應該逐步往「看到商品 RSpec 秘笈數量為 1」這類呈現調整,會更好閱讀和處理測試。

這樣的方式不可避免會出現一定程度的重複程式碼,然而考量到這是針對特定行為的操作,重複也許不一定是表面上看到的樣子。

接下來在 features/step_definitions/common.rb 繼續補完步驟定義,讓我們可以驗證這個行為。

 1# ...
 2When('把商品編號 {int} 加入購物車') do |product_id|
 3  post '/api/cart', { id: product_id }
 4end
 5
 6When('把商品編號 {int} 移出購物車') do |product_id|
 7  post '/api/cart', { id: product_id, amount: -1 }
 8end
 9
10# ...
11
12Then('我會看到 JSON 物件包含 {string} 為 {int} 的整數') do |key, amount|
13  json = JSON.parse(last_response.body)
14
15  expect(json).to include(key => amount)
16end

回到 app.rb 中,我們用最簡單的方式來實現增減的計數。

 1module Shop
 2  # :nodoc:
 3  class API < Grape::API
 4    # ...
 5    namespace 'api' do
 6      # ...
 7      params do
 8        requires :id, type: Integer, desc: 'Product ID'
 9        optional :amount, type: Integer, default: 1
10      end
11      post '/cart' do
12        @@items ||= {}
13        @@items[params[:id]] ||= 0
14        @@items[params[:id]] += params[:amount]
15        @@items
16      end
17    end
18  end
19end

我們先假設在沒有資料庫的狀態下,要記錄購物車狀態來進行設計,可以直接使用 Ruby 的 Class Variable 方式記錄下來,等到資料庫相關的實現後,在改為放到資料庫之中。

因為我們的狀態是直接被記錄下來的,在運行 Cucumber 的時候會被下一個測試繼承,因此我們需要在 feature/support/env.rb 中加入一段重設的實作。

1# ...
2Before do
3  class Shop::API
4    @@items = nil
5  end
6end

在使用資料庫的狀態時,我們會使用像是 DatabaseCleaner 這類 Gem 來處理這件事情,在實現後端測試時盡量每次都重設資料來確保環境沒有受到干擾。

最後運行 bundle exec cucumber 確認沒有發生錯誤,就可以繼續調整前端的對應。

前端實作

原本我們的前端是透過 ref({}) 的方式記憶一個 Hash 物件對應商品資料,現在後端在操作後就會提供這個資訊,我們可以修改 src/stores/cart.ts 來實現這件事情。

 1// ...
 2import { updateCart } from '../api'
 3
 4export const useCartStore = defineStore('cart', () => {
 5  const add = async (id: number) => {
 6    const res = await updateCart({ id, amount: 1 })
 7    refresh(res)
 8  }
 9  const remove = async (id: number) => {
10    if(!hasItem(id)) {
11      return
12    }
13
14    const res = await updateCart({ id, amount: -1 })
15    refresh(res)
16  }
17  const refresh = (newItems: Record<number, number>) => {
18    clear()
19    for(let id in newItems) {
20      items.value[id] = newItems[id]
21    }
22  }
23  // ...
24})

這次的修改我們將原本的 add()remove() 方法改為非同步的行為,並且直接呼叫 updateCart() API 來取得修改後的購物車內容,並且增加一個 refresh() 方法清空原本的購物車,然後放入新的品項進去。

這次我們將呼叫 API 的行為實作到了 Store 中,跟前面在前端部分實作結帳時使用了不同的方式。大多數前端專案會將 API 呼叫放到 Store 裡面,這是因為 Store 扮演了 Data Access Object 的角色,然而如果有需要支援不同的資料來源,就會需要思考如何切分 Store 的實現不同資料來源的行為。

因為我們增加了 updateCart() 這個 API,要繼續修改 src/api.ts 把新的 API 加入到裡面。

1export const updateCart = async ({ id, amount }: { id: Number, amount: Number }) => {
2  return await fetch(`${API_SERVER}/api/cart`, {
3    method: "POST",
4    headers: {
5      "Content-Type": "application/json",
6    },
7    body: JSON.stringify({ id, amount })
8  }).then(res => res.json())
9}

同樣的,測試現在會因為我們沒有將後端的伺服器啟用,還是以 Mock(模擬)的方式來實現,我們需要在 features/support/world.ts 再透過 Playwright 模擬路由的機制增加一個假的 API 來實現。

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

實際上是把我們在 src/cart/store.ts 的實作搬到這裡來實現,也對應了後端的行為邏輯。假設未來需要模擬特定狀況無法調整後端,也可以利用這個方式來做出一些特定情形的模擬。

完成之後用 yarn run cucumber-js 來確認前端的實作沒有被破壞,同時也可以前後端的伺服器,我們現在重新整理後也可以再調整品項看到購物車的紀錄被保持下來。

有興趣的話可以嘗試增加一個 GET /api/cart 端點來在頁面載入時恢復購物車內容。