Developer testing isn’t primarily about verifying code. It’s about making great code. If you can’t test something, it might be your testing skills failing you but it’s probably your code code’s design. Testable code is almost always better code. - Chad Fowler
關於寫測試,很多人的第一印象可能是:
時程緊迫預算吃緊,哪來的時間做自動化測試呢?這個想法是短視的,寫測試其實有以下好處:
其中光是第一個好處,就值得你學習如何寫測試,來加速你的開發,怎麼說呢?回想你平常是怎麼確認你寫的程式正確的呢? 是不是在命令列中實際執行看看,或是打開瀏覽器看看結果,每次修改,就重新手動重新整理看看。這些步驟其實可以透過用自動化測試取代,大大節省手工測試的時間。也其實是一種投資,如果是簡單的程式,也許你手動執行一次就寫對了,但是如果是複雜的程式,往往第一次不會寫對,你會浪費很多時間在檢查到底你寫的程式的正確性,而寫測試就可以大大的節省這些時間。更不用說你明天,下個禮拜或下個月需要再確認其他程式有沒有副作用影響的時候,你有一組測試程式可以大大節省手動檢查的時間。
在這一章,我們將使用RSpec來取代Rails預設的Test::Unit來做為我們測試的工具。RSpec是一套改良版的xUnit測試框架,讓我們先來比較看看:
幾乎每種語言都有一套基於xUnit測試框架的測試工具,讓你可以測試軟體中的基本元件,也就是類別和方法。簡單來說,每個單元測試的流程是:設定測試資料、然後執行要測試的方法、最後檢查結果是否正確。
這是一個Test::Unit範例:
class OrderTest < Test::Unit::TestCase
def setup
@order = Order.new
end
def test_order_status_when_initialized
assert_equal @order.status, "New"
end
def test_order_amount_when_initialized
assert_equal @order.amount, 0
end
end
如果用RSpec的語法則是寫成:
describe Order do
before do
@order = Order.new
end
context "when initialized" do
it "should have default status is New" do
@order.status.should == "New"
end
it "should have default amount is 0" do
@order.amount.should == 0
end
end
end
光看程式有沒有覺得非常容易了解跟閱讀,也更像是一種規格Spec文件,讓我們繼續介紹下去如何使用吧。
RSpec是一套Ruby的測試DSL(Domain-specific language)框架,它的程式比Test::Unit更好讀,寫的人更容易描述測試目的,可以說是一種可執行的規格文件。也 非常多的Ruby on Rails專案採用RSpec作為測試框架。它又稱為一種BDD(Behavior-driven development)測試框架,相較於TDD用test思維,測試程式的結果。BDD強調的是用spec思維,描述程式應該有什麼行為。
在Gemfile中加入:
group :test, :development do
gem "rspec", "~> 2.0"
gem "rspec-rails", "~> 2.0"
end
安裝:
rails generate rspec:install
如何執行測試:
bundle exec rake spec
rake spec
會先執行一次rake db:test:prepare
建立測試資料庫。
測試單一檔案,例如:
bundle exec rspec spec/models/user_spec.rb
在示範怎麼在Rails中寫單元測試前,讓我們先介紹一些基本的RSpec用法:
describe和context幫助你組織分類,都是可以任意套疊的:
describe Order do
describe "#amount" do
context "when user is vip" do
# ...
end
context "when user is not vip" do
# ...
end
end
end
通常最外層是我們想要測試的類別,然後下一層是哪一個方法,然後是不同的情境。
每個it就是一小段測試,在裡面我們會用should來設定期望,例如:
describe Order do
describe "#amount" do
context "when user is vip" do
it "should discount five percent if total >= 1000" do
user = User.new( :is_vip => true )
order = Order.new( :user => user, :total => 2000 )
order.amount.should == 1900
end
it "should discount ten percent if total >= 10000" { ... }
end
context "when user is vip" { ... }
end
end
除了should,也有相反地should_not可以用。
如同xUnit框架的setup和teardown:
before(:each)
每段it之前執行before(:all)
整段describe前只執行一次after(:each)
每段it之後執行after(:all)
整段describe後只執行一次範例如下:
describe Order do
describe "#amount" do
context "when user is vip" do
before(:each) do
@user = User.new( :is_vip => true )
@order = Order.new( :user => @user )
end
it "should discount five percent if total >= 1000" do
@order.total = 2000
@order.amount.should == 1900
end
it "should discount ten percent if total >= 10000" do
@order.total = 10000
@order.amount.should == 9000
end
end
context "when user is vip" { ... }
end
end
可以先列出來打算要寫的測試:
describe Order do
describe "#paid?" do
it "should be false if status is new"
it "should be true if status is paid or shipping" do
pending
end
end
end
上述的should後面可以接各種Matcher,例如:
target.should be_true
# targer.should == true
target.should be_false
# targer.should == false
target.should be_nil
# targer.should == nil
可以檢查型別、方法:
target.should be_a_kind_of(Array)
# target.class.should == Array
target.should be_an_instance_of(Array)
# target.class.should == Array
target.should respond_to(:foo)
# target.repond_to?(:foo).should == true
可以檢查 Array、Hash:
target.should have_key(:foo)
# target[:foo].should_not == nil
target.should include(4)
# target.include?(4).should == true
target.should have(3).items
# target.items.length == 3
任何 be_ 開頭都可以:
target.should be_empty
# target.empty?.should == true
target.should be_blank
# target.blank?.should == true
target.should be_admin
# target.admin?.should == true
不過別擔心,一開始先學會用should ==
就很夠用了,其他的Matchers可以之後邊看邊學,學一招是一招。再進階一點你可以自己寫Matcher,RSpec有提供擴充的DSL。
期望一段程式會改變某個值或丟出例外。例如,改變值:
describe Order do
describe "#ship!" do
context "with paid" do
it "should update status to shipping" do
expect {
order.ship!
}.to change { order.status }.from("new").to("shipping")
end
end
context "without paid" { ... }
end
end
丟出例外:
describe Order do
describe "#ship!" do
context "with paid" do
it "should raise NotPaidError" do
expect {
order.paid? = false
order.ship!
}.to raise_error(NotPaidError)
end
end
context "with paid" { ... }
end
end
用假的物件替換真正的物件,作為測試之用。主要用途有:
如何使用Mocks超出本書範圍。
在Rails中,RSpec分成數種不同測試,分別是Model測試、Controller測試、View測試、Helper測試、Route測試。
編輯Gemfile:
group :test, :development do
gem "rspec"
gem "rspec-rails"
end
輸入bundle
安裝,接著輸入:
rails g rspec:install
這樣就會建立出spec目錄來放測試程式,本來的test目錄就不用了。
裝了rspec-rails之後,rails g model時也會順道建立對應的Spec檔案。這裡我們來寫點Event model的測試吧,延續RESTful 與表單設計中操作 Resources 狀態一節所示範的方法為例,新增spec/models/event_spec.rb如下:
require 'spec_helper'
describe Event do
before do
@event = Event.new( :name => "foobar" )
end
describe ".closed?" do
it "should return true if status is CLOSED" do
@event.status = "CLOSED"
@event.closed?.should be_true
end
it "should return false if status is not CLOSED" do
@event.status = "OPEN"
@event.closed?.should be_false
end
end
describe ".open?" do
it "should return true if status is OPEN" do
@event.status = "OPEN"
@event.open?.should be_true
end
it "should return false if status is not OPEN" do
@event.status = "CLOSED"
@event.open?.should be_false
end
end
describe "open!" do
it "should set status to OPEN" do
@event.open!
@event.status.should == "OPEN"
end
end
describe "close!" do
it "should set status to CLOSED" do
@event.close!
@event.status.should == "CLOSED"
end
end
end
要怎麼執行測試呢?輸入bundle exec rake spec
就會根據目前的開發資料庫Schema建一個測試用資料庫,然後執行所有spec目錄下的_spec.rb
檔案結尾的測試。
如果測試資料庫已經建好了,例如執行過rake spec
或是bundle exec rake db:test:prepare
之後,你也可以單獨執行測試bundle exec rspec spec/models/event_spec.rb
。