---
title: "整理前端實作 - Cucumber 的文件測試法"
date: 2024-03-15T00:00:00+08:00
publishDate: 2024-03-15T00: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/15/test-with-cucumber-clean-frontend/"
language: "zh-tw"
---


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

<!--more-->

## 商品資料{#product-data}

從之前的實作來看，我們大致上可以切分出 `Cart` 跟 `Product` 兩個元件，然而這兩個元件會互相使用到對方的資料，因此我們需要有一個保存共同資料的實作，這邊我們借助 [Pinia](https://pinia.vuejs.org/) 這個套件來處理這個問題。

```bash
yarn add pinia
```

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

再依照文件的 [Getting Started](https://pinia.vuejs.org/getting-started.html) 將 Pinia 設定好後，我們就可以加入 `src/stores/product.ts` 來維護我們的資料狀態。

```ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Ref } from 'vue'
import Product from '../entities/product'

export const useProductStore = defineStore('product', () => {
  const items: Ref<Product[]> = ref([])
  const refresh = (newItems: Product[]) => items.value = [...newItems]
  const find = (id: number | string) => items.value.find(item => item.id === Number(id))

  return {
    items,
    refresh,
    find,
  }
})
```

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

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

```ts
export default interface Product {
  id: number
  name: string
  price: number
}
```

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

```vue
<template>
  <!-- 略 -->
  <div>
	  <div v-for="product in product.items" :key="product.id" :data-testid="'product-' + props.id">
	    {{ product.name }}
	    <button @click="onAddToCart(product.id)">加入購物車</button>
	    <button @click="onRemoveFromCart(product.id)" v-if="isInCart(product.id)">移出購物車</button>
	  </div>
  </div>
</template>

<script setup lang="ts">
// ...
import { useProductStore } from './stores/product'

const products = useProductStore()
fetch('/api/products')
  .then(res => res.json())
  .then(data => products.refresh(data))

// ...
</script>
```

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

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

## 購物車資料{#cart-data}

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

```ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Ref } from 'vue'

export const useCartStore = defineStore('cart', () => {
  const items: Ref<Record<number, number>> = ref({})
  const hasItem = (id: number) => items.value[id] && items.value[id] > 0
  const add = (id: number) => {
    items.value[id] = items.value[id] || 0
    items.value[id] += 1
  }
  const remove = (id: number) => {
    if(!hasItem(id)) {
      return
    }

    items.value[id] -= 1
  }
  const clear = () => {
    for(let id in items.value) {
      delete items.value[Number(id)]
    }
  }
  const totalItems = computed(() => Object.entries(items.value).reduce((sum, [_id, curr]) => sum + curr, 0))

  return {
    items,
    hasItem,
    add,
    remove,
    clear,
    totalItems,
  }
})
```

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

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

* Cart 裡面放的是 Product 嗎？
* Cart 該是 Order + Order Item 的組合嗎？

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

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

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

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

<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useProductStore } from '../stores/product'
import { useCartStore } from '../stores/cart'

const products = useProductStore()
const cart = useCartStore()

const subtotal = computed(() => Object.entries(cart.items).reduce((prev, [id, count]) => prev + products.find(id)?.price * count, 0))

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>
```

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

```vue
<template>
  <Cart />
  <div>
    <div v-for="product in products.items" :key="product.id" :data-testid="'product-' + product.id">
      {{ product.name }}
      <button @click="cart.add(product.id)">加入購物車</button>
      <button @click="cart.remove(product.id)" v-if="cart.hasItem(product.id)">移出購物車</button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useProductStore } from './stores/product'
import { useCartStore } from './stores/cart'
import Cart from './components/Cart.vue'

const products = useProductStore()
const cart = useCartStore()

fetch('/api/products')
  .then(res => res.json())
  .then(data => products.refresh(data))
</script>
```

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

```vue
<template>
  <div :data-testid="'product-' + props.id">
    {{ props.name }}
    <button @click="cart.add(props.id)">加入購物車</button>
    <button @click="cart.remove(props.id)" v-if="cart.hasItem(props.id)">移出購物車</button>
  </div>
</template>

<script setup lang="ts">
import { useCartStore } from '../stores/cart'

const props = defineProps<{
  id: number,
  name: string,
}>()
const cart = useCartStore()
</script>
```

最後再次清理 `src/App.vue`。

```vue
<template>
  <Cart />
  <div>
    <Product v-for="product in products.items" :key="product.id" :id="product.id" :name="product.name" />
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useProductStore } from './stores/product'
import { useCartStore } from './stores/cart'
import Cart from './components/Cart.vue'
import Product from './components/Product.vue'

const products = useProductStore()

fetch('/api/products')
  .then(res => res.json())
  .then(data => products.refresh(data))
</script>
```

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

## API

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

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

```ts
export const fetchProducts = async () => await fetch('/api/products').then(res => res.json())
export const checkout = async (items: Record<number, number>) => {
  const res = await fetch('/api/checkout', {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(items)
  })
  const data = await res.json()

  return [res.ok, data]
}
```

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

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

```vue
<script setup lang="ts">
import { useProductStore } from './stores/product'
import { fetchProducts } from './api'
import Cart from './components/Cart.vue'
import Product from './components/Product.vue'

const products = useProductStore()
fetchProducts().then(data => products.refresh(data))
</script>
```

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

```vue
<script setup lang="ts">
// ...
import { checkout } from '../api'

// ...

const checkoutResult = ref(null)
const onCheckout = () =>
  checkout(cart.items)
  .then(([ok, data]) => {
    if(ok) {
      cart.clear()
    }

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

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

