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_type
和 start_at
是必填,而且 parking_type
的值只能是 ["guest", "short-term", "long-term"]
其中之一。另外我們還自訂了一個檢查規則是 end_at
和 amount
需要一起填。
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
會看到綠色測試通過:
不過,太順利其實不是好事,假如我們把驗證暫時註解掉:
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
你會發現跑測試也是一片綠:
這告訴我們如果沒有先看到測試失敗,那麽測試成功可能只是幻覺:其實你的測試代碼什麽也沒測到,結果也會是綠色通過。
讓我們把測試的註解改回來
- # expect( parking ).to_not be_valid
+ expect( parking ).to_not be_valid
(略)
- # expect( parking ).to_not be_valid
+ expect( parking ).to_not be_valid
再跑一次測試:
紅字了,這表示這個測試真的起了作用,因為我們註解掉 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
,新增一個 duration
和 calculate_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
按下開始計費:
按下結束計費:
最後顯示時間是 UTC 有點奇怪,這是因為 Rails 預設的時區是 UTC,讓我們改成台北時間:
修改 config/application.rb
,加上一行指定台北時區:
module ParkingApp
class Application < Rails::Application
+ config.time_zone = "Taipei"
end
end
重開伺服器再看一次就是台北時區了。
無論設定什麽時區,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
會出現紅字測試失敗:
開始實作,只需要讓這個測試通過即可,先不用急著完成:
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
測試通過。
增加 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
測試也通過。
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
會出現紅字測試失敗:
改實作:
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
測試通過。
增加 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
測試通過。
增加 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
測試通過。
你會發現我們不需要一開始就把全部測試案例都先寫上去(這樣要一次通過所有測試會很辛苦),而是新寫一段測試碼讓測試失敗,改一下實作讓測試通過,然後再新寫下一個測試碼讓測試失敗,然後再改實作,如此迭代交替。
這種技巧,在軟體開發領域有一個很響亮的名稱,叫做 TDD(Test-Driven Development),流程是這樣的:
流程的最後一步是重構,例如,我們可以整個方法改成更簡潔的寫法:
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
進行檢查。