---
title: "重構與移出購物車 - Cucumber 的文件測試法"
date: 2024-02-23T00:00:00+08:00
publishDate: 2024-02-23T00: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/02/23/test-with-cucumber-refactor-and-remove-from-cart/"
language: "zh-tw"
---


延續現有的「加入購物車」功能，我們要繼續加入「移出購物車」的機制，因為原本的設計只是單純的滿足計數，這次還需要實現實際存在的商品列表來反應現實狀況。

<!--more-->

## 重構{#refactor}

目前我們是將商品寫死，並且直接統計點選「加入購物車」的次數，在不破壞原有測試的前提下，我們要改為動態的顯示商品列表，並且以商品的編號（ID）來統計數量。

```vue
<template>
  <div data-testid="cart">一共 {{ totalItems }} 項商品</div>
  <div>
    <div v-for="product in products" :key="product.id" :data-testid="'product-' + product.id">
      {{ product.name }}
      <button @click="onAddToCart(product.id)">加入購物車</button>
    </div>
  </div>
</template>
<!-- 略 -->
```

首先，我們把原本的 `{{ cart.count }}` 替換成 `{{ totalItems }}` 這個使用 `computed()` 產生的方法，然後將原本的商品列表用 `v-for` 依序載入進來，並且用 `:data-testid="'product-' + product.id"` 來確保測試用標記有被加上去。

繼續修改 TypeScript 的部分，將 `totalItems` 的實現放到裡面，以及修改 `onAddToCart` 為可以接受一個商品編號的版本。

```vue
<!-- 略 -->
<script setup lang="ts">
import { reactive, computed } from 'vue'

const products = {
  1: { id: 1, name: 'Ruby 秘笈' },
  2: { id: 2, name: 'RSpec 秘笈' }
}

const cart = reactive({})
const onAddToCart = (id) => {
  cart[id] = cart[id] || 0
  cart[id] += 1
}

const totalItems = computed(() => Object.values(cart).reduce((prev, curr) => prev + curr, 0))
</script>
```

我們加入了 `products` 來提供商品資訊，並且把原本的 `cart` 從 `reactive({ count: 0 })` 修改為 `reactive({})` 來保存一個 Key-Value 對應的購物車品項資訊。

針對 `onAddToCart()` 的修改，則是從原本的單純增加數量，改為 `cart[id] += 1` 來對指定的品項進行數量上的調整。

最後，我們用 `Object.values(cart)` 取出所有品項的數量，利用 `Array.reduce()` 來加總，這邊要特別注意的是我們必須提供 `Array.reduce(() => ... ,0)` 第二個參數（初始值）來確保購物車為空時，能有一個預設值。

重新運行 `yarn run cucumber-js` 命令，會發現我們順利的通過測試，也完成了一次重構。

## 移出購物車{#remove-from-cart}

現在我們已經有了 `onAddToCart()` 方法可以以商品編號進行對應，那麼要實現 `onRemoveFromCart()` 方法來將商品從購物車移除就容易許多，在這之前先補上新的 Cucumber 功能描述來驗證這件事情。

```gherkin
#language:zh-TW
功能: 商品列表
  # ...
  場景: 蒼時可以把已經在購物車的 Ruby 秘笈移出，並看到商品數為 1
    當 把 "Ruby 秘笈" 加入購物車
    並且 把 "RSpec 秘笈" 加入購物車
    並且 把 "Ruby 秘笈" 移出購物車
    那麼 可以看見購物車商品數為 1
```

因為多了一個「移出購物車」的步驟，我們還需要將新的步驟定義加入到 `features/step_definitions/common.ts` 裡面。

```typescript
// ...
When('把 {string} 移出購物車', async function(this: World, productName: string) {
  const product = await this.page.getByTestId(/product-/).filter({ hasText: productName })
  await product.getByRole('button', { name: '移出購物車' }).click()
})
```

基本上跟加入購物車的實作是一樣的，不過按鈕被替換成移出購物車來進行點選。

> 另一種邏輯是定義一個「對商品 "Ruby 秘笈" 進行 "移出購物車" 的操作」來反應這件事情，就可以動態支援不同的按鈕。

回到 Vue 的部分，我們在加入購物車後面放入移出購物車的按鈕。

```vue
<template>
  <!-- 略 -->
      {{ product.name }}
      <button @click="onAddToCart(product.id)">加入購物車</button>
      <button @click="onRemoveFromCart(product.id)" v-if="isInCart(product.id)">移出購物車</button>
    </div>
  <!-- 略 -->
</template>

<!-- 略 -->
```

為了在沒有任何品項時不會被點擊，我們也另外定義了一個 `isInCart()` 方法用來檢查目前的狀態，假設品項不存在的話就不要渲染出來（如果有效能考量，也可以考慮 `v-show` 的方式）

```vue
<!-- 略 -->
<script setup lang="ts">
import { reactive, computed } from 'vue'

// ...
const onRemoveFromCart = (id) => {
  if(!isInCart(id)) {
    return
  }

  cart[id] -= 1
}
const isInCart = (id) => cart[id] && cart[id] > 0
// ...
</script>
```

最後在 TypeScript 端補上對應的實作，將 `isInCart()` 和 `onRemoveFromCart()` 實作進去，再運行 `yarn run cucumber-js` 就可以順利通過新加入的測試，也完善了新功能。

> 目前的程式碼看起來「不太乾淨」是在預期內的，所以還不需要急著將程式碼進行重構，可以等到下一次修改再少量的調整，因為我們已經有了一定程度的測試保護，在重構上的影響不會對現有功能造成太大的衝擊。

