天天看點

Rails開發:購物車(7)第14章 測試

第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版本變動太大,無法跑通,不再繼續按照書中講解測試。