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

整理前端實作 - Cucumber 的文件測試法

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

在開始實作後端之前,我們先將原本都集中在 src/App.vue 的程式碼整理一下。這個處理也可以在開發的過程中逐步重構,可以根據現況調整。

商品資料

從之前的實作來看,我們大致上可以切分出 CartProduct 兩個元件,然而這兩個元件會互相使用到對方的資料,因此我們需要有一個保存共同資料的實作,這邊我們借助 Pinia 這個套件來處理這個問題。

1yarn add pinia

如果 Vue 版本低於 3.3.4 可能會發生問題,可以用 yarn upgrade vue 來更新到較新的版本。

再依照文件的 Getting Started 將 Pinia 設定好後,我們就可以加入 src/stores/product.ts 來維護我們的資料狀態。

 1import { defineStore } from 'pinia'
 2import { ref } from 'vue'
 3import type { Ref } from 'vue'
 4import Product from '../entities/product'
 5
 6export const useProductStore = defineStore('product', () => {
 7  const items: Ref<Product[]> = ref([])
 8  const refresh = (newItems: Product[]) => items.value = [...newItems]
 9  const find = (id: number | string) => items.value.find(item => item.id === Number(id))
10
11  return {
12    items,
13    refresh,
14    find,
15  }
16})

對我們來說,需要一個 items 的參考紀錄目前顯示出來的商品資訊,同時還需要提供 refresh() 方法來更新商品列表,以及 find 來提供給 Cart 元件計算購物車內的總價。

因為使用 TypeScript 的關係,我們需要明確的定義 ref 對應的資料類型,因此另外加入了 src/entities/product.ts 來對商品資料進行定義。

1export default interface Product {
2  id: number
3  name: string
4  price: number
5}

定義好 Store 之後,我們就可以調整 src/App.vue 的實作,將商品資料獨立處理。

 1<template>
 2  <!--  -->
 3  <div>
 4	  <div v-for="product in product.items" :key="product.id" :data-testid="'product-' + props.id">
 5	    {{ product.name }}
 6	    <button @click="onAddToCart(product.id)">加入購物車</button>
 7	    <button @click="onRemoveFromCart(product.id)" v-if="isInCart(product.id)">移出購物車</button>
 8	  </div>
 9  </div>
10</template>
11
12<script setup lang="ts">
13// ...
14import { useProductStore } from './stores/product'
15
16const products = useProductStore()
17fetch('/api/products')
18  .then(res => res.json())
19  .then(data => products.refresh(data))
20
21// ...
22</script>

接下來我們會依序的替換掉這些實作,直到將每個物件都分離清楚為止。

抓取資料的實作其實也可以實作在 Store 裡面,這樣就會構成一個類似於 Repository 的實現,然而這不在現階段的討論範圍,因此暫時不討論。

購物車資料

購物車的部分也採取相同的方式,我們在 src/stores/cart.ts 定義一個 Store 來保存狀態,並且把相關的行為封裝起來。

 1import { defineStore } from 'pinia'
 2import { ref, computed } from 'vue'
 3import type { Ref } from 'vue'
 4
 5export const useCartStore = defineStore('cart', () => {
 6  const items: Ref<Record<number, number>> = ref({})
 7  const hasItem = (id: number) => items.value[id] && items.value[id] > 0
 8  const add = (id: number) => {
 9    items.value[id] = items.value[id] || 0
10    items.value[id] += 1
11  }
12  const remove = (id: number) => {
13    if(!hasItem(id)) {
14      return
15    }
16
17    items.value[id] -= 1
18  }
19  const clear = () => {
20    for(let id in items.value) {
21      delete items.value[Number(id)]
22    }
23  }
24  const totalItems = computed(() => Object.entries(items.value).reduce((sum, [_id, curr]) => sum + curr, 0))
25
26  return {
27    items,
28    hasItem,
29    add,
30    remove,
31    clear,
32    totalItems,
33  }
34})

基本上跟原本 src/App.ts 的實作沒有太大的差異,將大部分的實作移動到裡面後,還有一個 subtotal 是沒有放進來的。

這是因為 subtotal 同時參考了 ProductCart 兩種來源,在處理上我們就需要思考一些問題。

  • Cart 裡面放的是 Product 嗎?
  • Cart 該是 Order + Order Item 的組合嗎?

在目前的設計上,購物車的資料是參考商品的(也比較合理)那麼我們只保存 id 也算合理,因為計算總價的時後是必須參考當下商品的金額為主。

在這樣的前提之下,我們應該是在元件中取出商品資料來計算,而不是統一由 Store 來處理這件事情,那麼 subtotal 的處理自然不會放在這裡。

基本上 Cart 相關的處理也都圍繞在這部分,我們接下來就可以拆分出 src/components/Cart.vue 來抽離原本實作在 src/App.vue 中的邏輯。

 1<template>
 2  <div class="cart">
 3    <div v-if="hadCheckout">{{ checkoutResult }}</div>
 4    <div data-testid="cart-amount">一共 {{ cart.totalItems }} 項商品</div>
 5    <div data-testid="cart-subtotal">總金額為 ${{ subtotal }}</div>
 6    <button @click="onCheckout">結帳</button>
 7  </div>
 8</template>
 9
10<script setup lang="ts">
11import { ref, reactive, computed } from 'vue'
12import { useProductStore } from '../stores/product'
13import { useCartStore } from '../stores/cart'
14
15const products = useProductStore()
16const cart = useCartStore()
17
18const subtotal = computed(() => Object.entries(cart.items).reduce((prev, [id, count]) => prev + products.find(id)?.price * count, 0))
19
20const checkoutResult = ref(null)
21const onCheckout = () => fetch('/api/checkout', {
22  method: "POST",
23  headers: {
24    "Content-Type": "application/json",
25  },
26  body: JSON.stringify(cart)
27})
28  .then(res => {
29    if(res.ok) {
30      for(let id in cart) {
31        delete cart[id]
32      }
33    }
34
35    return res
36  })
37  .then(res => res.json())
38  .then(data => checkoutResult.value = data.text)
39const hadCheckout = computed(() => checkoutResult.value !== null)
40</script>

最後再到 src/App.vue 抽離掉購物車相關的實作,並且替換成元件即可。

 1<template>
 2  <Cart />
 3  <div>
 4    <div v-for="product in products.items" :key="product.id" :data-testid="'product-' + product.id">
 5      {{ product.name }}
 6      <button @click="cart.add(product.id)">加入購物車</button>
 7      <button @click="cart.remove(product.id)" v-if="cart.hasItem(product.id)">移出購物車</button>
 8    </div>
 9  </div>
10</template>
11
12<script setup lang="ts">
13import { ref, reactive, computed } from 'vue'
14import { useProductStore } from './stores/product'
15import { useCartStore } from './stores/cart'
16import Cart from './components/Cart.vue'
17
18const products = useProductStore()
19const cart = useCartStore()
20
21fetch('/api/products')
22  .then(res => res.json())
23  .then(data => products.refresh(data))
24</script>

同樣的道理,因為商品所依賴的 Cart 也有對應的 Store 我們還可以繼續拆分成 src/components/Product.vue

 1<template>
 2  <div :data-testid="'product-' + props.id">
 3    {{ props.name }}
 4    <button @click="cart.add(props.id)">加入購物車</button>
 5    <button @click="cart.remove(props.id)" v-if="cart.hasItem(props.id)">移出購物車</button>
 6  </div>
 7</template>
 8
 9<script setup lang="ts">
10import { useCartStore } from '../stores/cart'
11
12const props = defineProps<{
13  id: number,
14  name: string,
15}>()
16const cart = useCartStore()
17</script>

最後再次清理 src/App.vue

 1<template>
 2  <Cart />
 3  <div>
 4    <Product v-for="product in products.items" :key="product.id" :id="product.id" :name="product.name" />
 5  </div>
 6</template>
 7
 8<script setup lang="ts">
 9import { ref, reactive, computed } from 'vue'
10import { useProductStore } from './stores/product'
11import { useCartStore } from './stores/cart'
12import Cart from './components/Cart.vue'
13import Product from './components/Product.vue'
14
15const products = useProductStore()
16
17fetch('/api/products')
18  .then(res => res.json())
19  .then(data => products.refresh(data))
20</script>

這樣就完成階段性的重構,讓原本混雜再一起的邏輯有了一定程度的拆分,在後續的擴充過程中再依照情況判斷需要做的修改即可。

API

在原本的實作中,我們呼叫 API 的處理是散落在不同並不好管理,因為搭配後端進行測試時可能會有需要另外指定不同的路徑,因此我們還需要統整一下 API 端點。

加入 src/api.ts 來統一定義 API 呼叫的實作,可以使用像是 axios 這類套件來處理,因為我們的實作相對簡單,就直接使用 fetch 來實作。

 1export const fetchProducts = async () => await fetch('/api/products').then(res => res.json())
 2export const checkout = async (items: Record<number, number>) => {
 3  const res = await fetch('/api/checkout', {
 4    method: "POST",
 5    headers: {
 6      "Content-Type": "application/json",
 7    },
 8    body: JSON.stringify(items)
 9  })
10  const data = await res.json()
11
12  return [res.ok, data]
13}

我們把現有的兩個 API 呼叫都搬移到裡面,並且做了一些調整,讓回傳的內容是統一的。這樣在使用時就可以預期一定會拿到處理好的 JSON 物件而非字串。

src/App.vue 更新,改為採用統一定義的介面呼叫。

1<script setup lang="ts">
2import { useProductStore } from './stores/product'
3import { fetchProducts } from './api'
4import Cart from './components/Cart.vue'
5import Product from './components/Product.vue'
6
7const products = useProductStore()
8fetchProducts().then(data => products.refresh(data))
9</script>

最後針對 src/components/Cart.vue 也做相同的處理即可。

 1<script setup lang="ts">
 2// ...
 3import { checkout } from '../api'
 4
 5// ...
 6
 7const checkoutResult = ref(null)
 8const onCheckout = () =>
 9  checkout(cart.items)
10  .then(([ok, data]) => {
11    if(ok) {
12      cart.clear()
13    }
14
15    return data
16  })
17  .then(data => checkoutResult.value = data.text)
18const hadCheckout = computed(() => checkoutResult.value !== null)
19</script>

到這邊我們的前端實作基本上就告一段落,接下來會在後端實作一個跟資料庫實際整合的版本,並且讓測試可以直接呼叫來進階成實際的整合測試。