---
title: "結帳處理 - Cucumber 的文件測試法"
date: 2024-04-26T00:00:00+08:00
publishDate: 2024-04-26T00:00:00+08:00
lastmod: 2023-12-06T15:50:15+08:00
tags: ["Cucumber","教學","測試","後端","Ruby","Grape","ActiveRecord"]
series: "test-with-cucumber"
toc: true
permalink: "https://blog.aotoki.me/posts/2024/04/26/test-with-cucumber-process-checkout/"
language: "zh-tw"
---


我們現在處理好保存狀態的機制，目前還剩下 `POST /api/checkout`  的實作還沒加入到裡面，除此之外每次開啟前端時也無法看到最新的購物車狀態，我們要來將這些情境處理到可用的情形。

<!--more-->

## 結帳機制{#checkout}

我們目標的結帳機制是實作「清空購物車」來實現目標，並且在購物車沒有商品或者不存在時顯示錯誤，因此實作上並不困難。

先針對結帳的行為加入對應的 Cucumber 測試，開啟 `features/cart.feature`。

```gherkin
#language:zh-TW
# ...
場景: 購物車沒有商品時，當我嘗試結帳會看到「結帳失敗」
    當 進行結帳
    那麼 我會看到 JSON 物件包含 "text" 為 "結帳失敗" 的文字

  場景: 購物車有一項商品時，當我嘗試結帳會看到「結帳成功」
    當 把商品編號 1 加入購物車
    並且  進行結帳
    那麼 我會看到 JSON 物件包含 "text" 為 "結帳成功" 的文字
```

因為這些是新的步驟，修改 `features/step_definitions/common.rb` 增加新的步驟定義

```ruby
# ...
When('進行結帳') do
  post '/api/checkout'
end

Then('我會看到 JSON 物件包含 {string} 為 {string} 的文字') do |key, value|
  json = JSON.parse(last_response.body)

  expect(json).to include(key => value)
end
```

完成測試的實作後，修改 `app.rb` 加入結帳的邏輯。

```ruby
# ...
module Shop
  class API < Grape::API
    CHECKOUT_SUCCESS = { text: '結帳成功' }.freeze
    CHECKOUT_FAILED = { text: '結帳失敗' }.freeze
    # ...
    namespace :api do
      # ...
      post '/checkout' do
        @cart = Cart.find(1)
        next CHECKOUT_FAILED if @cart.empty?

        @cart.destroy
        CHECKOUT_SUCCESS
      rescue ActiveRecord::RecordNotFound
        CHECKOUT_FAILED
      end
    end
  end
end
```

因為我們沒有要串接金流或者進行其他的處理，只需要呼叫 `#destory` 方法即可，然而在預設的行為下 ActivRecord 不一定會幫我們移除 `CartItem` 的內容，因此我們還需要修改 `Cart` 的實作，開啟 `models/cart.rb` 進行調整。

```ruby
class Cart < ActiveRecord::Base
  has_many :items, class_name: 'CartItem', dependent: :destroy, autosave: true

  # ...

  def empty?
    items.sum(&:amount) == 0
  end
end
```

我們對 `has_many` 增加了 `dependent: :destroy` 選項，這樣在移除購物車的時候就會自然的將 `CartItem` 一起清理掉，除此之外我們在購物車品項都為 `0` 的時候要拒絕結帳，因此還需要額外實作一個 `#empty?` 方法並且統計品項的數量，用來在不正確的數量時回傳錯誤訊息。

最後運行 `bundle exec cucumber` 確認實作沒有破壞原有的行為，也能夠正常通過 Cucumber 文件所撰寫的要求。

## 購物車資訊{#cart-info}

解決了結帳的行為後，我們需要讓原本的購物車可以重現後端保存的狀態，不然可能會在不知情的狀況下對錯誤的商品進行結帳，因此我們還需要加入 `GET /api/cart` 來回傳最後的狀態，開啟 `app.rb` 加入實作。

```ruby
module Shop
  class API < Grape::API
    CHECKOUT_SUCCESS = { text: '結帳成功' }.freeze
    CHECKOUT_FAILED = { text: '結帳失敗' }.freeze
    # ...
    namespace :api do
      # ...
      get '/cart' do
        Cart.find_or_initialize_by(id: 1).status
      end

      # ...
      post '/cart' do
        # ...
        @cart.save
        @cart.status
      end
    end
  end
end
```

因為我們都需要將 `{ 1 => 1 }` 這樣的 Hash 回傳，因此統一封裝成一個 `#status` 方法來反應這個要求，修改 `models/cart.rb` 擴充行為。

```ruby
class Cart < ActiveRecord::Base
  # ...

  def status
    items.map do |item|
      [item.product_id, item.amount]
    end.to_h
  end
end
```

接著要修改前端的實作，讓購物車載入時就能夠讀取到上一次的狀態，先修改 `src/stores/cart.ts` 讓我們可以使用 `refresh()` 方法刷新購物車。

```ts
// ...

export const useCartStore = defineStore('cart', () => {
  // ...

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

接著修改 `src/api.ts` 加入新的 API 呼叫。

```ts
// ...
export const fetchProducts = async () => await fetch(`${API_SERVER}/api/products`).then(res => res.json())
export const getCart = async() => await fetch(`${API_SERVER}/api/cart`).then(res => res.json())
// ...
```

最後到 `src/components/Cart.vue` 裡面將 `getCart()` 呼叫來載入新的購物車資訊。

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

// ...
const cart = useCartStore()

// ...
getCart().then(items => cart.refresh(items))
</script>
```

這樣在開啟前端預覽時就會正常，然而我們的測試並沒有區分是 `GET /api/cart` 還是 `POST /api/cart` 因此會失敗，我們還需要修改 `features/support/world.ts` 裡面對 `/api/cart` 的定義，在 GET 狀態下回傳空的購物車。

```ts
// ...
Before(async function() {
  const state: Record<number, number> = {}

  await this.page.route('/api/cart', async (route: playwright.Route) => {
    if(route.request().method() === 'GET') {
      return await route.fulfill({ json: {} })
    }

    const data = await route.request().postDataJSON()

    state[Number(data.id)] = state[data.id] || 0
    state[Number(data.id)] += Number(data.amount)

    await route.fulfill({ json: state })
  })
})
```

最後運行 `yarn run cucumber-js` 就可以看到測試順利通過。

> 這裡並沒有在舉出「購物車已有物品」的例子，透過前面一系列的實作後，可以挑戰看看如何實現對應的測試來驗證前端的行為是否正常。

