Link Search Menu Expand Document

2. 停車計費程序 Part 1

2-1 目標

上一章只是單純的 Ruby 程序,那麽在 Rails 裡面,要怎麽寫測試呢?

如果是非常簡單的程序邏輯,寫自動化測試比較沒有價值,讓我們挑戰一個比較難的例子,計算停車費的應用:

規格如下:

  • 未註冊登入用戶,可以點選開始計費和結束,並使用「一般費率」: 第一個小時 ¥2,之後每半小時 ¥1
  • 有註冊的登入用戶,可以選擇「短期費率」 或「長期費率」
    • 短期費率:第一個小時 ¥2,之後每半小時 ¥0.5
    • 長期費率:一天 ¥16,若停六小時以內 ¥12

你應該可以想像得到,這個要測試的時候,會有很多種情況吧? 如果每次改代碼都要把全部情況都測過一遍,是會非常耗時的。 這時候如果有撰寫自動化的技能的話,就可以大大地輔助我們開發,減少開發的時間、減少程序是否正確的疑惑。

接下來我們會實作 Rails Model 的單元測試。自動化測試分成幾種不同類型,目前學的這種是針對單一類別的方法進行測試,叫做單元測試(Unit Testing)。

2-2 初始 Rails 並安裝 rspec-rails

執行

rails new parking-app
cd parking-app
rm -rf test
git init
git add .
git commit -m "Init Rails project"

Rails 內建也有一個測試框架放在 /test 目錄,但是不太好用。業界幾乎都改用 RSpec 來寫測試。

編輯 Gemfile

  group :development, :test do
+   gem 'rspec-rails'

    # Call 'byebug' anywhere in the code to stop execution and get a debugger console
    gem 'byebug', platform: :mri
  end

執行 bundle

執行 rails g rspec:install 這會建立出 spec 目錄來放我們的測試檔案。

git add .
git commit -m "Install rspec-rails"

2-3 建立 Parking Model

新增 Parking model 來儲存停車的資料,並檢查必填的資料。

rails g model parking

編輯 migration 檔案:

  class CreateParkings < ActiveRecord::Migration[5.0]
    def change
      create_table :parkings do |t|
+       t.string :parking_type  # 費率類型: guest, short-term, long-term
+       t.datetime :start_at    # 開始時間
+       t.datetime :end_at      # 結束時間
+       t.integer :amount       # 總金額(分)
+       t.integer :user_id, :index => true
        t.timestamps
      end
    end
  end


注意到 amount 金額欄位通常會儲存貨幣的最小單位,因此這裡 ¥1 會存成 100。

執行 rake db:migrate

編輯 apps/models/parking.rb

  class Parking < ApplicationRecord

+  validates_presence_of :parking_type, :start_at
+  validates_inclusion_of :parking_type, :in => ["guest", "short-term", "long-term"]
+
+  validate :validate_end_at_with_amount
+
+  def validate_end_at_with_amount
+    if ( end_at.present? && amount.blank? )
+      errors.add(:amount, "有結束時間就必須有金額")
+    end
+
+    if ( end_at.blank? && amount.present? )
+      errors.add(:end_at, "有金額就必須有結束時間")
+    end
+  end

  end


這裡我們檢查 parking_typestart_at 是必填,而且 parking_type 的值只能是 ["guest", "short-term", "long-term"] 其中之一。另外我們還自訂了一個檢查規則是 end_atamount 需要一起填。

2-4 撰寫 Parking Model 的 Validation 測試

上述的檢查該如何寫測試呢?

  require 'rails_helper'

  RSpec.describe Parking, type: :model do
-   pending "add some examples to (or delete) #{__FILE__}" 這行砍掉不需要
+   describe ".validate_end_at_with_amount" do
+
+     it "is invalid without amount" do
+       parking = Parking.new( :parking_type => "guest",
+                              :start_at => Time.now - 6.hours,
+                              :end_at => Time.now)
+       expect( parking ).to_not be_valid
+     end
+
+     it "is invalid without end_at" do
+       parking = Parking.new( :parking_type => "guest",
+                              :start_at => Time.now - 6.hours,
+                              :amount => 999)
+       expect( parking ).to_not be_valid
+     end
+   end
  end

執行 rspec spec/models/parking_spec.rb 會看到綠色測試通過:

image

不過,太順利其實不是好事,假如我們把驗證暫時註解掉:

  class Parking < ApplicationRecord

-  validate :validate_end_at_with_amount
+  # validate :validate_end_at_with_amount

然後把兩個 expect 也註解掉試看看:


      it "is invalid without amount" do
        parking = Parking.new( :parking_type => "guest",
                               :start_at => Time.now - 6.hours,
                               :end_at => Time.now)
-       expect( parking ).to_not be_valid
+       # expect( parking ).to_not be_valid
      end

      it "is invalid without end_at" do
        parking = Parking.new( :parking_type => "guest",
                               :start_at => Time.now - 6.hours,
                               :amount => 999)
-       expect( parking ).to_not be_valid
+       # expect( parking ).to_not be_valid
      end

你會發現跑測試也是一片綠:

image

這告訴我們如果沒有先看到測試失敗,那麽測試成功可能只是幻覺:其實你的測試代碼什麽也沒測到,結果也會是綠色通過。

讓我們把測試的註解改回來


-  # expect( parking ).to_not be_valid
+  expect( parking ).to_not be_valid

   (略)

-  # expect( parking ).to_not be_valid
+  expect( parking ).to_not be_valid

再跑一次測試:

image

紅字了,這表示這個測試真的起了作用,因為我們註解掉 validate :validate_end_at_with_amount 的關系,所以測試失敗了。

接著請把 validate :validate_end_at_with_amount 的註解改回來:

  class Parking < ApplicationRecord

-  # validate :validate_end_at_with_amount
+  validate :validate_end_at_with_amount

2-5 完成基本架構

接下來我們來製作 Controller 和網頁表單,把操作流程做出來:

  • Step1: 顯示開始停車的表單
  • Step2: 新建一筆停車,紀錄下開始時間
  • Step3: 顯示結束停車的表單
  • Step4: 結束一筆停車,記錄下結束時間,並且計算停車費
  • Step5: 顯示停車費用

首先修改 config/routes.rb

  Rails.application.routes.draw do

+   resources :parkings
+   root "parkings#new"

  end

執行 rails g controller parkings

修改 app/controllers/parkings_controller.rb

  class ParkingsController < ApplicationController

+   # Step1: 顯示開始停車的表單
+   def new
+     @parking = Parking.new
+   end
+
+   # Step2: 新建一筆停車,紀錄下開始時間
+   def create
+     @parking = Parking.new( :parking_type => "guest", :start_at => Time.now )
+     @parking.save!
+
+     redirect_to parking_path(@parking)
+   end
+
+   # Step3: 如果還沒結束,顯示結束停車的表單
+   # Step5: 如果已經結束,顯示停車費用。
+   def show
+     @parking = Parking.find(params[:id])
+   end
+
+   # Step4: 結束一筆停車,記錄下結束時間,並且計算停車費
+   def update
+     @parking = Parking.find(params[:id])
+     @parking.end_at = Time.now
+     @parking.calculate_amount # 這個方法會計算費用
+
+     @parking.save!
+
+     redirect_to parking_path(@parking)
+   end

  end

編輯 app/models/parking.rb,新增一個 durationcalculate_amount 方法:

   class Parking < ApplicationRecord

     # (略)

+    # 計算停了多少分鐘
+    def duration
+      ( end_at - start_at ) / 60
+    end

+    def calculate_amount
+      # 如果有開始時間和結束時間,則可以計算價格
+      if self.amount.blank? && self.start_at.present? && self.end_at.present?
+        self.amount = 9487   # TODO: 等會再來處理
+      end
+    end
+
   end

修改 app/views/parking/new.html.erb

<h1>停車費用計算</h1>

<%= form_for @parking do |f| %>

  <p><%= f.submit "開始計費" %></p>

<% end %>

修改 app/views/parking/show.html.erb

<h1>停車費用計算</h1>

<% if @parking.amount.present? # 已經結束,顯示停車費 %>

  <p>從 <%= @parking.start_at %> 至 <%= @parking.end_at %><p>
  <p>共 <%= @parking.duration %> 分鐘</p>
  <p>總計 <%= number_to_currency(@parking.amount.to_f / 100, :unit => "¥") %> 元</p>

  <p><%= link_to "繼續停車", new_parking_path %></p>

<% else # 還沒結束,顯示結束按鈕 %>

  <%= form_for @parking do |f| %>

    <p><%= f.submit "結束計費" %></p>

  <% end %>

<% end %>

讓我們操作看看,瀏覽 http://localhost:3000

image

按下開始計費:

image

按下結束計費:

image

最後顯示時間是 UTC 有點奇怪,這是因為 Rails 預設的時區是 UTC,讓我們改成台北時間:

修改 config/application.rb,加上一行指定台北時區:

  module ParkingApp
    class Application < Rails::Application
+      config.time_zone = "Taipei"
    end
  end

重開伺服器再看一次就是台北時區了。

image

無論設定什麽時區,Rails 存進資料庫一律是用 UTC 儲存,這樣在做跨時區的應用時,比大小才不會出錯。這裡有設定時區的話,Rails 會自動在讀取和寫入資料庫的時候幫你轉換時區。

2-6 準備「一般費率」測試案例

接下來讓我們進攻這堂課的重點,計算停車的費率:未註冊登入用戶使用一般費率的規則:第一個小時 ¥2,之後每半小時 ¥1

剛才在寫測試的時候,有一個關鍵是要決定要測什麽:準備測試案例可說是寫測試的關鍵,要測哪些案例(example)才算這個程序是正確的。

因此在開始寫代碼前,我們先梳理一下:根據上述的規則,我們腦力激盪一下列出所有可能測試案例的清單,特別是邊界(edge case)的情形:

時間長度 總金額
30 分鐘 2
60 分鐘 2
61 分鐘 3
90 分鐘 3
120 分鐘 4

列出所有需要測試的可能情況後,再刪減掉明顯重復的案例,例如測了 30 分鐘,就不需要再測 15 分鐘 或 45 分鐘了,因為都在第一個小時內。

2-7 開始撰寫「一般費率」的測試

我們在 2-4 時,告訴各位寫測試時,要看到測試失敗,才表示測試有作用。

因此,寫測試的時機點,不一定是先寫實作代碼,再寫測試代碼。而可以是先寫測試代碼,再寫實作代碼。這樣就順理成章會先看到測試失敗的情形。

根據上一節準備的案例,我們開始寫第一個測試案例:


+  describe ".calculate_amount" do
+    it "30 mins should be ¥2" do
+      t = Time.now
+      parking = Parking.new( :parking_type => "guest", :start_at => t, :end_at => t + 30.minutes )
+      parking.calculate_amount
+      expect(parking.amount).to eq(200)
+    end
+  end

用 describe 包起來只是用來分類測試案例而已,並沒有什麽真正的作用。這裡用意是裡面都是針對 calculate_amount 方法的測試。另外,describe 和 it 的第一個字串參數也只是描述,是可以打中文的,例如 it "30 分鐘應該是 2 元"

執行 rspec spec/models/parking_spec.rb 會出現紅字測試失敗:

image

開始實作,只需要讓這個測試通過即可,先不用急著完成:

   def calculate_amount
     if self.amount.blank? && self.start_at.present? && self.end_at.present?
-      self.amount = 9487 # TODO: 等會再來處理
+      if duration <= 60
+        self.amount = 200
+      end
    end
  end

執行 rspec spec/models/parking_spec.rb 測試通過。

image

增加 60 分鐘的測試案例,註意到案例跟案例之間是互相獨立、不會互相影響的(這樣測試失敗時,才不用懷疑是不是被別的測試影響到),因此新增加的測試案例,裡面需要的物件會重新建立。

   describe ".calculate_amount" do

+    it "60 mins should be ¥2" do
+      t = Time.now
+      parking = Parking.new( :parking_type => "guest", :start_at => t, :end_at => t + 60.minutes )
+      parking.calculate_amount
+      expect( parking.amount ).to eq(200)
+    end

     # (略)
   end

執行 rspec spec/models/parking_spec.rb 測試也通過。

image

2-8 完成「一般費率」計算

增加 61 分鐘的測試案例:

+    it "61 mins should be ¥3" do
+      t = Time.now
+      parking = Parking.new( :parking_type => "guest", :start_at => t, :end_at => t + 61.minutes )
+      parking.calculate_amount
+      expect( parking.amount ).to eq(300)
+    end

執行 rspec spec/models/parking_spec.rb 會出現紅字測試失敗:

image

改實作:


  def calculate_amount
    if self.amount.blank? && self.start_at.present? && self.end_at.present?
-     if duration <= 60
-       self.amount = 200
-     end
+     total = 0
+     if duration <= 60
+       total = 200
+     else
+       total += 200
+       left_duration = duration - 60
+       total += ( left_duration.to_f / 30 ).ceil * 100
+     end
+
+     self.amount = total
    end
  end

執行 rspec spec/models/parking_spec.rb 測試通過。

image

增加 90 分鐘的測試案例:

    it "90 mins should be ¥3" do
      t = Time.now
      parking = Parking.new( :parking_type => "guest", :start_at => t, :end_at => t + 90.minutes )
      parking.calculate_amount
      expect( parking.amount ).to eq(300)
    end

執行 rspec spec/models/parking_spec.rb 測試通過。

image

增加 120 分鐘的測試案例:

    it "120 mins should be ¥4" do
      t = Time.now
      parking = Parking.new( :parking_type => "guest", :start_at => t, :end_at => t + 120.minutes )
      parking.calculate_amount
      expect( parking.amount ).to eq(400)
    end

執行 rspec spec/models/parking_spec.rb 測試通過。

image

你會發現我們不需要一開始就把全部測試案例都先寫上去(這樣要一次通過所有測試會很辛苦),而是新寫一段測試碼讓測試失敗,改一下實作讓測試通過,然後再新寫下一個測試碼讓測試失敗,然後再改實作,如此迭代交替。

這種技巧,在軟體開發領域有一個很響亮的名稱,叫做 TDD(Test-Driven Development),流程是這樣的:

tdd

流程的最後一步是重構,例如,我們可以整個方法改成更簡潔的寫法:

  def calculate_amount
    if self.amount.blank? && self.start_at.present? && self.end_at.present?
      if duration <= 60
        self.amount = 200
      else
        self.amount = 200 + ((duration - 60).to_f / 30).ceil * 100
      end
    end
  end

這時候再執行 rspec spec/models/parking_spec.rb 還是通過,就知道這個重構萬無一失了。

至此,我們完成了一般費率的計算,過程中不需要打開瀏覽器,或是進 rails console 進行檢查。


Copyright © 2010-2022 Wen-Tien Chang All Rights Reserved.