如何使用 ZenTest 与 Ruby
重构和单元测试是一对强大的工具,应该成为每位程序员工作台的一部分。遗憾的是,并非每位程序员都知道如何使用这些工具。我第一次接触它们是在我开始使用 Ruby 时。重构和单元测试是 Ruby 社区的重要组成部分。
不久前,我将 Martin Fowler 的优秀著作《重构》第一章中的重构示例从 Java 翻译成 Ruby。 我觉得这将是学习更多关于重构知识并在使用 Ruby 时复习的好方法。 最近,我决定更新 Ruby 1.8.X 的翻译。 我需要做的事情之一是将旧的单元测试转换为使用 Test::Unit,这是 Ruby 的新单元测试框架。 然而,我不期待构建一个新的测试套件。 幸运的是,有可用的帮助。
Ryan Davis 编写了一个很棒的工具,名为 ZenTest,它可以为现有的代码体创建测试套件。 因为很多人对重构、单元测试和 ZenTest 感到陌生,所以本文旨在介绍这三种工具。
Martin 的示例代码是围绕视频商店应用程序构建的。 他的原始代码包括三个类——Customer、Movie 和 Rental。 在本文中,我只关注 Customer 类。 这是原始代码
class Customer attr_accessor :name def initialize(name) @name = name @rentals = Array.new end def addRental(aRental) @rentals.push(aRental) end def statement totalAmount = 0.0 frequentRenterPoints = 0 rentals = @rentals.length result = "\nRental Record for #{@name}\n" thisAmount = 0.0 @rentals.each do |rental| # determine amounts for each line case rental.aMovie.pricecode when Movie::REGULAR thisAmount += 2 if rental.daysRented > 2 thisAmount += (rental.daysRented - 2) * 1.5 end when Movie::NEW_RELEASE thisAmount += rental.daysRented * 3 when Movie::CHILDRENS thisAmount += 1.5 if each.daysRented > 3 thisAmount += (rental.daysRented - 3) * 1.5 end end # add frequent renter points frequentRenterPoints += 1 # add bonus for a two day new release rental if ( rental.daysRented > 1) && (Movie::NEW_RELEASE == rental.aMovie.pricecode) frequentRenterPoints += 1 end # show figures for this rental result +="\t#{rental.aMovie.title}\t#{thisAmount}\n" totalAmount += thisAmount end result += "Amount owed is #{totalAmount}\n" result += "You earned #{frequentRenterPoints} frequent renter points" end end
这不是世界上最干净的代码,但这正是重点——这代表了您从用户那里获得的代码。 它不包含任何测试,并且布局很差,但它可以工作。 你的工作是让它在不破坏它的情况下更好地工作。 那么,从哪里开始呢? 当然是单元测试。 是时候使用 ZenTest 了。
您可以告诉 ZenTestit 执行此操作
$ zentest videostore.rb > test_videostore.rb
这会生成一个包含大量测试的文件。 然而,运行测试套件并没有完全达到我们希望的效果
$ ruby testVideoStore.rb Loaded suite testVideoStore Started EEEEEEEEEEE Finished in 0.008974 seconds. 1) Error!!! test_addRental(TestCustomer): NotImplementedError: Need to write test_addRental testVideoStore.rb:11:in `test_addRental' testVideoStore.rb:54 2) Error!!! test_name=(TestCustomer): NotImplementedError: Need to write test_name= testVideoStore.rb:15:in `test_name=' testVideoStore.rb:54 3) Error!!! test_statement=(TestCustomer): NotImplementedError: Need to write test_statement testVideoStore.rb:19:in `test_statement' testVideoStore.rb:54 . . . 11 tests, 0 assertions, 0 failures, 11 errors $
像这样运行 ZenTest,我们到底得到了什么? 这是我们新的测试套件中对 Customer 类重要的部分
# Code Generated by ZenTest v. 2.1.2 # classname: asrt / meth = ratio% # Customer: 0 / 3 = 0.00% require 'test/unit' class TestCustomer < Test::Unit::TestCase def test_addRental raise NotImplementedError, 'Need to write test_addRental' end def test_name= raise NotImplementedError, 'Need to write test_name=' end def test_statement raise NotImplementedError, 'Need to write test_statement' end end
ZenTest 构建了三个测试方法:一个用于访问器方法,一个用于 addRental 方法,另一个用于 statement 方法。 为什么初始化程序没有任何内容? 这是跳过的,因为初始化程序往往非常可靠。 如果不是,您可以自己轻松添加测试方法。 此外,当我们编写时,我们将间接测试它test_name=,访问器方法的测试。 我们需要添加另一件事——测试套件不会加载我们正在测试的代码。 将脚本的开头更改为 require videostore.rb 文件应该可以为我们解决问题。
# Code Generated by ZenTest v. 2.1.2 # classname: asrt / meth = ratio% # Customer: 0 / 3 = 0.00% require 'test/unit' require 'videostore'
顶部的注释片段让我们知道我们在 Customer 类中有三个方法正在测试,零个断言测试它们,并且没有覆盖率。 让我们修复它。 我们首先为 test_name= 编写一些测试。 我们在这里以什么顺序进行并不重要,test_name= 只是一个方便的起点。
def test_name= aCustomer = Customer.new("Fred Jones") assert_equal("Fred Jones",aCustomer.name) aCustomer.name = "Freddy Jones" assert_equal("Freddy Jones",aCustomer.name end
再次运行 testVideoStore.rb 给我们
$ ruby testVideoStore.rb Loaded suite testVideoStore Started E.EEEEEEEEE Finished in 0.011233 seconds. 1) Error!!! test_addRental(TestCustomer): NotImplementedError: Need to write test_addRental testVideoStore.rb:13:in `test_addRental' testVideoStore.rb:58 2) Error!!! test_statement(TestCustomer): NotImplementedError: Need to write test_statement testVideoStore.rb:23:in `test_statement' testVideoStore.rb:58 . . . 11 tests, 2 assertions, 0 failures, 10 errors $
到目前为止,一切都很好。 显示测试运行中错误的 E 行已减少了一行,并且底部的摘要行告诉我们大致相同的内容。
我们没有直接测试 addRental 的方法,所以我们现在只需编写一个存根测试。
def test_addRental assert(1) # stub test, since there is nothing in the method to test end
当我们再次运行测试时,我们得到
$ ruby testVideoStore.rb Loaded suite testVideoStore Started ..EEEEEEEEE Finished in 0.008682 seconds. 1) Error!!! test_statement(TestCustomer): NotImplementedError: Need to write test_statement testVideoStore.rb:22:in `test_statement' testVideoStore.rb:57 . . . 11 tests, 3 assertions, 0 failures, 9 errors $
我们做得越来越好了; TestCustomer 类中只剩下一个错误。 让我们完成一个测试,清除我们的 test_statement 错误并验证 addRental 是否正常工作
def test_statement aMovie = Movie.new("Legacy",0) aRental = Rental.new(aMovie,2) aCustomer = Customer.new("Fred Jones") aCustomer.addRental(aRental) aStatement = "\nRental Record for Fred Jones\n\tLegacy\t2.0 Amount owed is 2.0\nYou earned 1 frequent renter points" assert_equal(aStatement,aCustomer.statement) end
我们再次运行测试并看到
$ ruby testVideoStore.rb Loaded suite testVideoStore Started ...EEEEEEEE Finished in 0.009378 seconds. . . . 11 tests, 4 assertions, 0 failures, 8 errors $
太棒了! 剩余的错误发生在 Movie 和 Rental 类中; Customer 类是干净的。
我们可以像这样继续处理剩余的类,但我不会用这些细节来烦扰您。 相反,我想看看当您已经有一些测试到位时,ZenTest 如何提供帮助。 后来的开发允许我们完全做到这一点。 例如,假设视频商店所有者想要一个新的基于 Web 的报表,客户可以在线访问。 经过一些重构和新的开发,代码看起来像这样
class Customer attr_accessor :name def initialize(name) @name = name @rentals = Array.new end def addRental(aRental) @rentals.push(aRental) end def statement result = "\nRental Record for #{@name}\n" @rentals.each do |each| # show figures for this rental result +="\t#{each.aMovie.title}\t#{each.getCharge}\n" end result += "Amount owed is #{getTotalCharge}\n" result += "You earned #{getFrequentRenterPoints} frequent renter points" end def htmlStatement result = "\n<H1>Rentals for <EM>#{name}</EM></H1><P>\n" @rentals.each do |each| result += "#{each.aMovie.title}: #{each.getCharge}<BR>\n" end result += "You owe <EM>#{getTotalCharge}</EM><P>\n" result += "On this rental you earned <EM>#{getFrequentRenterPoints}" + "</EM> frequent renter points<P>" end def getTotalCharge result = 0.0 @rentals.each do |each| result += each.getCharge() end result end def getFrequentRenterPoints result = 0 @rentals.each do |each| result += each.getFrequentRenterPoints end result end end
现在代码中有很多新内容。 如果我们再次运行 ZenTest,它会拾取我们没有覆盖率的方法。 我们应该在编写新方法时编写它们,但此方法更具说明性。 所以这次,我们以稍微不同的方式调用 ZenTest
$ zentest videostore.rb testVideoStore.rb > Missing_tests
我们的(修剪后的)输出看起来像这样
# Code Generated by ZenTest v. 2.1.2 # classname: asrt / meth = ratio% # Customer: 4 / 6 = 66.67% require 'test/unit' class TestCustomer < Test::Unit::TestCase def test_getFrequentRenterPoints raise NotImplementedError, 'Need to write test_getFrequentRenterPoints' end def test_getTotalCharge raise NotImplementedError, 'Need to write test_getTotalCharge' end def test_htmlStatement raise NotImplementedError, 'Need to write test_htmlStatement' end end
我们需要填写另外三个测试方法才能获得完整的覆盖率。 在我们编写这些内容时,我们可以将它们迁移到我们现有的 testVideoStore.rb 测试套件。 然后,我们可以继续进行重构和添加新功能。 当然,将来,我们将随着我们的进行而简单地添加测试。 ZenTest 在这里也可以提供帮助。 我们可以为新开发编写存根,然后运行 ZenTest 以创建新的测试存根。 经过一些重构,例如提取方法,ZenTest 可以以相同的方式使用。
重构和单元测试是程序员的强大工具,ZenTest 提供了一种在 Ruby 环境中开始使用它们的简便方法。 希望这篇介绍已经引起了您的兴趣。
如果您有兴趣了解更多关于重构的信息,请获取一本《重构:改进现有代码的设计》并访问 www.refactoring.com。 有关单元测试的更多信息,请参阅 c2.com/cgi/wiki?UnitTest www.junit.org/index.htm 和 www.extremeprogramming.org/rules/unittests.html。
关于 Test::Unit 和 ZenTest 的最新信息可以在它们的主页上找到:testunit.talbott.ws 和 www.zenspider.com/ZSS/Products/ZenTest。