第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=>'secret@pragprog.com')
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版本变动太大,无法跑通,不再继续按照书中讲解测试。