第14章 測試
用很短的時間,我們開發了一個高品質的、基于web 的購物車應用。在這個過程中,我們總是編寫一點代碼,然後在浏覽器裡點選一個按鈕,讓身邊的客戶看看應用程式的行為是否符合預期,然後快速提出回報。在開發Rails 應用的第一個小時裡,這種測試政策确實管用;但很快,你就有了一大堆的功能,手工的測試無法跟上了。你的手指開始疲勞,已經厭倦了一次又一次地點選所有這些按鈕,是以你的測試不再頻繁——如果你還在測試的話。
然後,終于有一天,你做了一個小小的變動,卻破壞了别的幾項功能。可是,你對此一無所知,直到客戶的電話打過來,告訴你她非常生氣。更糟糕的是,你花了好幾個小時才找到出錯的地方。你在這兒做了一個小小的改動,卻在那裡造成了破壞。等到你總算解開這個謎題時,客戶覺得她自己都快變成一個優秀的程式員了。
事情不一定要這樣的。有一個實用的辦法可以改變這一切:寫測試!
在本章裡,我們要為大家都了解而且喜愛的Depot應用編寫自動化的測試。在理想狀态下,我們本應該以一種漸進的方式逐漸編寫這些測試,一點點地構造起一個信心的基礎。是以,我們把這一章叫做“任務T” ,因為我們應該随時做測試。從第672 頁起,你可以看到本章的全部代碼。
加上測試
在匆忙而草率的編碼之後,讀者很可能認為:當開發Rails 應用時,測試是在事後來做的。大錯特錯。Rails 架構的最大樂趣之一在于:它從每個項目的一開始就能夠把測試融入其中。實際上,從你用rails 指令建立應用程式的那一刻起,Rails 就已經為你生成了一套測試的基礎。
test 的子目錄看到四個已有的目錄,以及一個輔助檔案。
按照慣例, Rails 把模型的測試叫做單元測試(unit test),把控制器的測試叫做功能測試(functional test) ,而對“橫跨多個控制器的業務流程”進行的測試則被稱為內建測試(integration test) 。
用generate 腳本建立的模型和控制器, Rails 已經建立好了對應的單元測試和功能測試。這是個好的開頭,但Rails 能夠幫忙的也就是這麼多了。這些東西可以幫我們走上正路,讓我們專心于編寫出色的測試。我們将從資料端開始,逐漸向上接近使用者端。
模型的單元測試
depot_r/test/unit/product_test.rb
require File.dirname(__FILE__) + '/../test_helper'
class ProductTest < Test::Unit::TestCase
fixtures :products
def test_truth
assert true
end
end
(2.3.5版本如下)
require 'test_helper'
class ProductTest < ActiveSupport::TestCase
# Replace this with your real tests.
test "the truth" do
assert true
end
end
Rails 是在Test::Unit 架構( 該架構已經随Ruby 一道安裝了) 的基礎上生成測試代碼的。如果我們已經用Test::Unit 對Ruby 程式進行過測試,那麼Rails 應用的測試也就不成問題了。
Rails 生成的第二件東西是test_truth() 方法。如果你熟悉Test::Unit 的話,那麼對于這個方法就應該了如指掌:它的方法名以test 開頭,說明測試架構将把它當作測試方法來運作:其中的assert 一行是實際的測試——并不是那麼“實際”,它所做的一切無非是檢驗true 确實是true 而已。顯然這隻是一段占位程式,但卻非常重要,因為它讓我們看到所有的測試基礎設施都已經到位。
ruby test/unit/product_test.rb
(2.3.5 require 'test_helper' 是錯的,require File.dirname(__FILE__) + '/../test_helper')
結果:
Started
EE
Finished in 0.062 seconds.
1) Error:
test_the_truth(ProductTest):
Mysql::Error: Unknown database 'depot_test'
D:/DevelopTools/Ruby/lib/ruby/1.8/monitor.rb:242:in `synchronize'
2) Error:
test_the_truth(ProductTest):
Mysql::Error: Unknown database 'depot_test'
D:/DevelopTools/Ruby/lib/ruby/1.8/monitor.rb:242:in `synchronize'
1 tests, 0 assertions, 0 failures, 2 errors
測試專用資料庫
rake db:test:prepare
rake aborted!
Unknown database 'depot_test'
無法建立資料庫,這裡用手工建立後再運作
ruby -I test test/unit/product_test.rb
Loaded suite test/unit/product_test
Started
.
Finished in 0.266 seconds.
1 tests, 1 assertions, 0 failures, 0 errors
真正的單元測試
從Rails 生成Product模型類之後,我們已經往其中添加了不少代碼,其中一些是用于進行資料校驗的。
我們怎麼知道這些校驗邏輯确實起作用了呢?這就得靠測試。首先,如果建立一個貨品卻不給它設定任何屬性,我們希望它不能通過校驗,并且每個字段都應該有對應的錯誤資訊。通過valid?()方法可以知道模型對象是否通過校驗,用invalid?() 方法則可以知道某個特定的屬性是否有錯誤資訊與之關聯。
depot_r/test/unit/product_test.rb
def test_invalid_with_empty_attributes
product = Product.new
assert !product.valid ?
assert product.errors.invalid ? (:title)
assert product.errors.invalid ? (:description)
assert product.errors.invalid ? (:price)
assert product.errors.invalid ? (:image_url)
end
depot_r/test/unit/product_test.rb
def test_positive_price
product = Product.new(:title => "My Book Title" ,
:description => "yyy" ,
:image_url => "zzz.jpg" )
product.price = -1
assert !product.valid ?
assert_equal "should be at least 0.01" , product.errors.on(:price)
product.price = 0
assert !product.valid ?
assert_equal "should be at least 0.01" , product.errors.on(:price)
product.price = 1
assert product.valid ?
end
depot_r/test/unit/product_test.rb
def test_image_url
ok = %w{ fred.gif fred.jpg fred.png FRED.JPG FRED.Jpg
http://a.b.c/x/y/z/fred.gif }
bad = %w{ fred.doc fred.gif/more fred.gif.more }
ok.each do |name|
product = Product.new(:title => "My Book Title" ,
:description => "yyy" ,
:price => 1,
:image_url => name)
assert product.valid ? , product.errors.full_messages
end
bad.each do |name|
product = Product.new(:title => "My Book Title" , :description => "yyy" ,
:price => 1, :image_url => name)
assert !product.valid ? , "saving #{name}"
end
end
先在資料庫中存入貨品資料: 測試夾具
Test Fixtures
在測試的世界裡,夾具(fixture)是讓你在其中進行測試的環境。譬如說你要測試一塊電路闆,就需要将它安裝在一個測試夾具上,後者會提供電源和輸入資訊來驅動被測的功能。
在test/fixtures目錄中指定夾具資料。該目錄中的檔案包含了測試所用的資料,格式可能是CSV 或者YAML 。在這裡,我們将使用YAML ,這是Rails 推薦使用的格式。每個YAML 夾具檔案包含了一個模型類的初始資料。夾具檔案的名稱很重要:檔案的名稱必須與資料庫表名稱相比對。由于我們需要在products 表中填充資料,記載這些資料的檔案就應該叫products.yml 。在最初生成模型類時, Rails 已經建立了這個夾具檔案。
one:
title: MyString
description: MyText
image_url: MyString
two:
title: MyString
description: MyText
image_url: MyString
在每個資料行的開頭處必須使用空格來縮進,而不能使用tab 鍵;并且同一條記錄中所有的資料行必須使用同樣的縮進量。最後,你還需要確定每個條目中每個字段的名稱正确:如果YAML 中指定的屬性名與資料庫字段名不比對,可能導緻一些很難跟蹤的異常。
depot_r/test/fixtures/products.yml
ruby_book:
id: 1
title: Programming Ruby
description: Dummy description
price: 1234
image_url: ruby.png
讓Rails 在運作單元測試之前先把測試資料載入prodocts 表。
depot_r/test/unit/product_test.rb
fixtures :products
加上fixtures 這條指令就意味着在執行每個測試方法之前, products 表會被首先清空,每個測試方法都會使用一張全新的表。
使用夾具資料
有一種辦法是使用模型類提供的查找方法來讀取資料。不過Rails 能讓事情變得更簡單。
調用products(:ruby_book) 就會得到一個Product模型對象,其中包含我們在夾具中定義的資料。
給夾具起個好名字:
譬如說,檢驗product(:valid_order_for_fred)的合法性,就是檢驗Fred的訂單是否合法。
你會很快編出一個精彩的小故事: fred 定了一個christmas_order , 他先用invalid_credit_card支付,然後又改用valid_credit_card支付,最後選擇将這份禮物發貨給aunt_mary。
depot_r/test/unit/product_test.rb
def test_unique_title
product = Product.new(:title => products(:ruby_book).title,
:description => "yyy" ,
:price => 1,
:image_url => "fred.gif" )
assert !product.save
assert_equal "has already been taken" , product.errors.on(:title)
end
如果不希望像這樣把ActiveRecord 錯誤資訊寫死在字元串裡,也可以将其與内建的錯誤資訊表進行比對。
assert_equal ActiveRecord::Errors.default_error_messages[:taken], product.errors.on(:title)
完整的内建錯誤資訊清單請看ActiveRecord 中的validations.rb 檔案。
:inclusion => "is not included in the list" ,
:exclusion => "is reserved" ,
:invalid => "is invalid" ,
:confirmation => "doesn't match confirmation" ,
:accepted => "must be accepted" ,
:empty => "can't be empty" ,
:blank => "can't be blank" ,
:too_long => "is too long (maximum is %d characters)" ,
:too_short => "is too short (minimum is %d characters)" ,
:wrong_length => "is the wrong length (should be %d characters)" ,
:taken => "has already been taken" ,
:not_a_number => "is not a number" ,
:greater_than => "must be greater than %d" ,
:greater_than_or_equal_to => "must be greater than or equal to %d" ,
:equal_to => "must be equal to %d" ,
:less_than => "must be less than %d" ,
:less_than_or_equal_to => "must be less than or equal to %d" ,
:odd => "must be odd" ,
:even => "must be even"
測試購物車
Cart 類包含了一些業務邏輯。當我們把貨品放入購物車時,它會檢查該貨品是否已經存在于其中。如果已經存在,則增加對應條目的數量;如果不存在,則增加一個新的條目。我們來針對這項功能編寫一點測試
建立cart_test.rb 檔案,再從别的測試檔案中複制一份樣闆程式就行了( 别忘了把測試的類名改為CartTest)。
require File.dirname(__FILE__) + '/../test_helper'
class CartTest < ActiveSupport::TestCase
fixtures :products
end
depot_r/test/fixtures/products.yml
ruby_book:
id: 1
title: Programming Ruby
description: Dummy description
price: 1234
image_url: ruby.png
rails_book:
id: 2
title: Agile Web Development with Rails
description: Dummy description
price: 2345
image_url: rails.png
depot_r/test/unit/cart_test.rb
def test_add_unique_products
cart = Cart.new
rails_book = products(:rails_book)
ruby_book = products(:ruby_book)
cart.add_product rails_book
cart.add_product ruby_book
assert_equal 2, cart.items.size
assert_equal rails_book.price + ruby_book.price, cart.total_price
end
ruby test/unit/cart_test.rb
depot_r/test/unit/cart_test.rb
def test_add_duplicate_product
cart = Cart.new
rails_book = products(:rails_book)
cart.add_product rails_book
cart.add_product rails_book
assert_equal 2*rails_book.price, cart.total_price
assert_equal 1, cart.items.size
assert_equal 2, cart.items[0].quantity
end
發現代碼重複,Ruby 的單元測試架構允許我們很友善地為每個測試方法搭建共同的環境:隻要在測試案例中添加一個名叫setup()的方法,這個方法就會在每個測試方法運作之前首先運作—— setup 方法負責幫每個測試搭建環境。
depot_r/test/unit/cart_test1.rb
def setup
@cart = Cart.new
@rails = products(:rails_book)
@ruby = products(:ruby_book)
end
def test_add_unique_products
@cart.add_product @rails
@cart.add_product @ruby
assert_equal 2, @cart.items.size
assert_equal @rails.price + @ruby.price, @cart.total_price
end
def test_add_duplicate_product
@cart.add_product @rails
@cart.add_product @rails
assert_equal 2*@rails.price, @cart.total_price
assert_equal 1, @cart.items.size
assert_equal 2, @cart.items[0].quantity
end
setup()方法在“保持測試結果一緻性”方面扮演着至關重要的角色。
對單元測試的支援
assert(boolean,message)
如果boolean參數值為false 或nil ,則斷言失敗。
assert(User.find_by_name("dave" ), "user 'dave' is missing" )
assert_equal(expected, actual,message)
assert_not_equal(expected, actual,message)
除非expected 參數與actual 參數相等/ 不等,否則斷言失敗。
assert_equal(3, Product.count)
assert_not_equal(0, User.count, "no users in database" )
assert_nil(object,message)
assert_not_nil(object,message)
除非object 參數是/ 不是nil ,否則斷言失敗。
assert_nil(User.find_by_name("willard" )
assert_not_nil(User.find_by_name("henry" )
assert_in_delta(expected_float, actual_float, delta,message)
除非兩個浮點數之差的絕對值小于delta 參數,否則斷言失敗。在判斷浮點數 相等時應該盡量
使用此方法而不是assert_equal() ,因為浮點數是不精确的。
assert_in_delta(1.33, line_item.discount, 0.005)
assert_raise(Exception, ...,message) { block... }
assert_nothing_raised(Exception, ...,message) { block... }
除非代碼塊産生/ 不産生列舉的異常之一,否則斷言失敗。
assert_raise(ActiveRecord::RecordNotFound){Product.find(bad_id)}
assert_match(pattern, string,message)
assert_no_match(pattern, string,message)
除非string 參數與pattern參數指定的正規表達式比對/ 不比對,否則斷言失敗。如果
pattern參數是一個字元串,則進行全文比對,任何正規表達式元字元都不會被轉義。
assert_match(/flower/i, user.town)
assert_match("bang*flash" , user.company_name)
assert_valid(activerecord_object)
除非參數提供的ActiveRecord 對象合法( 換句話說,通過校驗) ,否則斷言失 敗。如
果校驗失敗,錯誤資訊會被用作斷言失敗資訊的一部分。
user=Account.new(:name=>"dave",:email=>'[email protected]')
assert_valid(user)
flunk(message)
無條件地失敗。
unless user.valid ? || account.valid?
flunk("One of user or account should be valid" )
end
http://ruby-doc.org/stdlib/libdoc/test/unit/rdoc/classes/Test/Unit/Assertions.html
此外, Rails 還支援對應用程式路由邏輯的測試
控制器的功能測試
控制器負責控制使用者界面的展示。它們接收進入的web 請求( 通常是使用者的輸入) ,與模型對象進行互動以獲得應用程式的狀态,然後找到合适的視圖顯示給使用者。是以,當對控制器進行測試時,我們必須確定一定的請求能夠得到合适的應答。我們仍舊需要模型對象,不過前面的單元測試已經覆寫了模型類,是以可以相信它們是可靠的。
Depot 應用有4 個控制器,每個控制器中都有幾個action 方法,是以這裡有很多東西需要測試。不過,我們将從較高的層面來進行測試。不妨從使用者将要用到的第一個功能開始——登入。
使用者登入
如果任何人都可以進入并管理Depot 應用,那可不太妙。雖然我們并不需要多麼複雜的安全系統,但至少要確定登入控制器能夠把閑雜人等置諸門外。
depot_r/test/functional/admin_controller_test.rb
require File.dirname(__FILE__) + '/../test_helper'
require 'admin_controller'
# Re-raise errors caught by the controller.
class AdminController; def rescue_action(e) raise e end; end
class AdminControllerTest < ActiveSupport::TestCase
def setup
@controller = AdminController.new
@request = ActionController::TestRequest.new
@response = ActionController::TestResponse.new
end
# Replace this with your real tests.
def test_truth
assert true
end
end
功能測試的關鍵在于setup()方法,它為每個功能測試方法初始化了三樣東西:
@controller,其中包含了需要測試的控制器執行個體。
@request包含了一個請求對象。在真實運作的應用程式中,請求對象包含了所有輸入請求的資訊和資料:HTTP 頭資訊、POST 和GET資料等等。在測試環境下,我們會使用一個特别的、測試專用的請求對象,它不依賴于真實的HTTP 請求。
@response 包含了一個應答對象。在編寫應用程式時我們還沒見過應答對象,不過早已用到它們了。每當處理來自浏覽器的請求時, Rails 都會在幕後準備一個應答對象。模闆會将資料渲染到應答對象中,我們傳回的狀态碼也會被記錄在應答對象中。應用程式完成對請求的處理之後, Rails 就會取出應答對象中的資訊,根據這些資訊向浏覽器發送應答。
@request 和@response對象對于功能測試是至關重要的——有了它們,我們就不必在測試控制器的時候打開web 伺服器。也就是說,功能測試并不需要web 伺服器、網絡連接配接或用戶端程式。
首頁:管理者專用
現在來編寫我們的第一個控制器測試吧——它需要做的隻是“點選”登入頁面。
depot_r/test/functional/admin_controller_test.rb
def test_index
get :index
assert_response :success
end
get() 方法是由測試輔助類提供的一個便利的方法,它模拟了一個針對AdminController的web 請求( 想想HTFP GET 請求) ,并捕獲控制器的應答。随後,assert_response()方法會檢查應答是否正确。
可以用-n 選項來指定運作某一個特定的測試方法。
ruby test/functional/admin_controller_test.rb -n test_index
在2.3.5下出錯:
1) Error:
test_index(AdminControllerTest):
NoMethodError: undefined method `get' for #<AdminControllerTest:0x349acc0>
test/functional/admin_controller_test.rb:17:in `test_index'
2.3.5版本的功能測試與2.2.2版本變動太大,無法跑通,不再繼續按照書中講解測試。