About Me
• 張文鈿 a.k.a. ihower
• http://ihower.tw
• http://twitter.com/ihower
• Lead Software Developer at OptimisDev Taiwan
• Ruby Developer since 2006
• The Organizer of Ruby Taiwan Community
• http://ruby.tw
• http://rubyconf.tw
單元測試 Verification
確認程式的執行結果有沒有對?
Unit Test Are we writing the code right?
整合測試
Integration Test
驗收測試 Validation
檢查這軟體有滿足用戶需求嗎?
Acceptance Test Are we writing the right software?
單元測試 測試個別的類別和函式結果正確
Unit Test
整合測試 測試多個類別之間的互動正確
Integration Test
驗收測試 從用戶觀點測試整個軟體
Acceptance Test
單元測試
白箱測試
Unit Test
整合測試
Integration Test
驗收測試
黑箱測試
Acceptance Test
單元測試
Developer 開發者
Unit Test
整合測試
Integration Test
驗收測試
QA 測試人員
Acceptance Test
xUnit Framework
每個程式語言都有這樣的框架
• test case 依序執行各個 test :
• Setup 建立初始狀態
• Exercise 執行要測的程式
• Verify (assertion)驗證狀態如預期
• Teardown 結束清理資料
今天的案例
• 訂單 Order
• 狀態 status
• 依照 user 是否是 vip,計算總價 amount
• 運送 ship!
Ruby 的 Test::Unit
class OrderTest < Test::Unit::TestCase
def setup
@order = Order.new Method 的命名是
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
今天的主題 RSpec 寫法
(行家版)
describe Order do
context "when initialized" do
its(:status) { should == "New" }
its(:amount) { should == 0 }
end
end
Syntax Matters!
用正確的語法描述程式應該要有什麼行為
def test_order_status_when_initialized
assert_equal @order.status, "New"
end
# v.s.
it "should have default status is New" do
@order.status.should == "New"
end
可讀性大勝
加入 it
describe Order do
describe "#amount" do
context "when user is vip" do
it "should discount five percent if total > 1000" do
# ...
end
it "should discount ten percent if total > 10000" do
# ...
end
end
context "when user is not vip" do
it "should discount three percent if total > 10000" do
# ...
end
end
end
end
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
before 和 after
• 如同 xUnit 框架的 setup 和 teardown
• before(:each) 每段 it 之前執行
• before(:all) 整段 describe 執行一次
• after(:each)
• afte(:all)
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
pending
可以先列出來打算要寫的測試
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
describe Order do
describe "#amount" do
context "when user is vip" do
let(:user) { User.new( :is_vip => true ) }
let(:order) { Order.new( :user => user ) }
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 "#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 update status to shipping" do
order.status.should == "new"
order.ship!
order.status.should == "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
其實跟這個版本一樣
describe Order do
describe "#ship!" do
context "with paid" do
it "should raise NotPaidError" do
lambda {
order.paid? = false
order.ship!
}.should_raise(NotPaidError)
end
end
context "with paid" { ... }
end
end
使用 subject 可省略
receiver
describe Order do
subject { Order.new(:status => "New") }
it { should be_valid } # subject.valid?.should == true
end
its 可省略 receiver 的
方法呼叫
describe Order do
subject { Order.new(:status => "New") }
its(:status) { should == "New"} # subject.status.should == "New"
end
一些別名方法
哪個念起來順就用哪個
describe Order do # describe 和 context 其實是 alias
subject { Order.new(:status => "New") }
# it, specify, example 其實都一樣
it { should be_valid }
specify { should be_valid }
example { should be_valid }
end
用來測試最後的狀態
describe "#receiver_name" do
it "should be user name" do
user = stub(:user)
user.stub(:name).and_return("ihower")
order = Order.new(:user => user)
order.receiver_name.should == "ihower"
end
end
用來測試行為的發生
describe "#ship!" do
before do
@user = stub("user").as_null_object
@gateway = mock("ezcat")
@order = Order.new( :user => @user, :gateway => @gateway )
end
context "with paid" do
it "should call ezship API" do
@gateway.should_receive(:deliver).with(@user).and_return(true)
@order.ship!
end
end
版本一:
這個 Order 完全隔離 Item
describe Order do
before do
@order = Order.new
end
describe "#<<" do
it "should push item into order.items" do
Item = stub("Item")
item = stub("item", :name => "foobar")
Item.stub(:new).and_return(item)
@order << "foobar"
@order.items.last.name.should == "foobar"
end
end
end
測試通過,即使還沒
實作 Item
class Order
attr_accessor :items
def initialize
self.items = []
end
def <<(item_name)
self.items << Item.new(item_name)
end
end
版本二:
用真的 Item 物件
(拿掉剛才的所有 stub code 即可)
describe Order do
before do
@order = Order.new
end
describe "#<<" do
it "should push item into order.items" do
@order << "foobar"
@order.items.last.name.should == "foobar"
end
end
end
需要寫真的 Item
讓測試通過
class Item describe Item do
it "should be created by name" do
attr_accessor :name item = Item.new("bar")
item.name.should == "bar"
def initialize(name) end
self.name = name end
end
end
差在哪? 假設我們後來
修改了 Item 的實作
class Item
attr_accessor :name
def initialize(name)
self.name = name.upcase
end
end
造假舉例:
MVC 中的Controller 測試
# 測狀態
it "should be created successful" do
post :create, :name => "ihower"
response.should be_success
User.last.name.should == "ihower" # 會真的存進資料庫
end
# 測行為
it "should be created successful" do
Order.should_receive(:create).with(:name => "ihower").and_return(true)
post :create, :name => "ihower"
response.should be_success
end
Capybara
模擬瀏覽器行為
describe "the signup process", :type => :request do
it "signs me in" do
within("#session") do
fill_in 'Login', :with => 'user@example.com'
fill_in 'Password', :with => 'password'
end
click_link 'Sign in'
end
end
驗收測試的 Syntax
feature "signing up" do
background do
User.make(:email => 'user@example.com', :password => 'caplin')
end
scenario "signing in with correct credentials" do
within("#session") do
fill_in 'Login', :with => 'user@example.com'
fill_in 'Password', :with => 'caplin'
end
click_link 'Sign in'
end
end
原則一:Isolation
• 一個 it 裡面只有一種測試目的,最好就
只有一個 Expectation
• 盡量讓一個單元測試不被別的週邊因素
影響
• one failure one problem
這樣才容易 trace 問題所在
錯誤示範
describe "#amount" do
it "should discount" do
user.vip = true
order.amount.should == 900
user.vip = false
order.amount.should == 1000
end
end
善用 context
describe "#amount" do
context "when user is vip" do
it "should discount ten percent" do
user.vip = true
order.amount.should == 900
end
end
context "when user is not vip" do
it "should discount five percent" do
user.vip = false
order.amount.should == 1000
end
end
end
例一:
Private methods
class Order
def amount
if @user.vip?
self.caculate_amount_for_vip
else
self.caculate_amount_for_non_vip
end
end
private
def caculate_amount_for_vip
# ...
end
def caculate_amount_for_non_vip
# ...
end
end
錯誤示範
it "should discount for vip" do
@order.send(:caculate_amount_for_vip).should == 900
end
it "should discount for non vip" do
@order.send(:caculate_amount_for_vip).should == 1000
end
不要測 Private methods
• 測試 public 方法就可以同樣涵蓋到測試
private/protect methods 了
• 修改任何 private 方法,不需要重寫測試
• 變更 public 方法的實作,只要不改介面,
就不需要重寫測試
• 可以控制你的 Public methods 介面,如果只
是類別內部使用,請放到 Private/Protected
例二:
測試特定的實作
HTTParty 是一個第三方套件
describe User do
describe '.search' do
it 'searches Google for the given query' do
HTTParty.should_receive(:get).with('http://www.google.com',
:query => { :q => 'foo' } ).and_return([])
User.search query
end
end
end
換別套 HTTP 套
件,就得改測試
透過抽象化介面
來測試
describe User do
describe '.search' do
it 'searches for the given query' do
User.searcher = Searcher
Searcher.should_receive(:search).with('foo').and_return([])
User.search query
end
end
end
對應的實作
class User < ActiveRecord::Base
class_attribute :searcher
def self.search(query)
searcher.search query
end
end
修改實作不需
class Searcher
要修改測試
def self.search(keyword, options={})
HTTParty.get(keyword, options)
end
end
Reference:
• The RSpec Book
• The Rails 3 Way
• Foundation Rails 2
• xUnit Test Patterns
• http://pure-rspec-rubynation.heroku.com/
• http://jamesmead.org/talks/2007-07-09-introduction-to-mock-objects-in-ruby-
at-lrug/
• http://martinfowler.com/articles/mocksArentStubs.html
• http://blog.rubybestpractices.com/posts/gregory/034-issue-5-testing-
antipatterns.html
• http://blog.carbonfive.com/2011/02/11/better-mocking-in-ruby/