← 上一章: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

如上面這段程式碼後面的註解寫的,就是先新增一台購物車、丟個東西給它,然後測試它的狀態。那個 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