← 上一章:Rails 程式碼整理術(進階) 下一章:購物車 Part 2 →

購物車 Part 1

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

終於進入電子商務網站的實作階段了,接下來就實作一個大部份電子商務網站都用得到的購物車功能吧。

在這個實作範例中:

  1. 將使用 TDD(Test-Driven Development)方式進行開發。
  2. 購物車的內容不會建立資料表,僅先存放在 Session 裡。

關於第 1 點,如果您對 TDD 還不熟悉,可請先參閱第 23 章以及第 24 章的內容;關於第 2 點,有些人會使用資料庫來存放購物車裡的資料,不過我們這裡選擇使用 Session 來存放資料。

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

功能設計

image

說明:

一台購物車(Cart,①)可能會有很多的購買項目(CartItem ②),每個購買項目都有一項商品(Product ③)以及數量(Quantity ④)

基本功能

以下是我們對於購物車的期望:

  1. 可以把商品丟到到購物車裡,然後購物車裡就有東西了。
  2. 如果加了相同種類的商品到購物車裡,購買項目(CartItem)並不會增加,但商品的數量會改變。
  3. 商品可以放到購物車裡,也可以再拿出來。
  4. 每個 Cart Item 都可以計算它自己的金額(小計)。
  5. 可以計算整台購物車的總消費金額。
  6. 特別活動可搭配折扣(例如聖誕節的時候全面打 9 折,或是滿額滿千送百或滿額免運費)。

進階功能

因為購物車將會以 Session 方式儲存,所以:

  1. 可以將購物車內容轉換成 Hash 並存到 Session 裡。
  2. 也可以存放在 Session 的內容(Hash 格式),還原成購物車的內容。

測試環境設定

Gemfile 裡加上需要的 gem:

source 'https://rubygems.org'

# ...[略]...

group :development, :test do
  gem 'byebug', platform: :mri
  gem 'rspec-rails'
  gem 'factory_girl_rails'
  gem 'faker'
end

# ...[略]...

其中,用來寫測試的本體是 rspec-rails,其它像 factory_girl_rails 以及 faker 則是輔助產生測試資料用的 Gem。這些 Gem 因為僅會在開發及測試階段會使用到,所以建議放到 development 以及 test 的 Gem 群組裡。

Gemfile 存檔後別忘了執行 bundle install 指令,確保所有的套件都有安裝成功。

Gem 安裝完成之後,接著照 rspec-rails 的說明頁面,安裝 rspec 到 Rails 專案裡:

$ bin/rails g rspec:install
Running via Spring preloader in process 85482
  create  .rspec
  create  spec
  create  spec/spec_helper.rb
  create  spec/rails_helper.rb

這個指令會建立一個 spec 目錄,並且建立 2 個 Helper 檔案。接著試一下執行 rspec 指令:

$ rspec
No examples found.

Finished in 0.00036 seconds (files took 0.15527 seconds to load)
0 examples, 0 failures

這時候因為都還沒有任何測試,所以這個結果是正常的。另外,因為我們會用 Rspec 取代原本內建的測試,所以原本專案裡的 test 目錄也可移除。

先寫測試,再寫程式

先寫測試

還記得我們在第 24 章介紹 TDD 的時候是自己手動建立 bank_account_spec.rb 這個檔案嗎? 但如果有安裝 rspec-rails,可以使用它送的產生器幫忙做這件事。首先產生一個針對 Cart 這個 Model 的測試:

$ bin/rails g rspec:model Cart
Running via Spring preloader in process 86133
    create  spec/models/cart_spec.rb
    invoke  factory_girl
    create    spec/factories/carts.rb

接著執行測試看看,應該是會失敗(錯誤訊息是「沒有 Cart 這個常數」):

$ rspec
/private/tmp/shopping_mall/spec/models/cart_spec.rb:3:in `<top (required)>': uninitialized constant Cart (NameError)
  from /Users/user/.rvm/gems/ruby-2.4.0/gems/rspec-core-3.5.4/lib/rspec/core/configuration.rb:1435:in `load'
  ...[略]...
  from /Users/user/.rvm/gems/ruby-2.4.0/bin/ruby_executable_hooks:15:in `eval'
  from /Users/user/.rvm/gems/ruby-2.4.0/bin/ruby_executable_hooks:15:in `<main>'

這個錯誤訊息不意外,因為我們根本還沒寫。

建立 Cart Model

首先,大家看到 Model 這個字,就會以為跟資料庫有關,然後就準備用 rails g model Cart... 指令來產生這個檔案了。事實上並不需要的,因為我們這個購物車只需要是一般的 Ruby 類別(PORO, Plain Old Ruby Object)就行了,並不需要資料庫相關功能。請自己手動新增一個 cart.rb 檔案到 app/models 目錄裡:

# 檔案:app/models/cart.rb
class Cart
end

什麼內容還不用寫,也不需要繼承自 Rails 的 ActiveRecord::Base 類別,只要先定義一個 Cart 類別就好。接著執行 rspec 指令,跑一下測試:

$ rspec
*

Pending: (Failures listed here are expected and do not affect your suite's status)

  1) Cart add some examples to (or delete) /private/tmp/shopping_mall/spec/models/cart_spec.rb
     # Not yet implemented
     # ./spec/models/cart_spec.rb:4


Finished in 0.0008 seconds (files took 3.31 seconds to load)
1 example, 0 failures, 1 pending

剛才找不到 Cart 類別的問題就解決了。這個 pending 的訊息是因為在 spec/models/cart_spec.rb 檔案裡有一行 pending,可以先把它刪掉再執行一次就不會有這個警告訊息了。

把規格轉成測試

回到 spec/models/cart_spec.rb 檔案,把我們要測試的內容補上去:

require 'rails_helper'

RSpec.describe Cart, type: :model do
  describe "購物車基本功能" do
    it "可以把商品丟到到購物車裡,然後購物車裡就有東西了"
    it "如果加了相同種類的商品到購物車裡,購買項目(CartItem)並不會增加,但商品的數量會改變"
    it "商品可以放到購物車裡,也可以再拿出來"
    it "每個 Cart Item 都可以計算它自己的金額(小計)"
    it "可以計算整台購物車的總消費金額"
    it "特別活動可能可搭配折扣(例如聖誕節的時候全面打 9 折,或是滿額滿千送百)"
  end

  describe "購物車進階功能" do
    it "可以將購物車內容轉換成 Hash,存到 Session 裡"
    it "可以把 Session 的內容(Hash 格式),還原成購物車的內容"
  end
end

如果忘記為什麼這樣寫,請翻閱第 24 章的內容。用中文寫沒關係,Ruby 本身支援多國語言,測試的重點是把想要測的點清楚寫出來。

測試 Step 1

先從第一個測試開始寫吧:

require 'rails_helper'

RSpec.describe Cart, type: :model do
  describe "購物車基本功能" do
    it "可以把商品丟到到購物車裡,然後購物車裡就有東西了" do
      cart = Cart.new                   # 新增一台購物車
      cart.add_item 1                   # 隨便丟一個東西到購物車裡
      expect(cart.empty?).to be false   # 它應該不是空的
    end
    #...[略]...
  end
end

如上面這段程式碼後面的註解寫的,就是先新增一台購物車、丟個東西給它,然後測試它的狀態。那個 add_item 方法先不用擔心要傳什麼參數給它,就先隨便傳個數字 1 給它也沒關係。這時候執行測試,應該是會失敗的,因為購物車 cart 物件的 add_itemempty? 方法都還沒有寫。

實作 Step 1

回到 Cart 類別加上這兩個方法,想辦法通過測試:

class Cart
  def initialize
    @items = []
  end

  def add_item(product_id)
    @items << product_id
  end

  def empty?
    @items.empty?
  end
end

說明:

  1. initialize 的時候初始化一個空陣列 @items
  2. add_item 的時候,把傳進來的東西往 @items 陣列裡丟。
  3. empty? 方法則是回傳 @items 陣列是不是空的。

沒打錯字的話,應該可以通過測試。

測試 Step 2

繼續來加下一條測試:

require 'rails_helper'

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

    it "如果加了相同種類的商品到購物車裡,購買項目(CartItem)並不會增加,但商品的數量會改變" do
      cart = Cart.new                             # 新增一台購物車
      3.times { cart.add_item(1) }                # 加了 3 次的 1
      5.times { cart.add_item(2) }                # 加了 5 次的 2
      2.times { cart.add_item(3) }                # 加了 2 次的 3

      expect(cart.items.length).to be 3           # 總共應該會有 3 個 item
      expect(cart.items.first.quantity).to be 3   # 第 1 個 item 的數量會是 3
      expect(cart.items.second.quantity).to be 5  # 第 2 個 item 的數量會是 5
    end

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

跟上一個測試差不多,只是這邊用了 times 方法一口氣新增多個東西到購物車裡,當然,如果這時候執行測試,一定會繼續發生錯誤。

實作 Step 2

接下來,一樣想辦法來通過剛剛這個測試。回想一下,其實我們在 Step 1 寫的 add_item 方法並沒有做任何檢查,只是一股腦兒的把收到的 product_id 的往 @items 陣列裡丟,而且光靠 product_id 也不足以記錄傳進來的數量,所以看起來需要另外的類別存放收到的商品以及數量。先修改原來 Cart 類別的內容:

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
end

說明:

  1. 加了一個 itemsattr_reader,讓內、外部的取用方便一些。
  2. add_item 方法裡使用 find 方法來找看看是不是有 item 的 product_id 跟傳進來的 product_id 是一樣的。這個 find 方法不是我們在 Rails 的 Model 常用到的那個 find 方法,它只是個 Ruby 的陣列類別內建的方法,這個方法可以尋找在該陣列裡是否有符合條件的元素。
  3. 如果找到的話,就叫那個 found_item 增加數量。
  4. 找不到的話,就是用 CartItem 類別包一個物件,然後丟往 @items 陣列。

接下來,跟新增 Cart 類別一樣,新增一個 cart_item.rbapp/models 底下,而且也因為不需要用到資料庫相關的功能,所以也不需要繼承自 Rails 的類別,用一般的 PORO 來寫就行了:

# 檔案:app/models/cart_item.rb
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
end

說明:

  1. 加了 2 個 attr_reader,分別是 product_id 以及 quantity,方便外部取用。
  2. 在初始化的時候會接收 product_id 以及 quantity 兩個參數,但如果沒有傳 quantity 參數則是使用預設值 1。
  3. increment 方法會接收一次要新增的數量,預設值 1。

這樣一來測試應該就可以通過了。

測試 Step 3

繼續測試:

require 'rails_helper'

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

    it "商品可以放到購物車裡,也可以再拿出來" do
      cart = Cart.new
      p1 = Product.create(title:"七龍珠")               # 建立商品 1
      p2 = Product.create(title:"冒險野郎")             # 建立商品 2

      4.times { cart.add_item(p1.id) }                 # 放了 4 次的商品 1
      2.times { cart.add_item(p2.id) }                 # 放了 2 次的商品 2

      expect(cart.items.first.product_id).to be p1.id  # 第 1 個 item 的商品 id 應該會等於商品 1 的 id
      expect(cart.items.second.product_id).to be p2.id # 第 2 個 item 的商品 id 應該會等於商品 2 的 id
      expect(cart.items.first.product).to be_a Product # 第 1 個 item 拿出來的東西應該是一種商品
    end
  end

  # ...[略]...
end

執行測試,沒意外應該是會出錯的…

實作 Step 3

因為要透過 Product Model 來建立資料,然後放到購物車裡,所以這時候就真的需要請產生器幫我們建一個 Product Model 了:

$ bin/rails g model Product title description:text price:integer
Running via Spring preloader in process 88733
  invoke  active_record
  create    db/migrate/20170110151200_create_products.rb
  create    app/models/product.rb
  invoke    rspec
  create      spec/models/product_spec.rb
  invoke      factory_girl
  create        spec/factories/products.rb

別忘了執行 bin/rails db:migrate 喔!

接下來,回頭來幫 CartItem 加上 product 方法,讓它可以根據目前這條 item 的 product_id 查出產品是什麼:

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
end

如此一來,透過 Cart 類別的 add_item 方法,把 A 商品加到購物車裡,拿出來之後還是 A 商品,而不是放蘋果進去,結果拿出來變香蕉。

小結

TDD 的手感:

  1. 確認規格,把規格轉成測試。
  2. 執行測試,一定會失敗!(除非你有小精靈)。
  3. 想辦法讓測試通過。
  4. 回到 1。

寫測試的時候,先不用擔心寫得好不好看或優不優雅,先用最直覺的方式把你想測的內容寫出來,然後把實作內容做出來,想辦法通過測試。到這裡,我們大概完成了購物車一半的功能,下個章節將會繼續完成另外一半…

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

← 上一章:Rails 程式碼整理術(進階) 下一章:購物車 Part 2 →

Comments