在 Forge - 使用 Rails 进行测试
在过去的几个月中,我们研究了 Ruby on Rails,这是一个用 Ruby 语言编写的开源 Web 应用程序框架,旨在使 Web/数据库开发特别容易。我们已经研究了 Rails 开发的许多方面——将应用程序划分为模型、视图和控制器组件;子类化 ActiveRecord 以实现自动对象/数据库映射;以及使用 Rails 验证器来确保数据库中存储数据的完整性。
自从几个月前开始使用 Rails 以来,我对它的设计和执行印象深刻。在 Rails 中开发起初感觉有点不同和有趣,但您可以很快上手,享受 ActiveRecord 自动处理了大部分繁琐工作的事实。
尽管 Rails 可以减少我们在 Web/数据库应用程序中必须编写的代码量,但它不能将其减少到零。而且,只要有代码,就必然会有错误。正如经验丰富的 Web/数据库开发人员所知,测试这类应用程序可能有点棘手,因为客户端发送和看到的内容与服务器实际发生的情况之间存在脱节。即使在今天,Web 开发人员仍然使用打印语句和错误日志的组合来调试他们的应用程序,这并不罕见。事实上,我个人在很多情况下都犯过这种错误,部分是出于习惯,部分是因为这通常是查找项目问题的最佳方法。
经理和程序员都知道,在错误发生后立即修复错误,以及在项目的早期阶段修复错误是最便宜和最容易的。但是程序员通常不愿意测试他们的软件,尤其是当这种测试可能既耗时又乏味时。
因此,在过去的几年中,出现了一种相对简单的解决方案,程序员不仅负责测试他们已经开发的软件,还负责编写测试,以便尽可能多地检查软件。这种单元测试可以帮助确保系统的每个独立部分都是健壮的,从而使我们在将其集成到完整的应用程序中时可以依赖它。
本月,我们将研究内置的 Rails 功能,用于执行单元测试,并激发我们编写功能测试的兴趣。
到目前为止,我们所看到的一切似乎都合情合理,但在表面之下潜伏着危险。如果我们真的继续测试我们所有的代码,我们很可能会最终在实际数据库中添加、修改和删除数据。在严肃的生产系统上,这可能不仅仅是不方便,还可能导致无法估量的问题。
如果您从我们第一次开始使用 Rails 就一直关注,您可能会记得我们为我们处理的每个项目定义了三个不同的数据库。在我们过去几个月研究的简单 Weblog 应用程序的案例中,我们创建了三个数据库:blog_development、blog_test 和 blog_production。我们完全忽略了 _test 和 _development 数据库,只专注于 _production 版本。现在我们将开始测试我们的应用程序,我们将使用 _test 数据库。只有当我们确信我们的数据库和应用程序已通过测试套件后,我们才会将其移至生产系统。
如果您尚未这样做,请创建测试数据库并加载其定义。在我的系统上,我为 PostgreSQL 创建了一个博客用户并执行了以下操作
$/usr/local/pgsql/bin/createdb -U blog blog_test
然后,我加载了保存在 blog/db 目录中的数据库定义,在一个名为 create.sql 的文件中
$ /usr/local/pgsql/bin/psql -U blog blog_test < blog/db/create.sql
这将加载表定义。假设当我对开发数据库执行相同的操作时,create.sql 是相同的,我现在可以假设开发数据库和测试数据库以相同的方式定义。
但是,如果我们没有很好地更新 create.sql 以反映我们对开发数据库所做的每次修改会怎么样?那么我们是否被迫手动比较两个数据库结构,更新 create.sql,然后重新导入定义?
幸运的是,答案是否定的。Rails 附带了一个简短的程序 clone_structure_to_test,该程序将开发数据库的结构复制到测试数据库。请注意,它只复制结构,而不复制内容。要调用它,请切换到主应用程序目录(在我们的例子中为 blog),并使用 rake 或 Ruby make 程序,该程序执行当前目录中 Rakefile 的相应部分
$ rake clone_structure_to_test
如果 blog_test 数据库尚不存在,或者存在其他问题,您将收到错误消息。否则,您只会看到基本输出,就像我一样
[reuven@server blog]$ rake clone_structure_to_test (in /home/reuven/blog)
我在 clone_structure_to_test 中遇到了一些初始问题,脚本声称我不是 PostgreSQL 中公共模式的所有者。我通过授予博客数据库用户超级用户权限来解决了这个问题,这对于克隆过程正常工作是必要的
$ /usr/local/pgsql/bin/psql blog_test blog_development=# alter user blog createuser; ALTER USER
现在我们已经建立了测试数据库,我们可以开始编写一些测试了。但是我们将把它们放在哪里呢?Rails 以其典型的风格,已经为测试定义了一个位置,并假设我们将遵循与作者和其他 Rails 用户相同的约定。这意味着查看 blog/test 目录,与 blog/app 和 blog/db 目录并行。
blog/test 目录包含四个子目录和一个 Ruby 代码文件,所有这些都是 Rails 应用程序中的标准配置。这四个子目录是 fixtures、functional、mocks 和 unit,指的是我们期望创建或修改的测试机制的不同部分。
但是,在我们开始测试之前,我们需要克服一个问题:如果我们想测试我们的应用程序,我们应该首先用数据填充数据库表。此外,我们希望每次运行测试时都是相同、一致的数据,以便我们知道正在测试什么。Rails 使用 fixtures 解决了这个问题,fixtures 会在我们想要测试之前自动填充我们的测试数据库。我们系统中的 blog/test/fixtures 目录是我们可以在其中创建此类 fixtures 的位置,通常使用 YAML,另一种标记语言,其结构主要通过缩进确定。
我们为我们要测试的每个数据库表创建一个 YAML 文件。我们的数据库只包含一个表,因此我们只需要创建一个 YAML 文件 blogs.yml。果然,当我们查看 blog/test/fixtures 目录时,我们看到那里已经有这样一个文件,演示了我们如何创建 fixtures,并指向 ar.rubyonrails.org/classes/Fixtures.html 的文档,以防我们不完全理解 fixtures 的工作原理。
我们现在在我们的 blog.yml 文件中创建一个或多个条目,每个条目对应于 Blogs 数据库表中的一行,进而对应于 blog/app/models 目录中定义的 Blog 对象的一个实例。作为提醒,我们的表定义如下
CREATE TABLE Blogs ( id SERIAL NOT NULL, title TEXT NOT NULL, contents TEXT NOT NULL, posted_at TIMESTAMP NOT NULL DEFAULT NOW(), PRIMARY KEY(id) );
以下是我们如何为此表创建两个 fixtures
blog_entry_one: id: 1 title: My first entry! contents: It was a dark and stormy night, and I forgot my umbrella. So I decided to tell the world on my blog. posted_at: 2005-Sep-1 22:00:00 blog_entry_two: id: 2 title: My second entry! contents: It was much nicer this morning. posted_at: 2005-Sep-1 22:00:00
这相当于两个 INSERT 语句。鉴于 INSERT 是标准 SQL,为什么我们更喜欢使用 fixtures 呢?
首先,fixtures 确保我们始终从相同的基线数据开始。运行测试,却因为重复数据触发了唯一性约束而导致测试失败,这真是令人沮丧。
第二个原因是它允许我们不仅在数据库本身中测试我们的数据库对象,还可以从源头进行测试。也就是说,Rails 测试系统将我们的 YAML 文件中的数据加载到数据库中,然后通过我们的模型对象访问它们。然后,它第二次从我们的 YAML 文件加载值,并通过哈希使它们可用。然后我们可以比较两者,确保数据在我们开始测试更复杂的方法之前已正确导入。
有了 fixtures,我们现在可以开始执行我们的第一个单元测试了。单元测试检查单个方法和对象。如果应用程序的组件通过了一整套单元测试,仍然存在出错的可能性。但是,这些错误往往来自单元的集成,这由另一组测试涵盖。
毫不奇怪,单元测试是在我们主应用程序目录中的 test/unit 目录内定义的。Rails 会自动为您定义的每个模型类创建一个单元测试文件;因此,我的 test/unit 目录包含一个名为 blog_test.rb 的文件,对应于 app/models/blog.rb 中定义的 Blog 类。(请记住:Rails 模型对象具有单数名称,并指向具有复数名称的数据库表。)
默认情况下,该文件包含我们单元测试的骨架
require File.dirname(__FILE__) + '/../test_helper' class BlogTest < Test::Unit::TestCase fixtures :blogs def setup @blog = Blog.find(1) end # Replace this with your real tests. def test_truth assert_kind_of Blog, @blog end end
第一行有助于引导测试机制,并且不是当前关注的重点。但是,然后我们看到我们正在定义一个类(名为 BlogTest,使用 Rails 命名约定,我们期望在名为 blog_test.rb 的文件中看到它)。BlogTest 是 Test::Unit::TestCase 的子类,它随 Rails 一起提供,并为我们提供了许多不同的与测试相关的功能。
BlogTest 的定义以声明 fixtures 开始,其值为 :blogs——表明当 Rails 想要使用 BlogTest 对象测试我们的 Blog 对象时,它应该首先使用 test/fixtures/blogs.yml 中定义的 fixtures 填充测试数据库。如果我们有兴趣使用多个 fixtures,我们也可以命名它们
fixtures :blogs, :foo, :bar
默认情况下,为我们定义了两个方法,分别称为 setup 和 test_truth。setup 方法,正如您从其名称中可能期望的那样,为我们的测试做好准备。在这种特定情况下,它调用 Blog.find(),并为其提供参数 1。换句话说,它检索主键为 1 的对象(即 blog_entry_one)并将其放入 @blog 中。Perl 程序员可能需要记住,在 Ruby 中,@blog 是一个实例变量,不一定是数组。在这种特定情况下,@blog 包含 Blog 的单个实例——Blog.find(1) 返回的对象。
另一个方法 test_truth 然后使用 Rails 附带的预定义测试断言之一。在这种特定情况下,我们使用 assert_kind_of 断言来检查 @blog 是否是 Blog 的实例。
要运行此初始版本的测试,我们只需说
$ ruby blog_test.rb
当测试运行时,我们会收到状态报告。如果一切顺利,输出应如下所示
[reuven@server unit]$ ruby blog_test.rb Loaded suite blog_test Started . Finished in 19.066227 seconds. 1 tests, 1 assertions, 0 failures, 0 errors
在我第一次运行这些测试时,我收到了错误消息。第一个问题是我未能将 blogs.yml 从其原始默认状态更改,而没有定义除 id 主键字段之外的任何内容。由于我对 Blogs 表施加了完整性约束,PostgreSQL 停止了测试,表明它不允许 title 字段中的 NULL 值。
第二次我尝试运行测试时,Rails 发现了我的 YAML 文件中的错误,提醒我 YAML 格式要求使用空格进行一致的缩进,并且没有任何制表符。
Rails 明智地区分了失败和错误;上述两者都被归类为错误,让我专注于整体测试环境,而不是特定方法。
我们可以通过定义新方法来添加其他测试。例如,让我们检查 @blog 的标题是否与我们放入 YAML 文件中的值匹配。我们可以将以下内容添加到 BlogTest 类定义中
def test_title assert_equal @blogs["blog_entry_one"]["title"], @blog.title end
请注意我们的测试是如何以字符 test_ 开头的。这告诉 Rails 此方法应成为我们测试套件的一部分。由于每个单独的方法都是单独计算的,因此最好有大量测试方法,每个方法都包含少量断言。没有技术原因说明您不能在同一方法中放入大量断言,但这意味着您可能更难理解问题出在哪里。
在本例中,我们使用 assert_equal 来检查两个量是否相等。请密切注意非常相似的名称,您就会明白我们在做什么。要测试相等性的第一个项目是 @blogs[“blog_entry_one”][“title”]。@blogs 哈希是由测试套件自动为我们创建的,并且(如前所述)包含整个 YAML fixture 定义文件。如果 @blogs 包含整个 YAML 定义,则 @blogs[“blog_entry_one”] 包含第一个 fixture,而 @blogs[“blog_entry_one”][“title”] 包含第一个 fixture 的标题。
相比之下,@blog 具有单数名称,因为它仅包含 Blog 的单个实例。与所有优秀的 ActiveRecord 后代对象一样,我们可以使用方法来检索字段的内容——在本例中为 @blog.title。因此,最重要的是,此测试有助于我们检查标题是否相同。
以上只是您可能希望在系统上使用的测试类型中的两种。Rails 附带了大量的断言集合,允许您以各种方式测试您的模型。
请记住,方法只是测试方程式的一部分;您还需要在表定义中具有适当的完整性约束和检查,以及各种各样的输入,以确保您正在检查许多不同的可能性。创建大量 fixtures 的一种方法是动态创建它们,使用与 Rails 视图中使用的相同语法(称为 ERb 或 Embedded Ruby)。
正如我在上面提到的,功能测试是任何应用程序测试套件中的另一个重要元素。功能测试针对 Rail 控制器运行,其工作方式与我们的单元测试类似——在 tests/functional 目录中,每个控制器一个测试对象,并且每个控制器对象中的每个方法都有一个 test_ 方法。测试模型确保您的数据将是健壮的;测试控制器确保无论您通过 Web 从用户那里收到什么输入,应用程序都将优雅地处理这种情况。
最后,Rails 使创建 mock 对象变得容易,使我们能够轻松地假装已创建对象。例如,我们可能想要假装信用卡交易已完成,或者我们已向系统的 50,000 名用户发送了电子邮件,而实际上没有执行该任务。
Web 应用程序正变得越来越庞大和复杂,以至于它们需要严谨的测试技术来避免不可预见的问题。Ruby on Rails 附带了一个集成的测试系统,可以轻松地在所有级别(数据库、模型对象和控制器对象)创建和使用测试。许多 Ruby 开发人员是测试驱动开发的粉丝,这不足为奇,部分原因是 Ruby 和 Rails 环境使其如此容易实现。如果您要使用 Rails 进行开发,那么值得花额外的时间将测试添加到您的应用程序中。这很容易做到,并且可以为您节省大量的时间。
本文的资源: /article/8631。
Reuven M. Lerner,一位长期的 Web/数据库顾问和开发人员,现在是西北大学学习科学项目的一名研究生。他的 Weblog 在 altneuland.lerner.co.il,您可以通过 reuven@lerner.co.il 与他联系。