Rails开发:购物车(7)

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

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值