如何使用 ZenTest 与 Ruby

作者:Pat Eyler

重构和单元测试是一对强大的工具,应该成为每位程序员工作台的一部分。遗憾的是,并非每位程序员都知道如何使用这些工具。我第一次接触它们是在我开始使用 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.htmwww.extremeprogramming.org/rules/unittests.html

关于 Test::Unit 和 ZenTest 的最新信息可以在它们的主页上找到:testunit.talbott.wswww.zenspider.com/ZSS/Products/ZenTest

加载 Disqus 评论