更多内容请访问 rubyonrails.org:

Rails 应用程序测试

本指南涵盖了 Rails 中用于测试应用程序的内置机制。

阅读本指南后,您将了解

  • Rails 测试术语。
  • 如何为您的应用程序编写单元、功能、集成和系统测试。
  • 其他流行的测试方法和插件。

1 为什么要为您的 Rails 应用程序编写测试?

Rails 使编写测试变得非常容易。它从创建模型和控制器时生成测试骨架代码开始。

通过运行 Rails 测试,您可以确保您的代码即使在进行重大代码重构后也能符合预期的功能。

Rails 测试还可以模拟浏览器请求,因此您可以测试应用程序的响应,而无需通过浏览器进行测试。

2 测试简介

测试支持从一开始就融入 Rails 结构中。这不是一个“哦!让我们添加运行测试的支持,因为它们很新很酷”的顿悟。

2.1 Rails 从一开始就为测试做好准备

使用 rails new application_name 创建 Rails 项目后,Rails 会为您创建一个 test 目录。如果您列出此目录的内容,您将看到

$ ls -F test
application_system_test_case.rb  controllers/                     helpers/                         mailers/                         system/
channels/                        fixtures/                        integration/                     models/                          test_helper.rb

helpersmailersmodels 目录分别用于保存视图助手、邮件器和模型的测试。channels 目录用于保存 Action Cable 连接和频道的测试。controllers 目录用于保存控制器、路由和视图的测试。integration 目录用于保存控制器之间交互的测试。

系统测试目录保存系统测试,这些测试用于对应用程序进行完整的浏览器测试。系统测试允许您像用户体验一样测试您的应用程序,并帮助您测试 JavaScript。系统测试继承自 Capybara 并对您的应用程序执行浏览器内测试。

夹具是组织测试数据的一种方式;它们位于 fixtures 目录中。

当首次生成相关测试时,还会创建一个 jobs 目录。

test_helper.rb 文件保存测试的默认配置。

application_system_test_case.rb 保存系统测试的默认配置。

2.2 测试环境

默认情况下,每个 Rails 应用程序都有三个环境:开发、测试和生产。

每个环境的配置都可以类似地修改。在这种情况下,我们可以通过更改 config/environments/test.rb 中找到的选项来修改测试环境。

您的测试在 RAILS_ENV=test 下运行。

2.3 Rails 与 Minitest

如果您还记得,我们在 Rails 入门 指南中使用了 bin/rails generate model 命令。我们创建了第一个模型,除了其他事情外,它还在 test 目录中创建了测试存根

$ bin/rails generate model article title:string body:text
...
create  app/models/article.rb
create  test/models/article_test.rb
create  test/fixtures/articles.yml
...

test/models/article_test.rb 中的默认测试存根如下所示

require "test_helper"

class ArticleTest < ActiveSupport::TestCase
  # test "the truth" do
  #   assert true
  # end
end

逐行检查此文件将帮助您熟悉 Rails 测试代码和术语。

require "test_helper"

通过引入此文件 test_helper.rb,将加载运行测试的默认配置。我们将将其包含在所有编写的测试中,因此添加到此文件中的任何方法都可用于所有测试。

class ArticleTest < ActiveSupport::TestCase
  # ...
end

ArticleTest 类定义了一个测试用例,因为它继承自 ActiveSupport::TestCase。因此,ArticleTest 可以使用 ActiveSupport::TestCase 提供的所有方法。在本指南的后面,我们将看到它提供的一些方法。

在从 Minitest::Test(这是 ActiveSupport::TestCase 的超类)继承的类中定义的任何以 test_ 开头的函数都称为测试。因此,定义为 test_passwordtest_valid_password 的函数是合法的测试名称,并在运行测试用例时自动运行。

Rails 还添加了一个 test 函数,该函数接受一个测试名称和一个代码块。它生成一个普通的 Minitest::Unit 测试,方法名称以 test_ 为前缀。因此,您不必担心命名函数,并且可以编写类似的内容

test "the truth" do
  assert true
end

这与编写以下内容大致相同

def test_the_truth
  assert true
end

虽然您仍然可以使用常规函数定义,但使用 test 宏可以让测试名称更易读。

方法名称是通过将空格替换为下划线生成的。结果不需要是有效的 Ruby 标识符,因为名称可能包含标点符号等。因为在 Ruby 中,任何字符串在技术上都可以用作方法名称。这可能需要使用 define_methodsend 调用才能正常工作,但实际上对名称的限制很少。

接下来,让我们看看第一个断言

assert true

断言是一行代码,用于评估对象(或表达式)以获取预期结果。例如,断言可以检查

  • 这个值是否等于那个值?
  • 这个对象是否为 nil?
  • 这行代码是否抛出异常?
  • 用户的密码是否大于 5 个字符?

每个测试可能包含一个或多个断言,对允许的断言数量没有限制。只有当所有断言都成功时,测试才会通过。

2.3.1 第一个失败的测试

要查看测试失败如何报告,您可以在 article_test.rb 测试用例中添加一个失败的测试。

test "should not save article without title" do
  article = Article.new
  assert_not article.save
end

让我们运行这个新添加的测试(其中 6 是定义测试的行号)。

$ bin/rails test test/models/article_test.rb:6
Run options: --seed 44656

# Running:

F

Failure:
ArticleTest#test_should_not_save_article_without_title [/path/to/blog/test/models/article_test.rb:6]:
Expected true to be nil or false


bin/rails test test/models/article_test.rb:6



Finished in 0.023918s, 41.8090 runs/s, 41.8090 assertions/s.

1 runs, 1 assertions, 1 failures, 0 errors, 0 skips

在输出中,F 表示失败。您可以看到与 Failure 下方显示的失败测试名称相对应的跟踪信息。接下来的几行包含堆栈跟踪,然后是包含断言实际值和预期值的错误消息。默认的断言消息仅提供足够的信息来帮助您找出错误。为了使断言失败消息更易读,每个断言都提供了一个可选的消息参数,如下所示

test "should not save article without title" do
  article = Article.new
  assert_not article.save, "Saved the article without a title"
end

运行此测试将显示更友好的断言消息

Failure:
ArticleTest#test_should_not_save_article_without_title [/path/to/blog/test/models/article_test.rb:6]:
Saved the article without a title

现在要使此测试通过,我们可以为标题字段添加模型级别的验证。

class Article < ApplicationRecord
  validates :title, presence: true
end

现在测试应该通过了。让我们通过再次运行测试来验证

$ bin/rails test test/models/article_test.rb:6
Run options: --seed 31252

# Running:

.

Finished in 0.027476s, 36.3952 runs/s, 36.3952 assertions/s.

1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

现在,如果您注意到,我们首先编写了一个针对所需功能失败的测试,然后编写了一些添加功能的代码,最后我们确保我们的测试通过。这种软件开发方法称为 测试驱动开发 (TDD)

2.3.2 错误的样子

要查看错误是如何报告的,这里有一个包含错误的测试

test "should report error" do
  # some_undefined_variable is not defined elsewhere in the test case
  some_undefined_variable
  assert true
end

现在您可以看到从运行测试中在控制台中获得更多输出

$ bin/rails test test/models/article_test.rb
Run options: --seed 1808

# Running:

.E

Error:
ArticleTest#test_should_report_error:
NameError: undefined local variable or method 'some_undefined_variable' for #<ArticleTest:0x007fee3aa71798>
    test/models/article_test.rb:11:in 'block in <class:ArticleTest>'


bin/rails test test/models/article_test.rb:9



Finished in 0.040609s, 49.2500 runs/s, 24.6250 assertions/s.

2 runs, 1 assertions, 0 failures, 1 errors, 0 skips

注意输出中的 'E'。它表示一个带有错误的测试。

每个测试方法的执行在遇到任何错误或断言失败时立即停止,测试套件将继续执行下一个方法。所有测试方法都以随机顺序执行。config.active_support.test_order 选项可用于配置测试顺序。

当测试失败时,会显示相应的回溯。默认情况下,Rails 会过滤回溯,并且只会打印与您的应用程序相关的行。这消除了框架噪音,并有助于您关注自己的代码。但是,在某些情况下,您可能想要查看完整的回溯。设置 -b(或 --backtrace)参数以启用此行为

$ bin/rails test -b test/models/article_test.rb

如果我们想让此测试通过,我们可以修改它以使用 assert_raises,如下所示

test "should report error" do
  # some_undefined_variable is not defined elsewhere in the test case
  assert_raises(NameError) do
    some_undefined_variable
  end
end

此测试现在应该通过。

2.4 可用的断言

到目前为止,您已经对一些可用的断言有了一瞥。断言是测试中的工作蜂。它们是实际执行检查以确保一切按计划进行的人。

以下是您可以使用 Minitest(Rails 使用的默认测试库)使用的断言的摘录。[msg] 参数是您可以指定的可选字符串消息,以使您的测试失败消息更清晰。

断言 目的
assert( test, [msg] ) 确保 test 为 true。
assert_not( test, [msg] ) 确保 test 为 false。
assert_equal( expected, actual, [msg] ) 确保 expected == actual 为 true。
assert_not_equal( expected, actual, [msg] ) 确保 expected != actual 为 true。
assert_same( expected, actual, [msg] ) 确保 expected.equal?(actual) 为 true。
assert_not_same( expected, actual, [msg] ) 确保 expected.equal?(actual) 为 false。
assert_nil( obj, [msg] ) 确保 obj.nil? 为 true。
assert_not_nil( obj, [msg] ) 确保 obj.nil? 为 false。
assert_empty( obj, [msg] ) 确保 objempty?
assert_not_empty( obj, [msg] ) 确保 obj 不为 empty?
assert_match( regexp, string, [msg] ) 确保字符串与正则表达式匹配。
assert_no_match( regexp, string, [msg] ) 确保字符串与正则表达式不匹配。
assert_includes( collection, obj, [msg] ) 确保 obj 位于 collection 中。
assert_not_includes( collection, obj, [msg] ) 确保 obj 不位于 collection 中。
assert_in_delta( expected, actual, [delta], [msg] ) 确保数字 expectedactual 彼此之间的差值在 delta 之内。
assert_not_in_delta( expected, actual, [delta], [msg] ) 确保数字 expectedactual 彼此之间的差值不在 delta 之内。
assert_in_epsilon ( expected, actual, [epsilon], [msg] ) 确保数字 expectedactual 的相对误差小于 epsilon
assert_not_in_epsilon ( expected, actual, [epsilon], [msg] ) 确保数字 expectedactual 的相对误差不小于 epsilon
assert_throws( symbol, [msg] ) { block } 确保给定的块抛出符号。
assert_raises( exception1, exception2, ... ) { block } 确保给定的块引发给定的异常之一。
assert_instance_of( class, obj, [msg] ) 确保 objclass 的实例。
assert_not_instance_of( class, obj, [msg] ) 确保 obj 不是 class 的实例。
assert_kind_of( class, obj, [msg] ) 确保 objclass 的实例或从它继承。
assert_not_kind_of( class, obj, [msg] ) 确保 obj 不是 class 的实例,也不从它继承。
assert_respond_to( obj, symbol, [msg] ) 确保 objsymbol 响应。
assert_not_respond_to( obj, symbol, [msg] ) 确保 obj 不对 symbol 响应。
assert_operator( obj1, operator, [obj2], [msg] ) 确保 obj1.operator(obj2) 为 true。
assert_not_operator( obj1, operator, [obj2], [msg] ) 确保 obj1.operator(obj2) 为 false。
assert_predicate ( obj, predicate, [msg] ) 确保 obj.predicate 为 true,例如 assert_predicate str, :empty?
assert_not_predicate ( obj, predicate, [msg] ) 确保 obj.predicate 为 false,例如 assert_not_predicate str, :empty?
assert_error_reported(class) { block } 确保已报告错误类,例如 assert_error_reported IOError { Rails.error.report(IOError.new("Oops")) }
assert_no_error_reported { block } 确保没有报告错误,例如 assert_no_error_reported { perform_service }
flunk( [msg] ) 确保失败。这对于明确标记尚未完成的测试很有用。

以上只是 minitest 支持的一部分断言。有关详尽且最新的列表,请查看 Minitest API 文档,特别是 Minitest::Assertions

由于测试框架的模块化特性,您可以创建自己的断言。实际上,Rails 就是这样做的。它包含一些专门的断言来简化您的工作。

创建自己的断言是一个高级主题,在本教程中不会介绍。

2.5 Rails 特定的断言

Rails 在 minitest 框架中添加了一些自定义断言

断言 目的
assert_difference(expressions, difference = 1, message = nil) {...} 测试表达式返回值的数字差异,作为在 yield 块中评估的内容的结果。
assert_no_difference(expressions, message = nil, &block) 断言在调用传入的块之前和之后,评估表达式的数字结果没有改变。
assert_changes(expressions, message = nil, from:, to:, &block) 测试评估表达式的结果在调用传入的块后是否发生了变化。
assert_no_changes(expressions, message = nil, &block) 测试评估表达式的结果在调用传入的块后是否没有改变。
assert_nothing_raised { block } 确保给定的块不会引发任何异常。
assert_recognizes(expected_options, path, extras={}, message=nil) 断言给定路径的路由处理正确,并且解析的选项(在 expected_options 哈希中给出)与路径匹配。基本上,它断言 Rails 识别由 expected_options 给出的路由。
assert_generates(expected_path, options, defaults={}, extras = {}, message=nil) 断言提供的选项可用于生成提供的路径。这是 assert_recognizes 的反向操作。extras 参数用于告诉请求查询字符串中将包含的额外请求参数的名称和值。message 参数允许您为断言失败指定自定义错误消息。
assert_response(type, message = nil) 断言响应带有特定的状态代码。您可以指定 :success 来表示 200-299,:redirect 来表示 300-399,:missing 来表示 404,或 :error 来匹配 500-599 范围。您也可以传递显式状态号或其符号等效项。有关更多信息,请查看 状态代码的完整列表 以及它们的 映射 工作方式。
assert_redirected_to(options = {}, message=nil) 断言响应是重定向到与给定选项匹配的 URL。您还可以传递命名路由,例如 assert_redirected_to root_path 和 Active Record 对象,例如 assert_redirected_to @article
assert_queries_count(count = nil, include_schema: false, &block) 断言 &block 生成 int 个 SQL 查询。
assert_no_queries(include_schema: false, &block) 断言 &block 不生成任何 SQL 查询。
assert_queries_match(pattern, count: nil, include_schema: false, &block) 断言 &block 生成的 SQL 查询与模式匹配。
assert_no_queries_match(pattern, &block) 断言 &block 不生成与模式匹配的任何 SQL 查询。

您将在下一章中看到一些这些断言的用法。

2.6 关于测试用例的简短说明

Minitest::Assertions 中定义的所有基本断言(例如 assert_equal)也可以在我们在自己的测试用例中使用的类中使用。实际上,Rails 为您提供了以下类供您继承

这些类中的每一个都包含 Minitest::Assertions,允许我们在测试中使用所有基本断言。

有关 Minitest 的更多信息,请参考 其文档

2.7 事务

默认情况下,Rails 会自动将测试包装在数据库事务中,并在测试完成后回滚。这使得测试相互独立,并且对数据库的更改仅在单个测试中可见。

class MyTest < ActiveSupport::TestCase
  test "newly created users are active by default" do
    # Since the test is implicitly wrapped in a database transaction, the user
    # created here won't be seen by other tests.
    assert User.create.active?
  end
end

但是,ActiveRecord::Base.current_transaction 方法仍按预期工作。

class MyTest < ActiveSupport::TestCase
  test "current_transaction" do
    # The implicit transaction around tests does not interfere with the
    # application-level semantics of current_transaction.
    assert User.current_transaction.blank?
  end
end

如果存在多个写入数据库,则测试将包装在尽可能多的各自事务中,并且所有事务都将回滚。

2.7.1 选择退出测试事务

单个测试用例可以选择退出。

class MyTest < ActiveSupport::TestCase
  # No implicit database transaction wraps the tests in this test case.
  self.use_transactional_tests = false
end

2.8 Rails 测试运行器

我们可以使用 bin/rails test 命令一次运行所有测试。

或者,我们可以通过向 bin/rails test 命令传递包含测试用例的文件名来运行单个测试文件。

$ bin/rails test test/models/article_test.rb
Run options: --seed 1559

# Running:

..

Finished in 0.027034s, 73.9810 runs/s, 110.9715 assertions/s.

2 runs, 3 assertions, 0 failures, 0 errors, 0 skips

这将运行测试用例中的所有测试方法。

您还可以通过提供 -n--name 标志以及测试的​​方法名称来运行测试用例中的特定测试方法。

$ bin/rails test test/models/article_test.rb -n test_the_truth
Run options: -n test_the_truth --seed 43583

# Running:

.

Finished tests in 0.009064s, 110.3266 tests/s, 110.3266 assertions/s.

1 tests, 1 assertions, 0 failures, 0 errors, 0 skips

您还可以通过提供行号来运行特定行的测试。

$ bin/rails test test/models/article_test.rb:6 # run specific test and line

您还可以通过提供行范围来运行一系列测试。

$ bin/rails test test/models/article_test.rb:6-20 # runs tests from line 6 to 20

您还可以通过提供目录的路径来运行整个测试目录。

$ bin/rails test test/controllers # run all tests from specific directory

测试运行器还提供许多其他功能,例如快速失败、在测试运行结束时延迟测试输出等。请查看测试运行器的文档,如下所示

$ bin/rails test -h
Usage:
  bin/rails test [PATHS...]

Run tests except system tests

Examples:
    You can run a single test by appending a line number to a filename:

        bin/rails test test/models/user_test.rb:27

    You can run multiple tests with in a line range by appending the line range to a filename:

        bin/rails test test/models/user_test.rb:10-20

    You can run multiple files and directories at the same time:

        bin/rails test test/controllers test/integration/login_test.rb

    By default test failures and errors are reported inline during a run.

minitest options:
    -h, --help                       Display this help.
        --no-plugins                 Bypass minitest plugin auto-loading (or set $MT_NO_PLUGINS).
    -s, --seed SEED                  Sets random seed. Also via env. Eg: SEED=n rake
    -v, --verbose                    Verbose. Show progress processing files.
    -q, --quiet                      Quiet. Show no progress processing files.
        --show-skips                 Show skipped at the end of run.
    -n, --name PATTERN               Filter run on /regexp/ or string.
        --exclude PATTERN            Exclude /regexp/ or string from run.
    -S, --skip CODES                 Skip reporting of certain types of results (eg E).

Known extensions: rails, pride
    -w, --warnings                   Run with Ruby warnings enabled
    -e, --environment ENV            Run tests in the ENV environment
    -b, --backtrace                  Show the complete backtrace
    -d, --defer-output               Output test failures and errors after the test run
    -f, --fail-fast                  Abort test run on first failure or error
    -c, --[no-]color                 Enable color in the output
        --profile [COUNT]            Enable profiling of tests and list the slowest test cases (default: 10)
    -p, --pride                      Pride. Show your testing pride!

2.9 在持续集成 (CI) 中运行测试

要在 CI 环境中运行所有测试,您只需要一个命令

$ bin/rails test

如果您使用的是系统测试bin/rails test 不会运行它们,因为它们可能很慢。要运行它们,请添加另一个 CI 步骤,该步骤运行 bin/rails test:system,或者将您的第一个步骤更改为 bin/rails test:all,它运行所有测试,包括系统测试。

3 并行测试

并行测试允许您并行化测试套件。虽然分叉进程是默认方法,但线程也受支持。并行运行测试会缩短整个测试套件的运行时间。

3.1 使用进程进行并行测试

默认的并行化方法是使用 Ruby 的 DRb 系统分叉进程。进程根据提供的 worker 数量进行分叉。默认数量是您所在机器上的实际核心数量,但可以通过传递给 parallelize 方法的数字来更改。

要启用并行化,请将以下内容添加到您的 test_helper.rb

class ActiveSupport::TestCase
  parallelize(workers: 2)
end

传递的 worker 数量是进程将分叉的次数。您可能希望将本地测试套件的并行化与 CI 不同,因此提供了一个环境变量,以便能够轻松更改测试运行应使用的 worker 数量

$ PARALLEL_WORKERS=15 bin/rails test

在并行化测试时,Active Record 会自动处理为每个进程创建数据库并将模式加载到数据库中。数据库将以与 worker 对应的数字为后缀。例如,如果您有 2 个 worker,则测试将分别创建 test-database-0test-database-1

如果传递的 worker 数量为 1 或更少,则进程不会分叉,测试不会并行化,它们将使用原始的 test-database 数据库。

提供两个钩子,一个在进程分叉时运行,一个在分叉进程关闭之前运行。如果您的应用程序使用多个数据库或执行其他依赖于 worker 数量的任务,这些方法可能很有用。

parallelize_setup 方法在进程分叉后立即调用。parallelize_teardown 方法在进程关闭之前立即调用。

class ActiveSupport::TestCase
  parallelize_setup do |worker|
    # setup databases
  end

  parallelize_teardown do |worker|
    # cleanup databases
  end

  parallelize(workers: :number_of_processors)
end

在使用线程进行并行测试时,不需要也不可用这些方法。

3.2 使用线程进行并行测试

如果您更喜欢使用线程或使用 JRuby,则提供了一个线程并行化选项。线程并行器由 Minitest 的 Parallel::Executor 支持。

要将并行化方法更改为使用线程而不是分叉,请将以下内容放在您的 test_helper.rb

class ActiveSupport::TestCase
  parallelize(workers: :number_of_processors, with: :threads)
end

从 JRuby 或 TruffleRuby 生成的 Rails 应用程序将自动包含 with: :threads 选项。

传递给 parallelize 的 worker 数量决定了测试将使用的线程数量。您可能希望将本地测试套件的并行化与 CI 不同,因此提供了一个环境变量,以便能够轻松更改测试运行应使用的 worker 数量

$ PARALLEL_WORKERS=15 bin/rails test

3.3 测试并行事务

当您要测试在线程中运行并行数据库事务的代码时,这些事务可能会相互阻塞,因为它们已经嵌套在隐式测试事务中。

要解决此问题,您可以通过设置 self.use_transactional_tests = false 来禁用测试用例类中的事务。

class WorkerTest < ActiveSupport::TestCase
  self.use_transactional_tests = false

  test "parallel transactions" do
    # start some threads that create transactions
  end
end

使用禁用的事务测试,您必须清理测试创建的任何数据,因为更改不会在测试完成后自动回滚。

3.4 并行化测试的阈值

并行运行测试会在数据库设置和夹具加载方面增加开销。因此,Rails 不会并行化涉及少于 50 个测试的执行。

您可以在您的 test.rb 中配置此阈值

config.active_support.test_parallelization_threshold = 100

以及在测试用例级别设置并行化时

class ActiveSupport::TestCase
  parallelize threshold: 100
end

4 测试数据库

几乎每个 Rails 应用程序都与数据库进行大量交互,因此,您的测试也需要一个数据库来进行交互。要编写有效的测试,您需要了解如何设置此数据库并使用示例数据填充它。

默认情况下,每个 Rails 应用程序都有三个环境:开发、测试和生产。它们各自的数据库配置在 config/database.yml 中。

专用测试数据库允许您独立设置和交互测试数据。这样,您的测试就可以放心地处理测试数据,而无需担心开发或生产数据库中的数据。

4.1 维护测试数据库模式

为了运行您的测试,您的测试数据库需要具有当前结构。测试助手会检查您的测试数据库中是否有任何待处理的迁移。它将尝试将您的 db/schema.rbdb/structure.sql 加载到测试数据库中。如果迁移仍在待处理,将引发错误。这通常表示您的模式尚未完全迁移。对开发数据库运行迁移(bin/rails db:migrate)将使模式更新。

如果对现有迁移进行了修改,则需要重建测试数据库。这可以通过执行 bin/rails test:db 来完成。

4.2 关于夹具的低谷

对于良好的测试,您需要认真考虑设置测试数据。在 Rails 中,您可以通过定义和自定义夹具来处理此问题。您可以在 夹具 API 文档 中找到全面的文档。

4.2.1 什么是夹具?

夹具 是示例数据的别称。夹具允许您在测试运行之前使用预定义数据填充测试数据库。夹具与数据库无关,并用 YAML 编写。每个模型都有一个文件。

夹具并非旨在创建测试所需的所有对象,并且在仅用于可以应用于通用情况的默认数据时,其管理效果最佳。

您会在 test/fixtures 目录下找到夹具。当您运行 bin/rails generate model 创建新模型时,Rails 会自动在该目录中创建夹具存根。

4.2.2 YAML

YAML 格式的夹具是描述示例数据的用户友好方式。这些类型的夹具具有 .yml 文件扩展名(如 users.yml)。

这是一个示例 YAML 夹具文件

# lo & behold! I am a YAML comment!
david:
  name: David Heinemeier Hansson
  birthday: 1979-10-15
  profession: Systems development

steve:
  name: Steve Ross Kellock
  birthday: 1974-09-27
  profession: guy with keyboard

每个夹具都给出了一个名称,后面跟着一个缩进的冒号分隔的键/值对列表。记录通常用空行分隔。您可以在夹具文件中使用第一列中的 # 字符添加注释。

如果您正在使用关联,您可以在两个不同的夹具之间定义一个引用节点。这是一个使用 belongs_to/has_many 关联的示例

# test/fixtures/categories.yml
about:
  name: About
# test/fixtures/articles.yml
first:
  title: Welcome to Rails!
  category: about
# test/fixtures/action_text/rich_texts.yml
first_content:
  record: first (Article)
  name: content
  body: <div>Hello, from <strong>a fixture</strong></div>

请注意,fixtures/articles.yml 中找到的 first 文章的 category 键的值为 about,而 fixtures/action_text/rich_texts.yml 中找到的 first_content 条目的 record 键的值为 first (Article)。这暗示 Active Record 加载前者中 fixtures/categories.yml 中找到的类别 about,以及 Action Text 加载后者中 fixtures/articles.yml 中找到的文章 first

对于关联,可以通过名称相互引用,可以使用夹具名称而不是在关联夹具上指定 id: 属性。Rails 将自动分配一个主键以在运行之间保持一致。有关此关联行为的更多信息,请阅读夹具 API 文档

4.2.3 文件附件夹具

与其他 Active Record 支持的模型一样,Active Storage 附件记录继承自 ActiveRecord::Base 实例,因此可以通过夹具填充。

考虑一个具有关联图像作为 thumbnail 附件的 Article 模型,以及夹具数据 YAML

class Article < ApplicationRecord
  has_one_attached :thumbnail
end
# test/fixtures/articles.yml
first:
  title: An Article

假设在 test/fixtures/files/first.png 中有一个image/png 编码文件,以下 YAML 夹具条目将生成相关的 ActiveStorage::BlobActiveStorage::Attachment 记录

# test/fixtures/active_storage/blobs.yml
first_thumbnail_blob: <%= ActiveStorage::FixtureSet.blob filename: "first.png" %>
# test/fixtures/active_storage/attachments.yml
first_thumbnail_attachment:
  name: thumbnail
  record: first (Article)
  blob: first_thumbnail_blob

4.2.4 ERB 它

ERB 允许您在模板中嵌入 Ruby 代码。当 Rails 加载夹具时,YAML 夹具格式会使用 ERB 预处理。这使您可以使用 Ruby 来帮助您生成一些示例数据。例如,以下代码生成一千个用户

<% 1000.times do |n| %>
  user_<%= n %>:
    username: <%= "user#{n}" %>
    email: <%= "user#{n}@example.com" %>
<% end %>

4.2.5 夹具在行动

默认情况下,Rails 会自动加载来自 test/fixtures 目录的所有夹具。加载涉及三个步骤

  1. 从与夹具对应的表中删除任何现有数据
  2. 将夹具数据加载到表中
  3. 将夹具数据转储到一个方法中,以防您想直接访问它

为了从数据库中删除现有数据,Rails 尝试禁用引用完整性触发器(如外键和检查约束)。如果您在运行测试时遇到恼人的权限错误,请确保数据库用户有权在测试环境中禁用这些触发器。(在 PostgreSQL 中,只有超级用户可以禁用所有触发器。阅读有关 PostgreSQL 权限的更多信息 这里)。

4.2.6 夹具是 Active Record 对象

夹具是 Active Record 的实例。如上面第 3 点所述,您可以直接访问该对象,因为它会自动作为一个方法提供,该方法的范围是测试用例的本地范围。例如

# this will return the User object for the fixture named david
users(:david)

# this will return the property for david called id
users(:david).id

# one can also access methods available on the User class
david = users(:david)
david.call(david.partner)

要一次获取多个夹具,您可以传入一个夹具名称列表。例如

# this will return an array containing the fixtures david and steve
users(:david, :steve)

5 模型测试

模型测试用于测试应用程序的各种模型。

Rails 模型测试存储在 test/models 目录下。Rails 提供了一个生成器来为您创建一个模型测试骨架。

$ bin/rails generate test_unit:model article title:string body:text
create  test/models/article_test.rb
create  test/fixtures/articles.yml

模型测试没有像 ActionMailer::TestCase 这样的自己的超类。相反,它们继承自 ActiveSupport::TestCase

6 系统测试

系统测试允许您测试用户与应用程序的交互,在真实或无头浏览器中运行测试。系统测试在幕后使用 Capybara。

要创建 Rails 系统测试,您使用应用程序中的 test/system 目录。Rails 提供了一个生成器来为您创建一个系统测试骨架。

$ bin/rails generate system_test users
      invoke test_unit
      create test/system/users_test.rb

以下是新生成的系统测试的外观

require "application_system_test_case"

class UsersTest < ApplicationSystemTestCase
  # test "visiting the index" do
  #   visit users_url
  #
  #   assert_selector "h1", text: "Users"
  # end
end

默认情况下,系统测试使用 Selenium 驱动程序、Chrome 浏览器和 1400x1400 的屏幕尺寸运行。下一节将解释如何更改默认设置。

默认情况下,Rails 将尝试从测试期间引发的异常中恢复,并使用 HTML 错误页面进行响应。可以通过 config.action_dispatch.show_exceptions 配置来控制此行为。

6.1 更改默认设置

Rails 使更改系统测试的默认设置变得非常简单。所有设置都抽象化了,因此您可以专注于编写测试。

当您生成一个新应用程序或脚手架时,会在测试目录中创建一个 application_system_test_case.rb 文件。这是您所有系统测试配置应该存在的地方。

如果您想更改默认设置,您可以更改系统测试的“驱动程序”。假设您想将驱动程序从 Selenium 更改为 Cuprite。首先将 cuprite gem 添加到您的 Gemfile 中。然后在您的 application_system_test_case.rb 文件中执行以下操作

require "test_helper"
require "capybara/cuprite"

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  driven_by :cuprite
end

驱动程序名称是 driven_by 的必需参数。可以传递给 driven_by 的可选参数是 :using 用于浏览器(这将仅由 Selenium 使用)、:screen_size 用于更改屏幕截图的屏幕大小,以及 :options 用于设置驱动程序支持的选项。

require "test_helper"

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  driven_by :selenium, using: :firefox
end

如果您想使用无头浏览器,您可以通过在 :using 参数中添加 headless_chromeheadless_firefox 来使用无头 Chrome 或无头 Firefox。

require "test_helper"

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  driven_by :selenium, using: :headless_chrome
end

如果您想使用远程浏览器,例如 Docker 中的无头 Chrome,您必须添加远程 url 并通过 optionsbrowser 设置为远程。

require "test_helper"

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  url = ENV.fetch("SELENIUM_REMOTE_URL", nil)
  options = if url
    { browser: :remote, url: url }
  else
    { browser: :chrome }
  end
  driven_by :selenium, using: :headless_chrome, options: options
end

现在您应该获得与远程浏览器的连接。

$ SELENIUM_REMOTE_URL=https://127.0.0.1:4444/wd/hub bin/rails test:system

如果您的测试应用程序也运行在远程,例如 Docker 容器,Capybara 需要更多关于如何 调用远程服务器 的信息。

require "test_helper"

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  def setup
    Capybara.server_host = "0.0.0.0" # bind to all interfaces
    Capybara.app_host = "http://#{IPSocket.getaddress(Socket.gethostname)}" if ENV["SELENIUM_REMOTE_URL"].present?
    super
  end
  # ...
end

现在您应该获得与远程浏览器和服务器的连接,无论它是在 Docker 容器还是 CI 中运行。

如果您的 Capybara 配置需要比 Rails 提供的更多设置,可以将这些额外的配置添加到 application_system_test_case.rb 文件中。

有关其他设置,请参阅 Capybara 的文档

6.2 屏幕截图助手

ScreenshotHelper 是一种旨在捕获测试屏幕截图的助手。这对于查看测试失败时浏览器的状态或稍后查看屏幕截图进行调试非常有用。

提供两种方法:take_screenshottake_failed_screenshottake_failed_screenshot 会自动包含在 Rails 中的 before_teardown 中。

take_screenshot 助手方法可以包含在测试中的任何地方,以拍摄浏览器的屏幕截图。

6.3 实现系统测试

现在我们将为我们的博客应用程序添加一个系统测试。我们将通过访问索引页面和创建一篇新的博客文章来演示编写系统测试。

如果您使用脚手架生成器,系统测试骨架会自动为您创建。如果您没有使用脚手架生成器,请先创建一个系统测试骨架。

$ bin/rails generate system_test articles

它应该为我们创建了一个测试文件占位符。通过上一条命令的输出,您应该看到

      invoke  test_unit
      create    test/system/articles_test.rb

现在让我们打开该文件并编写我们的第一个断言

require "application_system_test_case"

class ArticlesTest < ApplicationSystemTestCase
  test "viewing the index" do
    visit articles_path
    assert_selector "h1", text: "Articles"
  end
end

测试应该看到文章索引页面上有一个 h1 并通过。

运行系统测试。

$ bin/rails test:system

默认情况下,运行 bin/rails test 不会运行您的系统测试。确保运行 bin/rails test:system 来实际运行它们。您也可以运行 bin/rails test:all 来运行所有测试,包括系统测试。

6.3.1 创建文章系统测试

现在让我们测试在我们的博客中创建一篇新文章的流程。

test "should create Article" do
  visit articles_path

  click_on "New Article"

  fill_in "Title", with: "Creating an Article"
  fill_in "Body", with: "Created this article successfully!"

  click_on "Create Article"

  assert_text "Creating an Article"
end

第一步是调用 visit articles_path。这将把测试带到文章索引页面。

然后 click_on "New Article" 将在索引页面上找到“New Article”按钮。这将把浏览器重定向到 /articles/new

然后测试将使用指定的文本填充文章的标题和正文。字段填充后,单击“Create Article”,这将发送一个 POST 请求以在数据库中创建新文章。

我们将被重定向回文章索引页面,在那里我们将断言新文章标题中的文本出现在文章索引页面上。

6.3.2 测试多种屏幕尺寸

如果您想在测试桌面时测试移动尺寸,您可以创建一个从 ActionDispatch::SystemTestCase 继承的另一个类,并在您的测试套件中使用它。在此示例中,名为 mobile_system_test_case.rb 的文件是在 /test 目录中创建的,并具有以下配置。

require "test_helper"

class MobileSystemTestCase < ActionDispatch::SystemTestCase
  driven_by :selenium, using: :chrome, screen_size: [375, 667]
end

要使用此配置,请在 test/system 中创建一个从 MobileSystemTestCase 继承的测试。现在,您可以使用多种不同的配置测试您的应用程序。

require "mobile_system_test_case"

class PostsTest < MobileSystemTestCase
  test "visiting the index" do
    visit posts_url
    assert_selector "h1", text: "Posts"
  end
end

6.3.3 进一步发展

系统测试的妙处在于它类似于集成测试,因为它测试了用户与您的控制器、模型和视图的交互,但系统测试更健壮,实际上测试了您的应用程序,就好像真实用户在使用它一样。接下来,您可以测试用户在应用程序中执行的任何操作,例如评论、删除文章、发布草稿文章等。

7 集成测试

集成测试用于测试应用程序的各个部分是如何交互的。它们通常用于测试应用程序中的重要工作流程。

要创建 Rails 集成测试,我们使用应用程序的 test/integration 目录。Rails 提供了一个生成器来为我们创建一个集成测试骨架。

$ bin/rails generate integration_test user_flows
      exists  test/integration/
      create  test/integration/user_flows_test.rb

以下是新生成的集成测试的外观

require "test_helper"

class UserFlowsTest < ActionDispatch::IntegrationTest
  # test "the truth" do
  #   assert true
  # end
end

这里测试继承自 ActionDispatch::IntegrationTest。这为我们提供了一些额外的帮助程序,以便在编写集成测试时使用。

默认情况下,Rails 将尝试从测试期间引发的异常中恢复,并使用 HTML 错误页面进行响应。可以通过 config.action_dispatch.show_exceptions 配置来控制此行为。

7.1 可用于集成测试的帮助程序

除了标准的测试帮助程序之外,从 ActionDispatch::IntegrationTest 继承还带有一些在编写集成测试时可用的额外帮助程序。让我们简要介绍一下我们可以选择的帮助程序的三种类别。

有关处理集成测试运行程序的信息,请参阅 ActionDispatch::Integration::Runner

执行请求时,我们将有 ActionDispatch::Integration::RequestHelpers 可供使用。

如果我们需要上传文件,请查看 ActionDispatch::TestProcess::FixtureFile 来帮助。

如果我们需要修改会话或集成测试的状态,请查看 ActionDispatch::Integration::Session 来帮助。

7.2 实现集成测试

让我们为我们的博客应用程序添加一个集成测试。我们将从创建一个新的博客文章的基本工作流程开始,以验证一切是否正常工作。

我们将从生成我们的集成测试骨架开始

$ bin/rails generate integration_test blog_flow

它应该为我们创建了一个测试文件占位符。通过上一条命令的输出,您应该看到

      invoke  test_unit
      create    test/integration/blog_flow_test.rb

现在让我们打开该文件并编写我们的第一个断言

require "test_helper"

class BlogFlowTest < ActionDispatch::IntegrationTest
  test "can see the welcome page" do
    get "/"
    assert_select "h1", "Welcome#index"
  end
end

我们将查看 assert_select 以查询下面 测试视图 部分中请求生成的 HTML。它用于通过断言关键 HTML 元素及其内容的存在来测试请求的响应。

当我们访问根路径时,我们应该看到 welcome/index.html.erb 为视图渲染。因此此断言应该通过。

7.2.1 创建文章集成

如何测试我们在博客中创建一篇新文章并查看生成的文章的能力。

test "can create an article" do
  get "/articles/new"
  assert_response :success

  post "/articles",
    params: { article: { title: "can create", body: "article successfully." } }
  assert_response :redirect
  follow_redirect!
  assert_response :success
  assert_select "p", "Title:\n  can create"
end

让我们分解一下这个测试,以便我们可以理解它。

我们首先调用 Articles 控制器上的 :new 操作。此响应应该成功。

之后,我们向 Articles 控制器上的 :create 操作发出一个 post 请求

post "/articles",
  params: { article: { title: "can create", body: "article successfully." } }
assert_response :redirect
follow_redirect!

在请求之后的这两行用于处理我们在创建新文章时设置的重定向。

如果计划在进行重定向后发出后续请求,请不要忘记调用 follow_redirect!

最后,我们可以断言我们的响应是成功的,并且我们的新文章可以在页面上阅读。

7.2.2 进一步发展

我们能够成功地测试访问博客和创建一篇新文章的非常小的工作流程。如果我们想进一步发展,我们可以添加评论、删除文章或编辑评论的测试。集成测试是试验应用程序各种用例的好地方。

8 控制器功能测试

在 Rails 中,测试控制器的各种操作被称为功能测试。请记住,您的控制器处理进入应用程序的 Web 请求,最终会以渲染的视图进行响应。编写功能测试时,您是在测试您的操作如何处理请求以及预期的结果或响应,在某些情况下是 HTML 视图。

8.1 功能测试中应包含的内容

您应该测试以下内容:

  • Web 请求是否成功?
  • 用户是否被重定向到正确的页面?
  • 用户是否成功验证?
  • 视图中是否显示了适当的消息给用户?
  • 响应中是否显示了正确的信息?

查看功能测试的最佳方式是使用脚手架生成器生成控制器。

$ bin/rails generate scaffold_controller article title:string body:text
...
create  app/controllers/articles_controller.rb
...
invoke  test_unit
create    test/controllers/articles_controller_test.rb
...

这将为 Article 资源生成控制器代码和测试。您可以查看 test/controllers 目录中的 articles_controller_test.rb 文件。

如果您已经有控制器,只想为七个默认操作中的每一个生成测试脚手架代码,可以使用以下命令

$ bin/rails generate test_unit:scaffold article
...
invoke  test_unit
create    test/controllers/articles_controller_test.rb
...

让我们看一下 articles_controller_test.rb 文件中的一个测试,test_should_get_index

# articles_controller_test.rb
class ArticlesControllerTest < ActionDispatch::IntegrationTest
  test "should get index" do
    get articles_url
    assert_response :success
  end
end

test_should_get_index 测试中,Rails 模拟了对名为 index 的操作的请求,确保请求成功,并且还确保生成了正确的响应主体。

get 方法启动 Web 请求并将结果填充到 @response 中。它最多可以接受 6 个参数。

  • 您要请求的控制器操作的 URI。它可以是字符串形式或路由助手(例如 articles_url)。
  • params:包含要传递给操作的请求参数哈希的选项(例如查询字符串参数或文章变量)。
  • headers:用于设置将与请求一起传递的标头。
  • env:用于根据需要自定义请求环境。
  • xhr:请求是否为 Ajax 请求。可以设置为 true 以将请求标记为 Ajax 请求。
  • as:用于使用不同的内容类型编码请求。

所有这些关键字参数都是可选的。

示例:调用第一个 Article:show 操作,并传入 HTTP_REFERER 标头。

get article_url(Article.first), headers: { "HTTP_REFERER" => "http://example.com/home" }

另一个示例:调用最后一个 Article:update 操作,并在 params 中传入新的 title 文本,作为一个 Ajax 请求。

patch article_url(Article.last), params: { article: { title: "updated" } }, xhr: true

还有一个示例:调用 :create 操作以创建一个新的文章,在 params 中传入 title 的文本,作为 JSON 请求。

post articles_path, params: { article: { title: "Ahoy!" } }, as: :json

如果您尝试运行 articles_controller_test.rb 中的 test_should_create_article 测试,它会因为新添加的模型级验证而失败,这是合理的。

让我们修改 articles_controller_test.rb 中的 test_should_create_article 测试,以便所有测试都能通过。

test "should create article" do
  assert_difference("Article.count") do
    post articles_url, params: { article: { body: "Rails is awesome!", title: "Hello Rails" } }
  end

  assert_redirected_to article_path(Article.last)
end

现在您可以尝试运行所有测试,它们应该会通过。

如果您按照 基本身份验证 部分中的步骤进行操作,则需要为每个请求标头添加授权,才能使所有测试通过。

post articles_url, params: { article: { body: "Rails is awesome!", title: "Hello Rails" } }, headers: { Authorization: ActionController::HttpAuthentication::Basic.encode_credentials("dhh", "secret") }

默认情况下,Rails 将尝试从测试期间引发的异常中恢复,并使用 HTML 错误页面进行响应。可以通过 config.action_dispatch.show_exceptions 配置来控制此行为。

8.2 功能测试中可用的请求类型

如果您熟悉 HTTP 协议,您就会知道 get 是一种请求类型。Rails 功能测试支持 6 种请求类型。

  • get
  • post
  • patch
  • put
  • head
  • delete

所有请求类型都有等效的方法可以使用。在典型的 C.R.U.D. 应用程序中,您会更频繁地使用 getpostputdelete

功能测试不会验证指定的请求类型是否被操作接受,我们更关心结果。请求测试存在于此用例中,以使您的测试更有目标性。

8.3 测试 XHR(Ajax)请求

要测试 Ajax 请求,您可以为 getpostpatchputdelete 方法指定 xhr: true 选项。例如

test "ajax request" do
  article = articles(:one)
  get article_url(article), xhr: true

  assert_equal "hello world", @response.body
  assert_equal "text/javascript", @response.media_type
end

8.4 世界末日的三个哈希表

在进行并处理请求后,您将有 3 个哈希表对象可以使用。

  • cookies - 设置的所有 cookie。
  • flash - 闪存中存在的任何对象。
  • session - 会话变量中存在的任何对象。

与普通哈希表对象一样,您可以通过字符串引用键来访问值。您也可以通过符号名称引用它们。例如

flash["gordon"]               # or flash[:gordon]
session["shmession"]          # or session[:shmession]
cookies["are_good_for_u"]     # or cookies[:are_good_for_u]

8.5 可用的实例变量

进行请求之后,您还可以访问功能测试中的三个实例变量。

  • @controller - 处理请求的控制器。
  • @request - 请求对象。
  • @response - 响应对象。
class ArticlesControllerTest < ActionDispatch::IntegrationTest
  test "should get index" do
    get articles_url

    assert_equal "index", @controller.action_name
    assert_equal "application/x-www-form-urlencoded", @request.media_type
    assert_match "Articles", @response.body
  end
end

8.6 设置标头和 CGI 变量

HTTP 标头CGI 变量 可以作为标头传递。

# setting an HTTP Header
get articles_url, headers: { "Content-Type": "text/plain" } # simulate the request with custom header

# setting a CGI variable
get articles_url, headers: { "HTTP_REFERER": "http://example.com/home" } # simulate the request with custom env variable

8.7 测试 flash 通知

如果您还记得之前的内容,世界末日的三个哈希表之一是 flash

当有人成功创建新的文章时,我们希望在博客应用程序中添加 flash 消息。

让我们从将此断言添加到 test_should_create_article 测试中开始。

test "should create article" do
  assert_difference("Article.count") do
    post articles_url, params: { article: { title: "Some title" } }
  end

  assert_redirected_to article_path(Article.last)
  assert_equal "Article was successfully created.", flash[:notice]
end

如果我们现在运行测试,应该会看到失败。

$ bin/rails test test/controllers/articles_controller_test.rb -n test_should_create_article
Run options: -n test_should_create_article --seed 32266

# Running:

F

Finished in 0.114870s, 8.7055 runs/s, 34.8220 assertions/s.

  1) Failure:
ArticlesControllerTest#test_should_create_article [/test/controllers/articles_controller_test.rb:16]:
--- expected
+++ actual
@@ -1 +1 @@
-"Article was successfully created."
+nil

1 runs, 4 assertions, 1 failures, 0 errors, 0 skips

现在让我们在控制器中实现闪存消息。我们的 :create 操作现在应该如下所示。

def create
  @article = Article.new(article_params)

  if @article.save
    flash[:notice] = "Article was successfully created."
    redirect_to @article
  else
    render "new"
  end
end

现在如果我们运行测试,应该会看到它通过。

$ bin/rails test test/controllers/articles_controller_test.rb -n test_should_create_article
Run options: -n test_should_create_article --seed 18981

# Running:

.

Finished in 0.081972s, 12.1993 runs/s, 48.7972 assertions/s.

1 runs, 4 assertions, 0 failures, 0 errors, 0 skips

8.8 整合在一起

此时,我们的文章控制器测试了 :index 以及 :new:create 操作。如何处理现有数据?

让我们为 :show 操作编写一个测试。

test "should show article" do
  article = articles(:one)
  get article_url(article)
  assert_response :success
end

请记住我们之前关于夹具的讨论,articles() 方法将使我们能够访问文章夹具。

如何删除现有文章?

test "should destroy article" do
  article = articles(:one)
  assert_difference("Article.count", -1) do
    delete article_url(article)
  end

  assert_redirected_to articles_path
end

我们还可以为更新现有文章添加一个测试。

test "should update article" do
  article = articles(:one)

  patch article_url(article), params: { article: { title: "updated" } }

  assert_redirected_to article_path(article)
  # Reload association to fetch updated data and assert that title is updated.
  article.reload
  assert_equal "updated", article.title
end

请注意,我们在这三个测试中开始看到一些重复,它们都访问了相同的文章夹具数据。我们可以通过使用 ActiveSupport::Callbacks 提供的 setupteardown 方法来将此代码简化。

我们的测试现在应该如下所示。暂时忽略其他测试,我们为了简洁起见省略了它们。

require "test_helper"

class ArticlesControllerTest < ActionDispatch::IntegrationTest
  # called before every single test
  setup do
    @article = articles(:one)
  end

  # called after every single test
  teardown do
    # when controller is using cache it may be a good idea to reset it afterwards
    Rails.cache.clear
  end

  test "should show article" do
    # Reuse the @article instance variable from setup
    get article_url(@article)
    assert_response :success
  end

  test "should destroy article" do
    assert_difference("Article.count", -1) do
      delete article_url(@article)
    end

    assert_redirected_to articles_path
  end

  test "should update article" do
    patch article_url(@article), params: { article: { title: "updated" } }

    assert_redirected_to article_path(@article)
    # Reload association to fetch updated data and assert that title is updated.
    @article.reload
    assert_equal "updated", @article.title
  end
end

与 Rails 中的其他回调类似,setupteardown 方法也可以通过传递块、lambda 或方法名称作为符号来调用。

8.9 测试助手

为了避免代码重复,您可以添加自己的测试助手。登录助手就是一个很好的例子。

# test/test_helper.rb

module SignInHelper
  def sign_in_as(user)
    post sign_in_url(email: user.email, password: user.password)
  end
end

class ActionDispatch::IntegrationTest
  include SignInHelper
end
require "test_helper"

class ProfileControllerTest < ActionDispatch::IntegrationTest
  test "should show profile" do
    # helper is now reusable from any controller test case
    sign_in_as users(:david)

    get profile_url
    assert_response :success
  end
end

8.9.1 使用单独的文件

如果您发现助手使 test_helper.rb 变得杂乱无章,可以将它们提取到单独的文件中。一个存放它们的好地方是 test/libtest/test_helpers

# test/test_helpers/multiple_assertions.rb
module MultipleAssertions
  def assert_multiple_of_forty_two(number)
    assert (number % 42 == 0), "expected #{number} to be a multiple of 42"
  end
end

然后可以根据需要显式地要求这些助手并包含它们。

require "test_helper"
require "test_helpers/multiple_assertions"

class NumberTest < ActiveSupport::TestCase
  include MultipleAssertions

  test "420 is a multiple of forty two" do
    assert_multiple_of_forty_two 420
  end
end

或者它们可以继续直接包含到相关的父类中。

# test/test_helper.rb
require "test_helpers/sign_in_helper"

class ActionDispatch::IntegrationTest
  include SignInHelper
end

8.9.2 积极要求助手

您可能会发现,积极地在 test_helper.rb 中要求助手很方便,这样您的测试文件就可以隐式地访问它们。这可以使用通配符来实现,如下所示。

# test/test_helper.rb
Dir[Rails.root.join("test", "test_helpers", "**", "*.rb")].each { |file| require file }

这样做会增加启动时间,而不是在各个测试中手动要求必要的文件。

9 测试路由

与 Rails 应用程序中的其他所有内容一样,您可以测试您的路由。路由测试位于 test/controllers/ 中,或者作为控制器测试的一部分。

如果您的应用程序具有复杂的路由,Rails 提供了许多有用的助手来测试它们。

有关 Rails 中可用的路由断言的更多信息,请参阅 ActionDispatch::Assertions::RoutingAssertions 的 API 文档。

10 测试视图

通过断言关键 HTML 元素及其内容的存在来测试对请求的响应,是测试应用程序视图的一种常见方法。与路由测试类似,视图测试位于 test/controllers/ 中,或者作为控制器测试的一部分。assert_select 方法允许您使用简单而强大的语法查询响应的 HTML 元素。

assert_select 有两种形式。

assert_select(selector, [equality], [message]) 确保通过选择器在选定元素上满足相等条件。选择器可以是 CSS 选择器表达式(字符串)或包含替换值的表达式。

assert_select(element, selector, [equality], [message]) 确保通过选择器在从元素Nokogiri::XML::NodeNokogiri::XML::NodeSet 的实例)及其后代开始的所有选定元素上满足相等条件。

例如,您可以使用以下方法验证响应中标题元素的内容。

assert_select "title", "Welcome to Rails Testing Guide"

您还可以使用嵌套的 assert_select 块进行更深入的调查。

在以下示例中,li.menu_item 的内部 assert_select 在外部块选择的元素集合中运行。

assert_select "ul.navigation" do
  assert_select "li.menu_item"
end

可以遍历选定元素的集合,以便为每个元素单独调用 assert_select

例如,如果响应包含两个有序列表,每个列表都有四个嵌套的列表元素,那么以下测试都会通过。

assert_select "ol" do |elements|
  elements.each do |element|
    assert_select element, "li", 4
  end
end

assert_select "ol" do
  assert_select "li", 8
end

这个断言非常强大。有关更高级的用法,请参阅其 文档

10.1 其他基于视图的断言

还有更多主要用于测试视图的断言。

断言 目的
assert_select_email 允许您对电子邮件正文进行断言。
assert_select_encoded 允许您对编码的 HTML 进行断言。它通过对每个元素的内容进行解码,然后用所有解码后的元素调用块来实现这一点。
css_select(selector)css_select(element, selector) 返回 selector 选择的所有元素的数组。在第二个变体中,它首先匹配基本 element,然后尝试在其任何子元素上匹配 selector 表达式。如果没有匹配项,这两个变体都将返回一个空数组。

以下是使用 assert_select_email 的示例。

assert_select_email do
  assert_select "small", "Please click the 'Unsubscribe' link if you want to opt-out."
end

11 测试视图片段

部分模板(通常称为“片段”)是将渲染过程分解成更易于管理的块的另一种方法。使用片段,您可以将模板中的代码块提取到单独的文件中,并在整个模板中重复使用它们。

视图测试提供了一个机会来测试片段是否按预期渲染内容。视图片段测试位于 test/views/ 中,并继承自 ActionView::TestCase

要渲染片段,请像在模板中一样调用 render。内容可通过测试本地 #rendered 方法获得。

class ArticlePartialTest < ActionView::TestCase
  test "renders a link to itself" do
    article = Article.create! title: "Hello, world"

    render "articles/article", article: article

    assert_includes rendered, article.title
  end
end

继承自 ActionView::TestCase 的测试还可以访问 assert_select其他基于视图的断言,这些断言由 rails-dom-testing 提供。

test "renders a link to itself" do
  article = Article.create! title: "Hello, world"

  render "articles/article", article: article

  assert_select "a[href=?]", article_url(article), text: article.title
end

为了与 rails-dom-testing 集成,继承自 ActionView::TestCase 的测试声明了一个 document_root_element 方法,该方法将渲染后的内容作为 Nokogiri::XML::Node 的实例返回。

test "renders a link to itself" do
  article = Article.create! title: "Hello, world"

  render "articles/article", article: article
  anchor = document_root_element.at("a")

  assert_equal article.name, anchor.text
  assert_equal article_url(article), anchor["href"]
end

如果您的应用程序使用 Ruby >= 3.0 或更高版本,依赖于 Nokogiri >= 1.14.0 或更高版本,并依赖于 Minitest >= >5.18.0document_root_element 支持 Ruby 的模式匹配

test "renders a link to itself" do
  article = Article.create! title: "Hello, world"

  render "articles/article", article: article
  anchor = document_root_element.at("a")
  url = article_url(article)

  assert_pattern do
    anchor => { content: "Hello, world", attributes: [{ name: "href", value: url }] }
  end
end

如果您想访问与您的 功能和系统测试 测试使用的相同 Capybara 支持的断言,您可以定义一个从 ActionView::TestCase 继承的基类,并将 document_root_element 转换为 page 方法

# test/view_partial_test_case.rb

require "test_helper"
require "capybara/minitest"

class ViewPartialTestCase < ActionView::TestCase
  include Capybara::Minitest::Assertions

  def page
    Capybara.string(rendered)
  end
end

# test/views/article_partial_test.rb

require "view_partial_test_case"

class ArticlePartialTest < ViewPartialTestCase
  test "renders a link to itself" do
    article = Article.create! title: "Hello, world"

    render "articles/article", article: article

    assert_link article.title, href: article_url(article)
  end
end

从 Action View 7.1 版本开始,#rendered 帮助器方法返回一个能够解析视图部分渲染内容的对象。

要将 #rendered 方法返回的 String 内容转换为对象,请通过调用 .register_parser 定义一个解析器。调用 .register_parser :rss 定义一个 #rendered.rss 帮助器方法。例如,要将渲染的 RSS 内容 解析为具有 #rendered.rss 的对象,请注册对 RSS::Parser.parse 的调用。

register_parser :rss, -> rendered { RSS::Parser.parse(rendered) }

test "renders RSS" do
  article = Article.create!(title: "Hello, world")

  render formats: :rss, partial: article

  assert_equal "Hello, world", rendered.rss.items.last.title
end

默认情况下,ActionView::TestCase 定义了一个解析器,用于

test "renders HTML" do
  article = Article.create!(title: "Hello, world")

  render partial: "articles/article", locals: { article: article }

  assert_pattern { rendered.html.at("main h1") => { content: "Hello, world" } }
end

test "renders JSON" do
  article = Article.create!(title: "Hello, world")

  render formats: :json, partial: "articles/article", locals: { article: article }

  assert_pattern { rendered.json => { title: "Hello, world" } }
end

12 测试助手

助手只是一个简单的模块,您可以在其中定义在您的视图中可用的方法。

为了测试助手,您需要做的就是检查助手方法的输出是否符合您的预期。与助手相关的测试位于 test/helpers 目录下。

假设我们有以下助手

module UsersHelper
  def link_to_user(user)
    link_to "#{user.first_name} #{user.last_name}", user
  end
end

我们可以像这样测试此方法的输出

class UsersHelperTest < ActionView::TestCase
  test "should return the user's full name" do
    user = users(:david)

    assert_dom_equal %{<a href="/user/#{user.id}">David Heinemeier Hansson</a>}, link_to_user(user)
  end
end

此外,由于测试类继承自 ActionView::TestCase,您可以访问 Rails 的帮助器方法,例如 link_topluralize

13 测试您的邮件程序

测试邮件程序类需要一些特定的工具来完成彻底的工作。

13.1 确保邮递员正常工作

您的邮件程序类——就像您 Rails 应用程序的任何其他部分——应该进行测试,以确保它们按预期工作。

测试您的邮件程序类的目标是确保

  • 电子邮件正在处理(创建和发送)
  • 电子邮件内容正确(主题、发件人、正文等)
  • 在正确的时间发送正确的电子邮件

13.1.1 从各个方面

测试您的邮件程序有两个方面,单元测试和功能测试。在单元测试中,您以隔离的方式运行邮件程序,使用严格控制的输入,并将输出与已知值(夹具)进行比较。在功能测试中,您不会测试邮件程序产生的细微细节;相反,我们测试我们的控制器和模型是否以正确的方式使用邮件程序。您进行测试以证明在正确的时间发送了正确的电子邮件。

13.2 单元测试

为了测试您的邮件程序是否按预期工作,您可以使用单元测试将邮件程序的实际结果与预先编写的应该产生的示例进行比较。

13.2.1 夹具的复仇

出于单元测试邮件程序的目的,夹具用于提供输出应该如何显示的示例。因为这些是示例电子邮件,而不是像其他夹具那样的 Active Record 数据,所以它们保存在与其他夹具不同的子目录中。test/fixtures 中目录的名称直接对应于邮件程序的名称。因此,对于名为 UserMailer 的邮件程序,夹具应该位于 test/fixtures/user_mailer 目录中。

如果您生成了邮件程序,生成器不会为邮件程序的操作创建存根夹具。您必须自己创建这些文件,如上所述。

13.2.2 基本测试用例

这是一个单元测试,用于测试名为 UserMailer 的邮件程序,其操作 invite 用于向朋友发送邀请。它是生成器为 invite 操作创建的基测试的改编版本。

require "test_helper"

class UserMailerTest < ActionMailer::TestCase
  test "invite" do
    # Create the email and store it for further assertions
    email = UserMailer.create_invite("[email protected]",
                                     "[email protected]", Time.now)

    # Send the email, then test that it got queued
    assert_emails 1 do
      email.deliver_now
    end

    # Test the body of the sent email contains what we expect it to
    assert_equal ["[email protected]"], email.from
    assert_equal ["[email protected]"], email.to
    assert_equal "You have been invited by [email protected]", email.subject
    assert_equal read_fixture("invite").join, email.body.to_s
  end
end

在测试中,我们创建电子邮件并将返回的对象存储在 email 变量中。然后,我们确保它被发送(第一个断言),然后,在第二批断言中,我们确保电子邮件确实包含我们预期的内容。帮助器 read_fixture 用于从该文件读取内容。

email.body.to_s 在只有一个(HTML 或文本)部分存在时出现。如果邮件程序同时提供两者,您可以使用 email.text_part.body.to_semail.html_part.body.to_s 将您的夹具与特定部分进行测试。

以下是 invite 夹具的内容

Hi [email protected],

You have been invited.

Cheers!

现在是时候更深入地了解如何为您的邮件程序编写测试。config/environments/test.rb 中的 ActionMailer::Base.delivery_method = :test 行将传递方法设置为测试模式,因此电子邮件实际上不会被传递(在测试时避免向您的用户发送垃圾邮件很有用),而是会被附加到一个数组中(ActionMailer::Base.deliveries)。

ActionMailer::Base.deliveries 数组仅在 ActionMailer::TestCaseActionDispatch::IntegrationTest 测试中自动重置。如果您想在这些测试用例之外拥有一个干净的环境,您可以使用以下方法手动重置它:ActionMailer::Base.deliveries.clear

13.2.3 测试排队的电子邮件

您可以使用 assert_enqueued_email_with 断言来确认电子邮件已使用所有预期的邮件程序方法参数和/或参数化邮件程序参数排队。这使您能够匹配使用 deliver_later 方法排队的任何电子邮件。

与基本测试用例一样,我们创建电子邮件并将返回的对象存储在 email 变量中。以下示例包括传递参数和/或参数的变体。

此示例将断言电子邮件已使用正确的参数排队

require "test_helper"

class UserMailerTest < ActionMailer::TestCase
  test "invite" do
    # Create the email and store it for further assertions
    email = UserMailer.create_invite("[email protected]", "[email protected]")

    # Test that the email got enqueued with the correct arguments
    assert_enqueued_email_with UserMailer, :create_invite, args: ["[email protected]", "[email protected]"] do
      email.deliver_later
    end
  end
end

此示例将断言邮件程序已使用正确的邮件程序方法命名参数排队,方法是将参数的哈希作为 args 传递

require "test_helper"

class UserMailerTest < ActionMailer::TestCase
  test "invite" do
    # Create the email and store it for further assertions
    email = UserMailer.create_invite(from: "[email protected]", to: "[email protected]")

    # Test that the email got enqueued with the correct named arguments
    assert_enqueued_email_with UserMailer, :create_invite, args: [{ from: "[email protected]",
                                                                    to: "[email protected]" }] do
      email.deliver_later
    end
  end
end

此示例将断言参数化邮件程序已使用正确的参数和参数排队。邮件程序参数作为 params 传递,邮件程序方法参数作为 args 传递

require "test_helper"

class UserMailerTest < ActionMailer::TestCase
  test "invite" do
    # Create the email and store it for further assertions
    email = UserMailer.with(all: "good").create_invite("[email protected]", "[email protected]")

    # Test that the email got enqueued with the correct mailer parameters and arguments
    assert_enqueued_email_with UserMailer, :create_invite, params: { all: "good" },
                                                           args: ["[email protected]", "[email protected]"] do
      email.deliver_later
    end
  end
end

此示例展示了测试参数化邮件程序是否已使用正确的参数排队的另一种方法

require "test_helper"

class UserMailerTest < ActionMailer::TestCase
  test "invite" do
    # Create the email and store it for further assertions
    email = UserMailer.with(to: "[email protected]").create_invite

    # Test that the email got enqueued with the correct mailer parameters
    assert_enqueued_email_with UserMailer.with(to: "[email protected]"), :create_invite do
      email.deliver_later
    end
  end
end

13.3 功能和系统测试

单元测试使我们能够测试电子邮件的属性,而功能和系统测试使我们能够测试用户交互是否适当地触发了电子邮件的传递。例如,您可以检查邀请朋友操作是否适当地发送了电子邮件

# Integration Test
require "test_helper"

class UsersControllerTest < ActionDispatch::IntegrationTest
  test "invite friend" do
    # Asserts the difference in the ActionMailer::Base.deliveries
    assert_emails 1 do
      post invite_friend_url, params: { email: "[email protected]" }
    end
  end
end
# System Test
require "test_helper"

class UsersTest < ActionDispatch::SystemTestCase
  driven_by :selenium, using: :headless_chrome

  test "inviting a friend" do
    visit invite_users_url
    fill_in "Email", with: "[email protected]"
    assert_emails 1 do
      click_on "Invite"
    end
  end
end

assert_emails 方法没有绑定到特定的传递方法,并将与使用 deliver_nowdeliver_later 方法传递的电子邮件一起使用。如果我们明确地要断言电子邮件已被排队,我们可以使用 assert_enqueued_email_with上面的示例)或 assert_enqueued_emails 方法。有关更多信息,请参阅 此处 的文档。

14 测试作业

作业可以在隔离状态下(专注于作业的行为)和上下文(专注于调用代码的行为)进行测试。

14.1 在隔离状态下测试作业

当您生成作业时,还会在 test/jobs 目录中生成一个关联的测试文件。

以下是一个计费作业的测试示例

require "test_helper"

class BillingJobTest < ActiveJob::TestCase
  test "account is charged" do
    perform_enqueued_jobs do
      BillingJob.perform_later(account, product)
    end
    assert account.reload.charged_for?(product)
  end
end

测试的默认队列适配器不会执行作业,直到 perform_enqueued_jobs 被调用。此外,它会在每次测试运行之前清除所有作业,以防止测试相互干扰。

该测试使用 perform_enqueued_jobsperform_later 而不是 perform_now,这样如果配置了重试,重试失败会被测试捕获,而不是重新排队并被忽略。

14.2 在上下文中测试作业

建议测试作业是否已正确排队,例如,由控制器操作排队。ActiveJob::TestHelper 模块提供了一些方法,可以帮助您完成此操作,例如 assert_enqueued_with

以下是一个测试帐户模型方法的示例

require "test_helper"

class AccountTest < ActiveSupport::TestCase
  include ActiveJob::TestHelper

  test "#charge_for enqueues billing job" do
    assert_enqueued_with(job: BillingJob) do
      account.charge_for(product)
    end

    assert_not account.reload.charged_for?(product)

    perform_enqueued_jobs

    assert account.reload.charged_for?(product)
  end
end

14.3 测试是否引发了异常

测试您的作业是否在某些情况下引发了异常可能很棘手,尤其是在您配置了重试的情况下。perform_enqueued_jobs 帮助器会使任何作业引发异常的测试失败,因此要让测试在引发异常时成功,您必须直接调用作业的 perform 方法。

require "test_helper"

class BillingJobTest < ActiveJob::TestCase
  test "does not charge accounts with insufficient funds" do
    assert_raises(InsufficientFundsError) do
      BillingJob.new(empty_account, product).perform
    end
    assert_not account.reload.charged_for?(product)
  end
end

一般不建议使用此方法,因为它会绕过框架的某些部分,例如参数序列化。

15 测试 Action Cable

由于 Action Cable 在您的应用程序内部的多个级别使用,因此您需要测试频道、连接类本身以及其他实体是否广播了正确的消息。

15.1 连接测试用例

默认情况下,当您使用 Action Cable 生成新的 Rails 应用程序时,还会在 test/channels/application_cable 目录下为基本连接类(ApplicationCable::Connection)生成一个测试。

连接测试旨在检查连接的标识符是否已正确分配,或者是否拒绝了任何不正确的连接请求。以下是一个示例

class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase
  test "connects with params" do
    # Simulate a connection opening by calling the `connect` method
    connect params: { user_id: 42 }

    # You can access the Connection object via `connection` in tests
    assert_equal connection.user_id, "42"
  end

  test "rejects connection without params" do
    # Use `assert_reject_connection` matcher to verify that
    # connection is rejected
    assert_reject_connection { connect }
  end
end

您也可以像在集成测试中一样指定请求 cookie

test "connects with cookies" do
  cookies.signed[:user_id] = "42"

  connect

  assert_equal connection.user_id, "42"
end

有关更多信息,请参阅 ActionCable::Connection::TestCase 的 API 文档。

15.2 频道测试用例

默认情况下,在生成频道时,也会在 test/channels 目录下生成一个关联的测试。

require "test_helper"

class ChatChannelTest < ActionCable::Channel::TestCase
  test "subscribes and stream for room" do
    # Simulate a subscription creation by calling `subscribe`
    subscribe room: "15"

    # You can access the Channel object via `subscription` in tests
    assert subscription.confirmed?
    assert_has_stream "chat_15"
  end
end

此测试非常简单,只断言频道将连接订阅到特定流。

您还可以指定底层连接标识符。以下是一个使用网络通知频道的测试示例。

require "test_helper"

class WebNotificationsChannelTest < ActionCable::Channel::TestCase
  test "subscribes and stream for user" do
    stub_connection current_user: users(:john)

    subscribe

    assert_has_stream_for users(:john)
  end
end

有关更多信息,请参阅 ActionCable::Channel::TestCase 的 API 文档。

15.3 自定义断言和在其他组件内测试广播

Action Cable 附带了一组自定义断言,可用于减少测试的冗长性。有关可用断言的完整列表,请参阅 ActionCable::TestHelper 的 API 文档。

确保在其他组件(例如控制器内)广播了正确的消息是一种良好的做法。这正是 Action Cable 提供的自定义断言非常有用的地方。例如,在模型中

require "test_helper"

class ProductTest < ActionCable::TestCase
  test "broadcast status after charge" do
    assert_broadcast_on("products:#{product.id}", type: "charged") do
      product.charge(account)
    end
  end
end

如果您想测试使用 Channel.broadcast_to 进行的广播,则应使用 Channel.broadcasting_for 生成底层流名称。

# app/jobs/chat_relay_job.rb
class ChatRelayJob < ApplicationJob
  def perform(room, message)
    ChatChannel.broadcast_to room, text: message
  end
end
# test/jobs/chat_relay_job_test.rb
require "test_helper"

class ChatRelayJobTest < ActiveJob::TestCase
  include ActionCable::TestHelper

  test "broadcast message to room" do
    room = rooms(:all)

    assert_broadcast_on(ChatChannel.broadcasting_for(room), text: "Hi!") do
      ChatRelayJob.perform_now(room, "Hi!")
    end
  end
end

16 测试急切加载

通常,应用程序不会在 developmenttest 环境中急切加载以加快速度。但在 production 环境中会。

如果项目中的某些文件由于某种原因无法加载,您最好在部署到生产环境之前检测到它,对吧?

16.1 持续集成

如果您的项目已实施 CI,则在 CI 中进行急切加载是确保应用程序进行急切加载的一种简单方法。

CI 通常会设置一些环境变量来指示测试套件在那里运行。例如,它可能是 CI

# config/environments/test.rb
config.eager_load = ENV["CI"].present?

从 Rails 7 开始,默认情况下新生成的应用程序将以这种方式配置。

16.2 纯测试套件

如果您的项目没有持续集成,您仍然可以通过调用 Rails.application.eager_load! 在测试套件中进行急切加载。

16.2.1 Minitest

require "test_helper"

class ZeitwerkComplianceTest < ActiveSupport::TestCase
  test "eager loads all files without errors" do
    assert_nothing_raised { Rails.application.eager_load! }
  end
end

16.2.2 RSpec

require "rails_helper"

RSpec.describe "Zeitwerk compliance" do
  it "eager loads all files without errors" do
    expect { Rails.application.eager_load! }.not_to raise_error
  end
end

17 附加测试资源

17.1 测试依赖时间的代码

Rails 提供了内置的帮助器方法,使您能够断言依赖时间的代码按预期工作。

以下示例使用 travel_to 帮助器

# Given a user is eligible for gifting a month after they register.
user = User.create(name: "Gaurish", activation_date: Date.new(2004, 10, 24))
assert_not user.applicable_for_gifting?

travel_to Date.new(2004, 11, 24) do
  # Inside the `travel_to` block `Date.current` is stubbed
  assert_equal Date.new(2004, 10, 24), user.activation_date
  assert user.applicable_for_gifting?
end

# The change was visible only inside the `travel_to` block.
assert_equal Date.new(2004, 10, 24), user.activation_date

有关可用时间帮助器的更多信息,请参阅 ActiveSupport::Testing::TimeHelpers API 参考。



返回顶部