← 上一章:購物車 Part 1 下一章:購物車 Part 3 →

購物車 Part 2

接續前一個章節,繼續把後續的功能以 TDD 的方式完成。

本章節程式碼可於 GitHub 上取得 https://github.com/kaochenlong/shopping_mall

先寫測試,再寫程式

測試 Step 4

在開始下一個測試之前,先看一下這個測試:

require 'rails_helper'

RSpec.describe Cart, type: :model do
  describe "購物車基本功能" do
    # ...[略]...
    it "每個 Cart Item 都可以計算它自己的金額(小計)" do
    end

    # ...[略]...
  end
end

這個看起來是跟 CartItem 比較有關,雖然要全部寫在同一個 spec 檔裡面也不是不行,但隨著功能越來越多,測試程式碼也會越來越多,建議另外開一個專屬的 spec 來做這件事。一樣使用產生器來產生 spec 檔案:

$ bin/rails g rspec:model CartItem
Running via Spring preloader in process 96855
  create  spec/models/cart_item_spec.rb
  invoke  factory_girl
  create    spec/factories/cart_items.rb

然後把 Cart Item 相關的測試移過去:

require 'rails_helper'

RSpec.describe CartItem, type: :model do
  it "每個 Cart Item 都可以計算它自己的金額(小計)" do
    p1 = Product.create(title:"七龍珠", price: 80)      # 建立商品 1
    p2 = Product.create(title:"冒險野郎", price: 200)   # 建立商品 2

    cart = Cart.new
    3.times { cart.add_item(p1.id) }                   # 加 3 次商品 1
    4.times { cart.add_item(p2.id) }                   # 加 4 次商品 2
    2.times { cart.add_item(p1.id) }                   # 再加 2 次商品 1

    expect(cart.items.first.price).to be 400           # 第 1 條 cart item 的價錢應該是 400 塊
    expect(cart.items.second.price).to be 800          # 第 2 條 cart item 應該是 800 塊
  end
end

在測試裡,期待 CartItem 本身可以計算自己這個 item 的價錢的能力。這時候跑測試,自然是一定會失敗的…

實作 Step 4

class CartItem
  attr_reader :product_id, :quantity

  def initialize(product_id, quantity = 1)
    @product_id = product_id
    @quantity = quantity
  end

  def increment(n = 1)
    @quantity += n
  end

  def product
    Product.find_by(id: product_id)
  end

  def price
    product.price * quantity
  end
end

因為目前每個 item 本身都可以知道對應到的商品以及數量,所以只要一行:

product.price * quantity

就可以算出這個 item 的價錢了。

測試 Step 5

既然每個 CartItem 都可以自己算錢,接下來要讓整台購物車也能算錢就會不太難做了。測試如下:

require 'rails_helper'

RSpec.describe Cart, type: :model do
  describe "購物車基本功能" do
    #...[略]...

    it "可以計算整台購物車的總消費金額" do
      cart = Cart.new
      p1 = Product.create(title:"七龍珠", price: 80)      # 建立商品 1
      p2 = Product.create(title:"冒險野郎", price: 200)   # 建立商品 2

      3.times {
        cart.add_item(p1.id)
        cart.add_item(p2.id)
      }

      expect(cart.total_price).to be 840
    end
  end

  # ...[略]...
end

商品 1 跟商品 2 各買了 3 份,整台購物車的 total_price 應該是 840 元。

實作 Step 5

class Cart
  attr_reader :items

  def initialize
    @items = []
  end

  def add_item(product_id)
    found_item = items.find { |item| item.product_id == product_id }

    if found_item
      found_item.increment
    else
      @items << CartItem.new(product_id)
    end
  end

  def empty?
    items.empty?
  end

  def total_price
    items.reduce(0) { |sum, item| sum + item.price }
  end
end

Cart 類別加上了 total_price 方法,並且用 Ruby 內建的 reduce 方法來計算所有 item 的價錢,這樣測試應該就可以通過了。

測試 Step 6

基本功能做完了,接下來要做的是比較複雜一點點的功能。因為預計會使用 Session 存購物車的資料,所以會需要把購物車物件轉換成 Hash 格式:

require 'rails_helper'

RSpec.describe Cart, type: :model do
  # ...[略]...

  describe "購物車進階功能" do
    it "可以將購物車內容轉換成 Hash,存到 Session 裡" do
      cart = Cart.new
      3.times { cart.add_item(2) }   # 新增商品 id 2
      4.times { cart.add_item(5) }   # 新增商品 id 5

      expect(cart.serialize).to eq session_hash
    end

    it "可以把 Session 的內容(Hash 格式),還原成購物車的內容" do
    end
  end

  private
  def session_hash
    {
      "items" => [
        {"product_id" => 2, "quantity" => 3},
        {"product_id" => 5, "quantity" => 4}
      ]
    }
  end
end

在這個測試,我手動把商品 id 2 以及 5 透過 add_item 方法丟到購物車裡,然後期待購物車的 serialize 方法可以回傳一個格式正確的 Hash…

實作 Step 6

為了符合預期的規格,在這裡我定義了一個 serialize 的方法,想辦法讓它回傳期望的 Hash 格式:

class Cart
  # ...[略]...

  def serialize
    all_items = items.map { |item|
      { "product_id" => item.product_id, "quantity" => item.quantity}
    }

    { "items" => all_items }
  end
end

要把物件收集成一個陣列雖然常會使用 .each 方法,但使用 .map 方法寫起來會更漂亮一點。如果沒打錯字的話,這個測試應該可以順利通過。

測試 Step 7

可以由購物車物件轉成 Hash,接下來是要可以反向的把 Hash 轉成購物車:

require 'rails_helper'

RSpec.describe Cart, type: :model do
  describe "購物車基本功能" do
    # ...[略]...
  end

  describe "購物車進階功能" do
    it "可以將購物車內容轉換成 Hash,存到 Session 裡" do
      # ...[略]...
    end

    it "可以把 Session 的內容(Hash 格式),還原成購物車的內容" do
      cart = Cart.from_hash(session_hash)

      expect(cart.items.first.product_id).to be 2
      expect(cart.items.first.quantity).to be 3
      expect(cart.items.second.product_id).to be 5
      expect(cart.items.second.quantity).to be 4
    end
  end

  private
  def session_hash
    # ...[略]...
  end
end

期待購物車有個類別方法 from_hash,可以接收一個 Hash 轉回購物車物件,並且期待轉回來的商品跟數量都是正確的…

實作 Step 7

這個實作可能會比前面幾個要來得複雜一點:

class Cart
  attr_reader :items

  def initialize(items = [])
    @items = items
  end

  #...[略]...

  def self.from_hash(hash)
    if hash.nil?
      new []
    else
      new hash["items"].map { |item_hash|
        CartItem.new(item_hash["product_id"], item_hash["quantity"])
      }
    end
  end
end

說明:

  1. 因為 Cart.from_hash 是類別方法,所以在定義方法的時候加上了 self.
  2. self.from_hash 方法中,不管傳進來的 Hash 是空的還是有資料,最終都還是呼叫 new 方法產生一個 Cart 實體,並且把傳入的 Hash 的內容轉換成 CartItem 物件。
  3. 因此,在 Cart 類別的 initialize 方法需要稍做調整,讓它可以接收一個參數,並把參數直接指定給 @items 實體變數。

這樣測試應該就可以通過了!YES!

至於最後那個「特別活動可搭配折扣」的測試及實作,就留給大家練習看看囉。

小結

請記得,TDD(Test-Driven Development)的重點在於「Development」而不在於「Test」,它是一種「測試先行」的「開發方法」。使用 TDD 方式進行開發一開始一定會有不小的阻力,畢竟跟平常的開發習慣不同,但逐漸習慣後會開始嘗到甜頭,並慢慢建立自信。甚到當你熟悉這個流程之後,沒先寫測試就開始實作反而會覺得晚上睡不著覺了。

← 上一章:購物車 Part 1 下一章:購物車 Part 3 →

Comments