---
title: "結帳與結果 - Cucumber 的文件測試法"
date: 2024-03-08T00:00:00+08:00
publishDate: 2024-03-08T00:00:00+08:00
lastmod: 2023-12-06T15:50:15+08:00
tags: ["Cucumber","教學","測試","前端","Vite","Vue","Playwright"]
series: "test-with-cucumber"
toc: true
permalink: "https://blog.aotoki.me/posts/2024/03/08/test-with-cucumber-checkout-and-result/"
language: "zh-tw"
---


透過 Playwright 的 Mock API 機制，我們已經有了非常簡單的後端整合機制，接下來我們要繼續加上結帳的按鈕並且模擬成功跟失敗的兩種情境，讓我們在不依賴後端的狀況下完成一個非常基礎的購物車前端實現。

<!--more-->

## 新增規格{#extend-spec}

在結帳的機制上，我們預期如果有商品就回傳「結帳成功」反之則會得到「結帳失敗」的訊息，因此在原本的 `features/product.feature` 文件中繼續增加新的描述。

```gherkin
#language:zh-TW
# ...
  場景: 蒼時購物車有物品時，可以進行結帳並且看到成功的結果
    當 把 "Ruby 秘笈" 加入購物車
    並且 進行結帳
    那麼 可以看見購物車總金額為 "$0"
    並且 可以看見購物車商品數為 0
    並且 可以看見結帳結果為 "結帳成功"


  場景: 蒼時購物車沒有物品時，可以進行結帳並且看到失敗的結果
    當 進行結帳
    那麼 可以看見購物車總金額為 "$0"
    並且 可以看見購物車商品數為 0
    並且 可以看見結帳結果為 "結帳失敗"
```

這兩條描述呈現了成功跟失敗的情況外，也要求送出後會將購物車清空的行為，因此我們需要再實作時也將這點考慮進去。

因為增加了兩個新的步驟，繼續擴充 `features/step_definitions/common.ts` 來讓 Cucumber 知道該如何處理這兩種情況。

```ts
// ...
When('進行結帳', async function(this: World) {
  await this.page.getByRole('button', { name: '結帳' }).click()
})

// ...
Then('可以看見結帳結果為 {string}', async function(this: World, result: string) {
  await expect(this.page.getByText(result)).toBeVisible()
})
```

跟前面的行為相比，因為相對的單純，我們可以直接尋找「結帳」的按鈕點選，以及確認頁面上是否存在「結帳成功」或者「結帳失敗」來做驗證。

> 如果頁面非常複雜，明確的指定或者縮小範圍還是會讓測試跑得更快一些，尤其時這些測試都有一個超時的設定，單一步驟處理過久不是一件好事。

## Mock API

跟商品資料不同的地方在於，結帳行為理論上要由後端處理，並且不應該有預期外的行為產生。因此我們不會利用假設（Given）的方式定義，因此直接在 `feature/support/world.ts` 中進行擴充，讓這個路徑可以被呼叫。

```ts
// ...

Before(async function() {
  await this.page.route('/api/checkout', async route => {
    const cartItems = route.request().postDataJSON()
    const json = { text: '結帳成功' }
    const isValid = Object.values(cartItems).length > 0

    if(!isValid) {
      json.text = '結帳失敗'
    }

    await route.fulfill({
      status: isValid ? 200 : 422,
      json
    })
  })
})
```

當我們有實際的後端可以使用時，可以選擇把這段邏輯拆除。在這裡我們從請求中取得 JSON 物件，並且根據傳入的購物車數量決定要回傳成功還是失敗的訊息。

> 在 Playwright API 中，可以呼叫 `route.fetch()` 來實際發出請求，並且利用 `route.fulfill()` 來改寫回傳內容，除了直接模擬行為外，也可以善用改寫機制來輔助測試。

## 實作結帳{#implement-checkout}

最後我們將結帳的實作加入到 `src/App.vue` 裡面來完成這個功能，先在原本的購物車區段加入新的按鈕與結果的區塊。

```vue
<template>
  <div class="cart">
    <div v-if="hadCheckout">{{ checkoutResult }}</div>
    <div data-testid="cart-amount">一共 {{ totalItems }} 項商品</div>
    <div data-testid="cart-subtotal">總金額為 ${{ subtotal }}</div>
    <button @click="onCheckout">結帳</button>
  </div>
  <!--more-->
</template>
```

這次增加了 `onCheckout` 方法來送出購物車，以及 `hadCheckout` 和 `checkoutResult` 來對應結帳後的結果，繼續修改 TypeScript 的部分加入對應的實作。

```vue
<script setup lang="ts">
// ...

const checkoutResult = ref(null)
const onCheckout = () => fetch('/api/checkout', {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify(cart)
})
  .then(res => {
    if(res.ok) {
      for(let id in cart) {
        delete cart[id]
      }
    }

    return res
  })
  .then(res => res.json())
  .then(data => checkoutResult.value = data.text)
const hadCheckout = computed(() => checkoutResult.value !== null)
</script>
```

這裡的結果會需要觸發 Vue 重新繪製畫面，因此我們需要用 `ref(null)` 來初始化，並且用 `computed()` 來檢查是否為 `null` 如果已經有 API 的結果回傳，我們就可以將內容顯示出來。

在 `onCheckout` 方法的實作上，我們直接使用 `fetch()` 來對 `/api/checkout` 端點進行呼叫，並且在回傳結果為成功（`Response.ok` 表示狀態碼在 `200` ~ `299` 之間）的時候對購物車進行清空的處理。

因為 `reactive({})` 只對修改物件生效，因此我們無法用 `cart = {}` 這類方式修改，只能呼叫迴圈將所有被紀錄的商品找出，用 `delete cart[id]` 的方式清除掉記憶體的資訊。

最後，回傳是以文字訊息的方式呈現，因此直接將結果放到 `checkoutResult` 中顯示出來即可。

到此為止，一個非常簡單的購物功能就完成，我們需要對現有的實作進行一些調整來支援跟後端的整合處理。

