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

重構與移出購物車 - Cucumber 的文件測試法

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

重構

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

 1<template>
 2  <div data-testid="cart">一共 {{ totalItems }} 項商品</div>
 3  <div>
 4    <div v-for="product in products" :key="product.id" :data-testid="'product-' + product.id">
 5      {{ product.name }}
 6      <button @click="onAddToCart(product.id)">加入購物車</button>
 7    </div>
 8  </div>
 9</template>
10<!--  -->

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

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

 1<!--  -->
 2<script setup lang="ts">
 3import { reactive, computed } from 'vue'
 4
 5const products = {
 6  1: { id: 1, name: 'Ruby 秘笈' },
 7  2: { id: 2, name: 'RSpec 秘笈' }
 8}
 9
10const cart = reactive({})
11const onAddToCart = (id) => {
12  cart[id] = cart[id] || 0
13  cart[id] += 1
14}
15
16const totalItems = computed(() => Object.values(cart).reduce((prev, curr) => prev + curr, 0))
17</script>

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

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

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

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

移出購物車

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

1#language:zh-TW
2功能: 商品列表
3  # ...
4  場景: 蒼時可以把已經在購物車的 Ruby 秘笈移出,並看到商品數為 1
5 把 "Ruby 秘笈" 加入購物車
6    並且 把 "RSpec 秘笈" 加入購物車
7    並且 把 "Ruby 秘笈" 移出購物車
8    那麼 可以看見購物車商品數為 1

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

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

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

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

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

 1<template>
 2  <!--  -->
 3      {{ product.name }}
 4      <button @click="onAddToCart(product.id)">加入購物車</button>
 5      <button @click="onRemoveFromCart(product.id)" v-if="isInCart(product.id)">移出購物車</button>
 6    </div>
 7  <!--  -->
 8</template>
 9
10<!--  -->

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

 1<!--  -->
 2<script setup lang="ts">
 3import { reactive, computed } from 'vue'
 4
 5// ...
 6const onRemoveFromCart = (id) => {
 7  if(!isInCart(id)) {
 8    return
 9  }
10
11  cart[id] -= 1
12}
13const isInCart = (id) => cart[id] && cart[id] > 0
14// ...
15</script>

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

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