内容


真实世界中的 Rails,第 4 部分

Ruby on Rails 中的测试策略

为 Rails 团队选择合适的工具和技巧

Comments

系列内容:

此内容是该系列 # 部分中的第 # 部分: 真实世界中的 Rails,第 4 部分

敬请期待该系列的后续内容。

此内容是该系列的一部分:真实世界中的 Rails,第 4 部分

敬请期待该系列的后续内容。

Rails 平台的独特之处就是 Ruby 语言本身。做为动态类型语言,Ruby 有很强的灵活性、方便性和功能性,但这些优点是有代价的。动态类型语言没有能捕捉某些错误(包括相对常见的输入错误和一些拼写错误)的编译器。从很早开始,面向对象的动态类型语言的用户就知道他们必须要进行测试。

Ruby on Rails 社区对测试的拥护程度就像美国人对 American Idol 节目的热衷一样。他们会定期查看测试用例结果。Ruby 的开发人员时常会谈及测试、将测试在 blog 上大书特书,有时甚至在幕后参与:参与的方式多是通过为开源框架做贡献,这与选 American Idol 通过手机投票进行参与的方式不同。

没有测试,Ruby 应用程序每行代码的出错率要比有测试的情况高很多。有了测试,您就能享有动态类型语言的诸多益处,而不利之处则更少。在本篇文章中,我不想谈论那些您已经十分熟悉的基本知识,比如是否需要测试或怎样才能说服经理让他认可测试是值得的。(我假设您进行过测试。)与之相反,我会对每个 Ruby 项目主管最终都必须要做的一些微妙的测试决定详加介绍。我会谈及如何测量测试覆盖率以及究竟应该进行多少测试。此外,我还会讲解基础的开箱即用的测试技术以及它们与新的 mock 框架相比孰优孰劣。与提供一个包罗万象的教程相反,我带给您的是一些我们在构建 ChangingThePresent.org(请参阅 参考资料)时所使用的技术的示例,以便您能对这些技术有一个基本认识。此外我还会详细地分析各种技术的优缺点。

开箱即用的 Rails 测试

在添加单个的 gem 前,Rail 框架所具有的测试异常健壮。只需很少的操作,就能指定可重复的数据库设置、向 Web 应用程序发送模拟的 HTTP 消息,并能进行三种测试:单元测试、功能测试和集成测试。下一节给出了这些测试的简单例子。

单元测试

单元测试针对的是 Rails 模型代码,有时还包括帮助器。单元测试用于确保模型能够按其构建之初的目标工作和确保模型中的关联能按预期运转。您应该还记得,Rails 模型是包装单一数据库表的对象。大多数情况下,每个数据库列都是具体某个模型上的一个属性。Rails 帮助器是帮助简化模型、视图、控制器代码的函数。需要确保每个模型或帮助器都有测试。在 ChangingThePresent,我们为最基础的模型而做的单元测试不多。

清单 1. 一个基础模型测试
require File.dirname(__FILE__) + '/../test_helper'

class BannerStyleTest < Test::Unit::TestCase
  fixtures :banner_styles

  def test_associations
    assert_working_associations
  end

  def test_validation_with_incorrect_specs_should_fail
    bs =  BannerStyle.new(:height => 10, :width => 10, :format => 'vertical_rectangle',
                          :model_name => 'Nonprofit')
    assert !bs.save, bs.errors.inspect

    bs2 =  BannerStyle.new(:height => 400, :width => 240, :format => 'vertical_rectangle',
                          :model_name => 'Nonprofit')
    assert bs2.save, bs2.errors.inspect
  end

  ...
end

清单 1 中所示的是具有两个测试的浓缩测试用例。Banner_styles 创建简单的广告横幅。每个横幅的大小和形状都基于一组松散的标准。此应用程序使用一个包含这些标准的表确保任何一个新横幅都符合这些标准。第一个测试使用帮助器通过反射检验 BannerStyle 上的所有关联,如清单 2 所示。第二个测试确保高和宽不符合标准的横幅不被保存,具有有效规格的横幅则被正确保存。

清单 2. 用于测试有效关联的帮助器
def assert_working_associations(m=nil)
  m ||= self.class.to_s.sub(/Test$/, '').constantize
  @m = m.new
  m.reflect_on_all_associations.each do |assoc|
    assert_nothing_raised("#{assoc.name} caused an error") do
      @m.send(assoc.name, true)
    end
  end
  true
end

清单 2 显示了能检验一个类中所有关联的帮助器。assert_working_associations 方法遍历此类上的所有关联并将关联的名称发送给该模型。这种 “一网打尽” 的做法能确保用每个模型使用一行代码即可调用所有模型测试中存在的关系。

功能和集成测试

功能测试通过孤立的 HTTP 请求检验用户接口。Rails 框架很利于调用单一 HTTP GET 和 POST 命令,是这类测试的骨干。集成测试也一样,但它们能一个接一个的调用很多 HTTP 请求。这类测试的原理和结构大都相同。清单 3 给出了一些基础的功能测试。

清单 3. 一个简单的功能测试
require File.dirname(__FILE__) + '/../test_helper'
require 'causes_controller'

class CausesController; def rescue_action(e) raise e end; end

class CausesControllerTest < Test::Unit::TestCase
  fixtures :causes, :members, :quotes, :cause_images, :blogs, :blog_memberships

  def setup
    @controller = CausesController.new
    @request    = ActionController::TestRequest.new
    @response   = ActionController::TestResponse.new
  end

  def test_index
    get :index

    assert_response :success
    assert_template 'index'

    assert_not_nil assigns(:causes)
    assert_equal Cause.find_all_ordered.size, assigns(:causes).size
  end

  def test_should_create_blog
    assert Cause.find(2).blog.nil?
    get :create_blog, :id => 2
    assert Cause.find(2).blog.nil?

    login_as :bruce
    get :create_blog, :id => 2
    assert !Cause.find(2).blog.nil?
    assert_equal Cause.find(2).name, Cause.find(2).blog.title
  end

从清单 3 可以看出测试和系统间的交互都是通过 HTTP GET 和 POST 命令进行的。测试的基本流程如下所示:

  1. 发起一个简单的 HTTP 操作。
  2. 测试此 HTTP 操作对系统的影响。

此外,清单 3 的 setup 方法搭建了测试支架以模拟 HTTP 调用。这个测试支架消除了对网络和基础设施的需求,从而将此测试用例只限制在这个应用程序本身。

Stub

我们为 ChangingThePresent.org 添加了几个测试帮助器方法,使其很容易地做一些事情,比如登录。在清单 3 中 test_should_create_blog 方法的第 5 行,可 以看到 login_as :bruce 方法调用。它调用清单 4 中的帮助器,将成员的 ID 直接复制给会话。如果使用过 Rails acts_as_authenticated,就会知道登录用户会设置与会话中的 :user 密钥相关联的值。

清单 4. 登录
def login_as(member)
  @request.session[:user] = member ? members(member).id : nil
end

很多开发人员对 stub 和 mock 这两个概念都不是很清楚,很容易混淆。stub 即是用更简单的实现代替真实世界中的实现。在清单 4 中的这个 stub 就用一个简单的替代物代替了我们的整个登录系统。stub 的作用就是模拟真实世界。mock 则不同,mock 对象更像一个测量工具,可以测量应用程序使用接口的方式。我在本文后面的部分将会更详细地讨论 stub 并会给出一些示例。

基本概念

至此,您已经看到了 Rails 开箱即用的测试的精髓。在进一步深入讨论前,先来给出两个核心决定:多少和多快?在形成整体的测试理念的过程中,您非常需要关注如何围绕覆盖率和速度进行权衡。

覆盖率

您所需要做出的最关键的决定之一就是究竟需要进行多少测试。如果测试不充分,就会危及代码的质量,而且往往会延迟交付时间。但如果测试过多,又往往会造成不能按预期时间完成,这对于业务而言十分严重。要明智地决定究竟需要多少测试,前提是必须能准确地测量已经进行了多少测试。代码覆盖率就是最重要的测试指标之一。

对于 ChangingThePresent,我们使用 RCov 确定测试覆盖率。我可以运行传统的 rake 命令并利用传统的点轨迹图做为报告。我也可以运行 rake test:coverage 获取更完整的报告,如清单 5 中所示。

清单 5. 运行 rake:用 RCov 确定覆盖率
807 tests, 2989 assertions, 0 failures, 0 errors
+----------------------------------------------------+-------+-------+--------+
|                  File                              | Lines |  LOC  |  COV   |
+----------------------------------------------------+-------+-------+--------+
|app/controllers/address_book_controller.rb          |   142 |   123 |  84.6% |
|app/controllers/admin_controller.rb                 |    77 |    65 |  93.8% |
|app/controllers/advisor_admin_controller.rb         |    86 |    63 |  88.9% |
|app/controllers/advisors_controller.rb              |    52 |    42 | 100.0% |

...


|app/models/stupid_gift.rb                           |    56 |    45 | 100.0% |
|app/models/stupid_gift_image.rb                     |    10 |    10 | 100.0% |
|app/models/titled_comment.rb                        |     2 |     2 | 100.0% |
|app/models/upgrade.rb                               |    13 |    10 | 100.0% |
|app/models/upgrade_item.rb                          |     3 |     3 | 100.0% |
|app/models/validation_model.rb                      |     7 |     7 | 100.0% |
|app/models/volunteer_opportunity.rb                 |   137 |   129 |  93.0% |
|app/models/work_period.rb                           |     5 |     4 | 100.0% |
+----------------------------------------------------+-------+-------+--------+
|Total                                               | 12044 | 10044 |  81.8% |
+----------------------------------------------------+-------+-------+--------+
81.8%   167 file(s)   12044 Lines   10044 LOC

RCov 的运行要用比较长的时间,所以我不会经常运行此命令,但当我运行它时,我就能准确地确定任何给定文件上的测试覆盖率。更妙的是,我可以在浏览器中打开一个覆盖率文件并查看测试用例所覆盖的代码行。图 1 显示了一个典型的覆盖率报告。

图 1. 一个实际的 RCov 报告
图 1. 一个实际的 RCov 报告
图 1. 一个实际的 RCov 报告

有了数字,就可以开始做一些关于需要进行多少测试的大致决定。对于 ChangingThePresent,我们已有的测试覆盖率统计数据有些浮动,但我们还是设置了 80% 和 85% 间的一个数字。由于一些重要的新特性尚在开发当中,所以覆盖率会临时有所下降。一旦我们将这些重要的新特性放到网上,覆盖率就会增加。当前的覆盖率为 81.7%。

要注意,我们的覆盖率会和您的不完全一样。测试覆盖率的多少取决于开发团队中开发人员的经验、应用程序的复杂性、软件的容错程度以及业务对延时的容忍程度。如果构建的是一个飞机工程应用程序,那么就会需要进行更多的测试;如果要为 Facebook 构建只会流行一时的 Web 2.0 应用程序,而该应用程序两个月后就变得毫无价值(除非您能独辟蹊径),那么就可以进行较少的测试。所有我认识的优秀 Ruby 程序员都建议产品代码的覆盖率要高于 80%,有的甚至建议要尽力达到 100%。

即使获得了 100% 的覆盖率,也不能保证测试就真正能够发挥作用。还必须考虑测试的类型,结合成功路径和边界条件,来获得尽可能好的覆盖率。

了解用于判断需要进行多少测试的工具之后,我们就可以转换话题,谈谈测试速度。对 Rails 而言,数据库决定测试的速度。对受数据库支撑的测试工具的种种尝试总是因问题太多而受阻。其中两个最大的问题就是重复性和速度。从重复性的角度来看,在不更改数据库的情况下,要想构建一个很好的测试套件是非常困难的,但若更改数据库,测试数据也会更改,而这反过来又会改变测试的行为。另一个问题是速度。更改数据库的代价太高。

速度

正如您所知道的,Rails 环境用固件解决重复性问题。开发人员一般会用测试数据设立固件。在处理每个测试用例前,Rails 测试框架会完全删除每个模型的数据并加载为每个测试用例所指定的每个固件。这样一来,每个测试用例都会有一个整洁的开始。但每个测试用例都有多个测试,并且每个测试都应完全独立于其他测试。为每个测试加载整套固件显然会非常慢。

Rails 巧妙地解决了部分速度问题。在运行每个测试用例后,Rails 回滚所有数据库更改。回滚比重新加载所有固件数据要快得多。但数据库访问的代价却还是不容忽视。即使使用了回滚,受数据库支撑的测试仍很慢。如果测试太慢,开发人员就不愿意运行它们。如果测试得不到运行,就相当于没有什么实际用处。尽管 Rails 解决了重复性的问题,但它却不能完全解决速度问题。测试的速度将会影响未来几年的测试策略。

另一个可选的方式是为测试使用内存数据库。通常 SQLite 要比 MySQL 运行得快很多。这种方式的缺点是可能无法在与生产系统相同的平台上运行测试。

如果为数据库支撑使用了 ActiveRecord,则很可能会用受数据库支撑的测试进行所有的单元测试。这样一来,您将不得不接受低速做为开发的代价。但没有规定要求必须使用受数据库支撑的模型去做功能测试。现在很多 Rails 开发人员都使用 stub 和 mock 替代数据库,这就使功能测试变得十分轻快。

用 Mocha 和 FlexMock 进行 Mock 和 stub

前面,我解释了 stub 是一种用更简单的实现替代真实世界中的实现的技术。测试用例可使用这种技术使实现变得更简单、更快、更容易预测。例如,您可能希望系统时钟总是返回相同的时间以便测试能够有可验证的重复结果。

Mocha 框架使进行 stub 变得十分容易。您只需定义好想要的结果。下面的代码对系统类 Date 进行 stub 以便总是返回相同的日期,比如 Ground Hog Day,如清单 6 所示。

清单 6. 创建一个简单的 stub
ground_hog_day = Date.new(2007, 2, 2)
Date.stubs(:today).returns(ground_hog_day)
assert_equal 2, Date.today.day

如果说 stub 提供了一个简化的真实世界模拟,那么 mock 可以做更多。有时,简单的模拟真实世界还不够。在测试时,需要确保代码能正确使用 API。例如,您可能会想要验证本地数据库应用程序是否打开了连接、执行了查询,并随后关闭了此连接。您也会想要验证控制器是否在一个模型对象上实际调用了 save。所以,mock 对象必须建立预期和行为。

Rails 有至少有 3 个 mock 库可用:Mocha、FlexMock 和 RSpec。我重点介绍 Mocha,但 3 个库中的每一个都有其自身的优势。对于 Mocha,需要实际清楚地给出每个预期的 API 调用,后跟 Mocha 应该返回的结果,如清单 7 中所示。

清单 7. Mocha mock 库
mock_member = mock("member_67")
mock_member.expects(:valid?).returns(true)
mock_member.expects(:save).returns(true)
mock_member.expects(:valid_captcha=).with(true)
mock_member.expects(:plaintext_password).returns('password')
mock_member.expects(:id).returns(67)
Member.expects(:find_by_login).with(nil).returns(mock_member)

post :create, :member => {}, :nonprofit => {:id => 67}

...

assert_response :redirect
assert_redirected_to :controller => 'nonprofits', :action => 'show',
 :id => mock_nonprofit_id

清单 7 显示了一个用于创建新成员的测试用例。可以为控制器和 mock 用户之间的每个交互建立预期。创建了一个 mock 成员并单独地定义每一个交互。接下来,对这个 Member 类和能返回 mock_member 的查找器进行 mock 。

如您所见,与模型层间的交互融入得相当不错,但 mock 对象却会把成员行为完全从功能测试用例中隔离出来。这有很多直接的优势。使用某些 API,比如信用卡签出和自释开关,是不可行的。其他 API,比如基于时间或内存的服务,也不够充分。因此总是需要使用 mock 对象框架对它们进行 mock 或 stub。

更有意思的讨论是关于是否需要 mock 或 stub 受数据库支撑的模型。一个好处是速度:测试用例根本不会触及数据库。另一个好处是独立性。要测试的代码被完全限制在控制层。缺点也很明显。测试用例变得异常复杂。一旦更改模型的行为,就不得不进行连锁更改,因为必须要更改模型对象和围绕这些模型的测试用例。这样做,测试用例很容易遗漏至关重要的内容。增加单一验证可能会在完全没被觉察的情况下破坏重要的场景。出于这个原因,我们没有为 ChangingThePresent mock 模型对象类。我们只把 mock 处理限制在外部的接口,比如到第三方的 Web 服务或网络服务。

应该指出的是 Ruby 社区现在更倾向于使用 mock 策略。继续采用数据库支撑的做法多少有点不合潮流。我们两种方法都采用了。之所以这么做是因为它们非常适用于我们的具体情况和我们的代码库。

持续集成

持续集成 (CI) 是我们添加到我们总体理念中的一个最重要的增强。我们运行了 Ruby 版的 Cruise Control。我们的 CI 服务器会检查出一个干净的构建并会在我每次检查新更改时从头运行测试用例。一旦更改破坏了构建,服务器就会通知每个开发人员。此服务器允许我在检查前运行几个有代表性的测试。我可以更改 Member 的几行代码,然后运行 unit/member_test.rbfunctional/members_controller_test.rb。15 秒之后,我就可以登入,并确信 Cruise Control 一出问题,就会通知我。

结束语

几年前,围绕测试展开的一些有趣辩论似乎是解决了自动测试好与坏的问题。现在的辩论则愈加有意思:

  • 所有测试是否应该全部受数据库支撑,是否应使用 mock 和 stub 来隔离功能测试?
  • 100% 覆盖率是否现实?
  • 内存测试所带来的额外速度提高可否抵消额外风险?

我给您最好的建议是寻找适合您开发团队和客户的技术。不要听凭某些专家的一面之词。在今后的几年,我们常常会发现之前我们所找到的答案并不全面。新的技术层出不穷,已有的技术则会逐渐淡出人们的视线。纸上写的东西并不总是能反映真实世界中的 Rails。


相关主题

  • 您可以参阅本文在 developerWorks 全球站点上的 英文原文
  • ChangingThePresent.org:面向捐助者的非盈利网站,是本文的基础。在这里,为了奉献爱心,癌症研究员可以贡献一小时的研究时间,您也可以资助白内障手术。
  • 了解有关 CruiseControl.rb 持续集成 的更多内容。ThoughtWorks 持续集成服务器为我们节省了大量的编程痛苦。
  • Mocks are not stubs:Martin Fowler 永恒的经典论述为您分析了 stub 和 mock 对象间的差异。
  • FlexMock 是时下颇受青睐的 Ruby mock 框架。
  • Mocha 是我们为 ChangingThePresent 所使用的 mock 框架。
  • RCov 是针对 Ruby 的一款很好的测试覆盖率工具。
  • 访问 Ruby and Rails 技术资源中心,这里整理了和 Ruby 动态语言以及十分流行的开源 Web 开发框架 Ruby on Rails 相关的技术文章、教程和相关资源。

评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Web development
ArticleID=251742
ArticleTitle=真实世界中的 Rails,第 4 部分: Ruby on Rails 中的测试策略
publish-date=08282007