← 上一章:購物車 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
說明:
- 因為
Cart.from_hash
是類別方法,所以在定義方法的時候加上了self.
。 - 在
self.from_hash
方法中,不管傳進來的 Hash 是空的還是有資料,最終都還是呼叫new
方法產生一個Cart
實體,並且把傳入的 Hash 的內容轉換成CartItem
物件。 - 因此,在
Cart
類別的initialize
方法需要稍做調整,讓它可以接收一個參數,並把參數直接指定給@items
實體變數。
這樣測試應該就可以通過了!YES!
至於最後那個「特別活動可搭配折扣」的測試及實作,就留給大家練習看看囉。
小結
請記得,TDD(Test-Driven Development)的重點在於「Development」而不在於「Test」,它是一種「測試先行」的「開發方法」。使用 TDD 方式進行開發一開始一定會有不小的阻力,畢竟跟平常的開發習慣不同,但逐漸習慣後會開始嘗到甜頭,並慢慢建立自信。甚到當你熟悉這個流程之後,沒先寫測試就開始實作反而會覺得晚上睡不著覺了。
Comments