DO NOT READ THIS FILE ON GITHUB, GUIDES ARE PUBLISHED ON https://guides.rubyonrails.org.

Rails 應用程式測試

此頁教學包含了Rails中,用於測試應用程式的機制

等閱讀完本篇教學後,你將會學到:

  • Rails的測試術語
  • 如何為你的系統寫單元測試、功能測試、整合測試和系統測試
  • 其他受歡迎的測試方法、插件

1、為何什麼要為你的Rails應用程式寫測試呢?

Rails 讓編寫測試變得非常容易。當你創造models、controllers的時候,就會順便建立一個框架測試代碼。

藉由運行測試,你可以確保你的程式碼,即使在主要程式碼重構之後,也可以保證功能一樣的正常運行。

Rails 還可以模擬瀏覽器的requests,也因此讓您無需通過瀏覽器,就可以測試應用程式的response。

2、測試介紹

在一開始,支援測試就已經被編寫入Rails的結構之中,不是因為「哇!因為測試是新的酷東西,所以我們把測試加進去」

2.1 Rails 開始進行測試

當你用rails new application_name 的時候,Rails會馬上幫你產生一個 test 的資料夾,如果你列出目錄的話,你將會看到:

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

system 系統測試資料夾代表系統測試,他被使用於對應用程式進行完整的瀏覽器測試。系統測試可以讓你以使用者體驗的角度,去測試你的應用程式,並幫助你測試 JavaScript。系統測試繼承於 Capybara,並在你應用程式的瀏覽器測試中執行

Fixtures 夾具是一種管理測試數據的方式,會在 fixtures 這個資料夾裡面。

jobs 資料夾將會在第一個關聯測試被產生的時候,順便新建一個出來

test_helper.rb 檔案中包含測試的預設設定。

application_system_test_case.rb 檔案中包含了系統測試的預設設定。

2.2 測試環境

每個Rails應用程式,預設下都有三個環境:開發、測試、生產環境。

每個環境設置都可以被修改,以這個為範例,我們可以藉由改變 config/environments/test.rb 的選項,來修改測試環境。

備註:運行測試時,RAILS_ENV環境變量的值是test,Ex. RAILS_ENV=test

2.3 使用 Minitest 測試 Rails 應用程式

如果你記得,我們在 Rails 入門 這個教學中,使用 bin/rails generate model 這個指令來創造我們第一個Model,這個指令會創造很多東西,其中包括在 test 資料夾中創建test stubs

$ 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 中,預設的 test stub 看起來像這樣:

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

因為 ArticleTest 繼承自 ActiveSupport::TestCase,所以此類別也有定義一個測試用例(test case),因此 ArticleTest 具有 ActiveSupport::TestCase 中所有可以用的方法。在本教學後面,我們將會看到他為我們提供的一些方法。

任何繼承自 Minitest::Test (他是 ActiveSupport::TestCase 的 superclass) 而且名稱開頭為 test_ 的方法,都簡稱為測試。
因此,名為 test_passwordtest_valid_password 兩個方法,都是有效的測試名稱,並且當測試用例運行時,會自動運行。

Rails 還有一個 test 方法,他可以接受測試一個名稱、區塊。他會生成一個名稱前綴為 test_Minitest::Unit 測試。所以你不必擔心命名方法,你可以這樣寫:

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

讓我們來對這個新增的行數進行測試(..rb: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


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 代表失敗。你可以先看到相對應失敗測試的名稱,接下來幾行包含斷言的實際值、預期值得訊息。
預設的斷言訊息的訊息,足以幫助我們找到錯誤,為了讓斷言的錯誤訊息更具可讀性,每個斷言提供一些參數選項做修改,如下:

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

現在,為了讓這個測試通過,我們可以幫此model的 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

現在,如果你有注意到,我們第一個寫的測試,他沒有達到預期的功能性,然後我們加了一些程式碼來添加功能,最後確保測試可以通過。這種開發流程就被稱作為測試驅動開發(Test-Driven Development (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>'


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 這個輸出,他代表這個測試是錯的 Notice the ‘E’ in the output. It denotes a test with error.

備註:只要有任何錯誤,或者是斷言失敗發生,測試的執行就會停止,並且測試套件會繼續執行下一個方法。所有的測試方法是隨機去執行的,不過可以使用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 是可新增的字串訊息,可以使用它讓你的測試失敗訊息更易讀。

Assertion Purpose
assert( test, [msg] ) 確保 test 是真值。
assert_not( test, [msg] ) 確保 test 是假值。
assert_equal( expected, actual, [msg] ) 確保 expected == actual 成立。
assert_not_equal( expected, actual, [msg] ) 確保 expected != actual 成立。
assert_same( expected, actual, [msg] ) 確保 expected.equal?(actual) 成立。
assert_not_same( expected, actual, [msg] ) 確保 expected.equal?(actual) 不成立。
assert_nil( obj, [msg] ) 確保 obj.nil? 成立。
assert_not_nil( obj, [msg] ) 確保 obj.nil? 不成立。
assert_empty( obj, [msg] ) 確保 obj空的
assert_not_empty( obj, [msg] ) 確保 obj 不是 空的
assert_match( regexp, string, [msg] ) 確保 字串符合正規表達式。
assert_no_match( regexp, string, [msg] ) 確保 字串不符合正規表達式。
assert_includes( collection, obj, [msg] ) 確保 objcollection 裡面。
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] ) 確保 obj 能回應 symbol 對應的方法。
assert_not_respond_to( obj, symbol, [msg] ) 確保 obj 不能回應 symbol 對應的方法。
assert_operator( obj1, operator, [obj2], [msg] ) 確保 obj1.operator(obj2) 成立。
assert_not_operator( obj1, operator, [obj2], [msg] ) 確保 obj1.operator(obj2) 不成立。
assert_predicate ( obj, predicate, [msg] ) 確保 obj.predicate 是真, 像是: assert_predicate str, :empty?
assert_not_predicate ( obj, predicate, [msg] ) 確保 obj.predicate 是假, 像是: assert_not_predicate str, :empty?
flunk( [msg] ) 確保失敗,可以用這個斷言來明確標示未完成的測試。

以上是minitest支援的斷言子集,如果想要看更詳細的列表,可以看這個 Minitest API documentation, 尤其是 Minitest::Assertions.

因為測試框架模塊化的特性,你可以自己創造斷言,事實上,這是Rails所做的,他做了一些斷言讓你寫測試更輕鬆。

備註:創建自己的斷言是進階主題,我們在這邊不會教到。

2.5 Rails 特定的斷言

Rails將自己一些自定義的斷言加進 minitest 這個框架中:

Assertion Purpose
assert_difference(expressions, difference = 1, message = nil) {...} 運行代碼區塊前後數量變化了多少(通過expression 表示)。
assert_no_difference(expressions, message = nil, &block) 運行代碼區塊前後數量沒變化(通過expression 表示)。
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 HASH指定)與路徑匹配。基本上,它斷言Rails 能識別expected_options 指定的路由。
assert_generates(expected_path, options, defaults={}, extras = {}, message=nil) 斷言指定的選項能生成指定的路徑。作用與assert_recognizes 相反。 extras 參數用於構建查詢字符串。 message 參數用於為斷言失敗定制錯誤消息。
assert_response(type, message = nil) 斷言回應的狀態程式碼, 可以指定表示200-299 的:success,表示300-399 的:redirect,表示404 的:missing,或者表示500-599 的:error。 此外,還可以明確指定數字狀態碼或對應的符號。詳情參見完整的狀態碼列表及其與符號的對應關係。完整狀態程式碼對應關係
assert_redirected_to(options = {}, message=nil) 斷言傳入的重導選項匹配最近一個動作中的重定向。 你可以傳路徑,像是 assert_redirected_to root_path 和 Active Record 物件,像是 assert_redirected_to @article.

你將會在下一章節看到一些斷言的用法。

2.6 關於測試用例的簡短說明

我們在自己的測試用例中,可以使用繼承自 Minitest::Assertions 類別的所有基本斷言,像是 assert_equal。事實上,Rails以下類別都是繼承自 Minitest::Assertions

這些類別都引入 Minitest::Assertions ,允許我們在測試中去使用所有的基本斷言。

備註:更多關於 Minitest 的資訊,可以點擊文件

2.7 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

這會在這個測試用例中,執行所有的測試方法。 This will run all test methods from the test case.

你可以藉由輸入 -n 或是 --name 加上方法名稱,來執行該測試用例中的特定測試方法 You can also run a particular test method from the test case by providing the -n or --name flag and the test’s method 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/controllers # run all tests from specific directory

測試還提供了很多其他的功能,像是快速失敗、當這次執行結束時,延緩測試輸出…等等。詳情可以檢查測試執行流程的文件:

$ bin/rails test -h
Usage: rails test [options] [files or directories]

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 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.
    -n, --name PATTERN               Filter run on /regexp/ or string.
        --exclude PATTERN            Exclude /regexp/ or string from run.

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
    -p, --pride                      Pride. Show your testing pride!

2.9 在持續整合中執行測試 (CI)

在持續整合的環境中執行測試,只需要輸入一行指令: To run all tests in a CI environment, there’s just one command you need:

bin/rails test

如果你想要使用 系統測試,要記得只輸入 bin/rails test 不會執行系統測試,因為這樣會讓系統變很慢,如果想要讓他們正常執行,只要輸入 bin/rails test:system 或是改變第一步驟的指令 bin/rails test:all,這樣就可以讓你在執行測試的時候,把系統測試包含在裡面。

3、平行測試

平行測試允許你平行你的測試套件,雖然分叉進程是預設方法,但是單線進程也是支持的,平行測試可以讓你減少測試執行的時間。

3.1 執行平行測試

Rails預設的平行方法,是使用DRb系統來運作的。這些進程根據有多少數量的工人來進行分叉。預設的數量是根據你電腦所擁有的核心數,不過可以透過平行方法並給予參數來改變數量。

test_helper.rb 此檔案裡面,可以找到平行方法:

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

有多少個工人數量,就代表進程會被分叉幾次,你可能希望用不同於持續整合(CI)的方式,平行本地測試插件,因此有一個環境變數提供你去更改測試的工人數量:

$ PARALLEL_WORKERS=15 bin/rails test

當平行測試時,Active Record會自動處理創建數據庫,並加載schema到每個數據庫的進程之中。數據庫將會以工人相對應的編號作為後綴。例如,如果你在這個測試有兩個工人,這個測試會分別創造 test-database-0test-database-1

如果工人的數量是1個或是低於進程數量,就不會分叉進行,測試也不會進行平行測試,資料庫也會使用原本的 test-database

有提供兩個 hooks,一個是在進程分叉時運行,一個是在分叉進程關閉前運行,如果你的應用程式使用多個資料庫,或者執行其他取決於工作人員數量的任務,這會非常的有用。

進程被分叉後,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 的數量,將決定此測試會使用多少執行緒數量,你可能會希望以不同於持續整合(CI)的方式,平行化本地測試插件,因此有提供一個環境變數,方便去更改執行測試應該要使用的工人數量:

$ PARALLEL_WORKERS=15 bin/rails test

3.3 測試平行交易(Transactions)

Rails會自動將所有的測試用例,包裝在測試完成後回滾(rollback)的資料庫交易(transaction)中,這會讓測試用例彼此獨立,並且對資料庫的更改只有在單個測試中可以看到。

當你想要測試在執行緒中執行平行交易(transactions)的程式碼時,交易(transactions)可以互相阻擋,因為他們已經鑲嵌在測試交易(transactions)下面。

你可以透過設定 self.use_transactional_tests = false ,來關閉測試用例類別中的交易(transactions)。

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

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

標註:對於被禁止使用的交易(transactional)測試,你必須去清除任何測試新增的任何數據,因為測試完成後,更改不會自動回滾(rollback)

3.4 平行化測試的閾值

平行測試會增加數據庫設定和夾具(fixture)加載方面的負載。因此Rails不會平行執行少於50個測試的執行。

你可以在 test.rb 設定你的閾值:

config.active_support.test_parallelization_threshold = 100

你也可以在測試用例的地方設定閾值:

class ActiveSupport::TestCase
  parallelize threshold: 100
end

4、測試資料庫

每個Rails的應用程式,幾乎都會與資料庫進行大量的交互運作,因此你的測試也會需要與資料庫進行交互運作,為了要寫更高效率的測試,你需要了解如何設定資料庫、使用樣本數據填充他。

預設下,每個Rails的應用程式有三個環境:開發、測試、生產(production),每個數據庫都在 config/database.yml 中設定。

專用測試資料庫允許你單獨設定測試數據並和他交互使用,這樣可以讓你很放心的處理測試資料,不用擔心會影響到開發、生產(production)的資料庫。

4.1 維護測試資料庫的架構(Schema)

為了執行測試,你的測試資料庫需要有目前數據庫的架構,test helper 會確認你目前是否有任何的 pending migrations,他會嘗試加載你的 db/schema.rbdb/structure.sql 匯入測試資料庫。如果 migrations 還有 pending 狀態,會引發錯誤,通常這代表 schema 尚未完全的 migrate。執行 bin/rails db:migrate 會保持 schema 保持最新狀態。

備註: 如果對現有的 migrations 進行修改,測試資料庫就需要重新建立,可以執行 bin/rails db:test:prepare 來做到這件事。

4.2 夾具(Fixtures)詳解

一個良好的測試,需要考慮測試數據的設定,在Rails,你可以通過定義、客製夾具(fixtures)來處理這個需求。你可以在 Fixtures API documentation 找到全面的文件。

4.2.1 什麼是夾具(Fixtures)?

_Fixtures_ 是樣本數據中特別的單字,夾具(Fixtures)允許你在測試開始前,先把預先定義的數據,填充到測試資料庫裡面,夾具(Fixtures)獨立於資料庫,並且用YAML書寫。在test資料夾,每個 Model 都有一個個對應的夾具(Fixtures)檔案。

備註:夾具(Fixtures)不是來創造你測試需要的每個物件,需要用到公用的預設數據時才應該使用。

你可以在 test/fixtures 這個資料夾下面找到夾具(fixtures),當你執行 bin/rails generate model 創造model的時候,Rails會自動在這個資料夾裡面創建 fixture stubs

4.2.2 YAML

YAML格式夾具(fixtures)是一個描述你的樣本資料人性化的方式,這個格式的副檔名是 .yml (像是 users.yml)。

下面是YAML的樣本夾具(fixture)檔案

# 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

每個夾具(fixture)被賦予一個名稱,該名稱後面跟著冒號分隔著的鍵/值的縮進列表。記錄通常有空格來做分行,你可以在第一行用 # 字號的方式把註解加進夾具(fixture)檔案中。

如果你正在運行關聯,你可以在兩個夾具(fixtures)間定義一個參考節點,這裡有個 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 裡面,為後者加載在 fixtures/articles.yml 中的 first 裡面。

備註: 在夾具(features)中創建關聯時,引用的是另一個夾具(features)的名稱,而不是 id: 屬性,Rails將會自動分配一個主鍵以便在運行間保持一致,有關關聯行為的更多資訊,請閱讀這個文件Fixtures API documentation

4.2.3 文件附件夾具(Fixtures)

就跟其他Active Record支持的models一樣,Active Storage附件紀錄繼承自 ActiveRecord::Base 實體,因此可以由夾具(Fixtures)填充。

考慮 Article 的model,他跟圖片有一個 thumbnail 附件的關聯,以及夾具(Fixtures)數據YAML:

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

假設在 test/fixtures/files/first.png 中有一個 image/png 的編碼文件,下面的YAML夾具條目(YAML fixture entries)將會產生跟 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加強夾具(feature)

ERB檔案可以讓你透過範例鑲嵌Ruby的程式碼,當Rails加載夾具(Fixtures)的時候,YAML夾具(Fixtures)格式會用ERB來做預先處理。這可以讓你使用Ruby語法來幫助你產生一些樣本檔案,例如,下面的程式碼會生成1000個使用者。

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

4.2.5 夾具(Fixtures) in Action

預設情況下,Rails會自動從 test/fixtures 資料夾載入所有的夾具(fixtures),載入包含三個步驟:

  1. 從夾具(fixture)相對應的資料表(table)中,刪除已經存在的資料
  2. 把夾具資料(fixture data)載入資料表(table)
  3. 將夾具資料存在方法中,以便你直接去只用他

提示:為了防止從資料庫刪除現存的資料,Rails嘗試禁止引用完整觸發器(像是外鍵、約束檢查),如果你在跑測試的時候遇到煩人的驗證錯誤,確保資料庫的使用者有權限在測試環境中禁用這些觸發器。(在PostgreSQL,只有超級用戶可以禁用所有觸發器,閱讀更多的PostgreSQL權限設定here)。)

4.2.6 夾具(Fixtures) 是 Active Record 的物件

夾具(Fixtures)是 Active Record 的實體,正如上面的第三點所述,你可以直接使用這個物件,因為夾具(feature)中的數據會轉儲成測試用例作用域中的方法。例如:

# 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)

如果想要一次獲得多個夾具(fixtures),你可以傳入多個夾具(fixture)名稱,例如:

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

5、模型測試 Model Testing

Model測試用於測試你應用程式中的多種models。

Rails model 測試被存於 test/models 資料夾的下方,Rails提供一個生成器來幫助你創造model測試的框架。

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

Model測試沒有像 ActionMailer::TestCase 有自己的 superclass,相反的,他們繼承自ActiveSupport::TestCase

6、系統測試 System Testing

系統測試允許你讓你測試使用者和你的應用程式進行互動,是在真實或無頭瀏覽器中進行測試,系統測試在底層使用 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 driver 運行,並且使用Chrome瀏覽器,螢幕大小為 1400X1400,下一個部分會解釋如何更改預設設定。

6.1 改變預設設定

Rails讓改變系統測試的預設設定變得很簡單,所有設定都被抽象出來,因此你可以專注於寫測試。

當妳生成一個應用程式或者是 scaffold 時,會在測試資料夾創造application_system_test_case.rb 這個檔案,這是所有你的系統設定都會有的。

如果你想要改變預設設定,你可以改變系統測試是由什麼來驅動(driven by),假設你想由 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_chrome 或是 headless_firefox,這樣可以新增無頭Chrome或者是無頭Firefox瀏覽器。

require "test_helper"

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

如果你想要使用遠端瀏覽器,像是Headless Chrome in Docker,你必須透過 options 使用遠端 url

require "test_helper"

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  options = ENV["SELENIUM_REMOTE_URL"].present? ? { url: ENV["SELENIUM_REMOTE_URL"] } : {}
  driven_by :selenium, using: :headless_chrome, options: options
end

像這樣的案例, webdrivers 這個gem就不再需要required,你應該需要完全移除他,或是在 Gemfile 那邊增加 require:

# ...
group :test do
  gem "webdrivers", require: !ENV["SELENIUM_REMOTE_URL"] || ENV["SELENIUM_REMOTE_URL"].empty?
end

現在應該可以成功連結遠端瀏覽器。

$ SELENIUM_REMOTE_URL=http://localhost:4444/wd/hub bin/rails test:system

如果你的應用程式在測試的時候是跑遠端,像是 Docker containerCapybara 需要更多關於 call remote servers 的資訊。 s

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 container 或是在 CI 中執行。

如果今天你的 Capybara 設定比Rails還要多,可以把額外設定都加進 application_system_test_case.rb 這個檔案裡面。

請看 Capybara’s documentation 有更多的額外設定。

6.2 截圖幫手

ScreenshotHelper 是一個被設計來幫你的測試捕捉截圖的小幫手,這有助於測試失敗時看到瀏覽器的畫面,或是查看截圖來debugging。

這邊提供兩個方法: take_screenshottake_failed_screenshottake_failed_screenshot 是自動包含在Rails裡面的 before_teardowntake_screenshot 輔助方法可以包含在你測試中的任何位置,以擷取瀏覽器的畫面。

6.3 執行系統測試

現在我們來幫我們的部落格應用程式加系統測試,我們將會拜訪索引頁面(index page)和創造新的部落格文章,來展示如何編寫系統測試。

如果使用 scaffold generator,系統測試框架會自動產生給你,如果你沒有使用,可以跟著下一面寫來產生框架。

$ bin/rails generate system_test articles

之後會創造測試檔案給我們,終端機的輸出指令會是下面這樣:

      invoke  test_unit
      create    test/system/articles_test.rb

現在讓我打開檔案並且寫下我們第一行的斷言assertion:

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,這會讓測試進到文章的 index page,然後這個 click_on "New Article" 將會找到在 index page 頁面中,”New Article”的按鈕,這會重導到瀏覽器 /articles/new 的連結。

然後這一行 click_on "New Article" 將會找到在 index page 頁面中,”New Article”的按鈕,這會重導到瀏覽器 /articles/new 的連結

再來測試將會用指定的文字填進文章中的標題和內容,一旦欄位被填寫,”Create Article” 被點擊,將會送出 Post request ,並且在資料庫中新增一個新文章。

最後我們將會重導到文章的 index page,並且我們斷言在文章標題的文字,會在文章中的 index page 裡面。

6.3.2 在不同大小的螢幕測試

如果想要在電腦測試時,多測試不同畫面大小的狀況,你可以創造另外一個繼承自 SystemTestCase 而且在測試插件中使用的類別,在這個範例裡面,以下的設定會在 /test 資料夾裡面,新增 mobile_system_test_case.rb 的檔案

require "test_helper"

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

使用這個設定,創造一個繼承自 MobileSystemTestCase 且在 test/system 的測試,現在你可以在多個不同的螢幕大小測試應用程式。

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 更進一步

系統測試美妙的地方就在於他跟整合測試很像,他們都在測試使用者在你的controller、model、view做互動,不過系統測試更完整一點,他會實際上像真正的使用者在使用他一樣,測試你的應用程式,進一步看,你可以測試任何,使用者在應用程式中做的事情,像是評論、刪除文章、刪除草稿文章…等等。

7、整合測試 Integration Testing

整合測試使用在應用程式中各個部分如何互相互動,他們通常用在測試應用程式的重要工作流程。

為了新增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,這可以讓我們為整合測試新增一些額外有用的方法。

7.1 用在整合測試的小幫手

除了標準的測試小幫手外,還有從 ActionDispatch::IntegrationTest 繼承來一些可以用來編寫整合測試的小幫手,我們來簡單介紹一下可以選擇的三種類型小助手。

有關處理執行整合測試,請看這一篇ActionDispatch::Integration::Runner

當執行請求時(performing requests),我們會有 ActionDispatch::Integration::RequestHelpers 這個可以使用。

如果我們需要修改工作階段(session)或者是整合測試的狀態,可以看這一篇尋求幫助 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

我們會在”測試畫面 Testing Views”之下,去找 assert_select 查詢請求的結果HTML,他藉由斷言關鍵的HTML元素、內容來測試我們請求的回應。

當我們拜訪(visit)根目錄,我們應該會看到 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 controller:new 動作。應該得到成功的回應。

然後,我們向 Articles controller:create 動作發送 POST 請求:

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

這兩行的意思是,當我們創造新的文章,會有一個處理重導的設置。

標註:如果你想要在重導後發出後續的請求,別忘了呼叫 follow_redirect

最後我們可以斷言我們的回應是成功的,而且頁面中顯示了新建的文章。

7.2.2 更進一步

我們剛剛測試了訪問部落格和新建文章功能,這只是工作流程的一小部分。如果想更進一步,還可以測試評論、刪除文章或編輯評論。整合測試就是用來檢查應用的各種使用場景的。

8、控制器功能測試 Functional Tests for Your Controllers

在Rails,測試控制器各動作需要編寫功能測試,記得你的 controller 處理你應用程式即將到來的網站請求,並且最後回應、渲染到頁面上,當你寫功能測試時,你正在測試如何處理請求和預期結果或回應(在某些情況下是 HTML view)。

8.1 功能測試應該包含哪些內容

你應該測試的內容有這些:

  • 網站請求是否有成功?
  • 使用者是否被重導到正確的頁面?
  • 使用者是否成功通過身份驗證?
  • 是否在 view 中成功顯示了適當的訊息?
  • 回應中是否有顯示正確的資訊?

查看功能測試最簡單的方式,就是使用框架生產器來產生 controller

$ 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
...

這會產生 controller code 和測試 Article 的資源。你可以查看在 test/controllers 資料夾中的 articles_controller_test.rb 文件。

如果你已經有 controller 並且只想產生7個測試框架的程式碼給7個預設的 actions,你可以輸入下面的程式碼。

$ 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模擬 actionindex 的請求,確認請求成功並且確保可以生成正確的回應主體。

get 方法發起網站請求,並且把結果傳到 @response 回應裡面,他最多可以接受六個引數:

  • 你請求 controller action 的URI,他可以是字串的形式或是路徑小幫手(e.g. articles_url)。
  • params:帶有參數請求 hash 的選項可以傳遞給 action
  • headers:用於會隨著請求傳遞的標頭
  • env:用於根據需要自定義請求環境
  • xhr:請求是否為 Ajax 請求,可以設定為true來幫請求標記為 Ajax
  • as:用於對不同類型的內容作編碼的請求

這些的關鍵字引數都是可選擇的。

例如:第一個 Article 呼叫 :showaction,傳入 HTTP_REFERER 的標頭:

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

另一個例子:為最後的 Article 呼叫 :updateaction,傳進新的文字,用 Ajax 請求,在 paramstitle 傳進新的文本:

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

再一個例子: 呼叫 :createaction 來創造新的文章,用 JSON 請求,在 paramstitle 傳進新的文本:

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

備註: 如果嘗試從 articles_controller_test 執行 test_should_create_article 測試,他會因為新添加的 model 級別驗證,導致測試失敗,這是正確的。

讓我們來修改 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") }

8.2 功能測試中的請求類型

如果你熟悉HTTP協議的話,會知道 get 是一種請求類型,Rails功能測試中可以使用六種請求:

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

這幾種請求都要相對應的方法使用,在常見的CRUD中,最常使用的是 getpostputdelete

備註: 功能測試不驗證 action 是否接受指定的請求類型,我們關注的是請求的結果,如果想要做到這樣的測試,可以做請求測試(request test)。

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 可用的三個HASH

請求發送並處理之後,你會有三個 hash 物件可以使用:

  • cookies - 設定cookies
  • flash - 任何在 flash 中的物件
  • session - 任何在工作階段變數的物件

和普通 HASH 物件一樣,你可以使用字串的鍵值來獲取相對應的值,另外,你也可以使用符號來獲得值,例如:

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

8.5 可用的實體變數

request 之後,功能測試你一樣可以使用三個實體變數:

  • @controller - 處理請求 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 headersCGI variables 可以透過 headers 參數傳入:

# 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 訊息

如果你記得前面說的,在三個 hash 中有一個是 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

讓我們來增加 flash 訊息到我們的 controller,加上去後,我們的 :create action 會看起來像這樣:

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 測試其他的 action

到現在為止,我們測試了文章 controller:index:new:create ,我們要怎麼處理現有的數據?

我們來寫一下 :show 的測試:

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

還記得我們前面對夾具(fixtures)的討論嗎? 我們可以使用 articles() 方法去使用 Articles 的夾具(fixtures)。

那如何刪除已經存在的文章呢?

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

注意我們在這三個測試中開始看到有重複的地方了,他們都用了同一個夾具(fixture)的數據,避免重複,我們可以使用 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其他的 callbacks 一樣, setupteardown 方法都可以支援區塊、lambda、符號形式的方法命名。 s

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/lib 或是 test/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 積極的引入小幫手,這樣就可以很方便的找到他,因此你的測試檔案可以很隱晦地去使用它們,這邊可以使用 globbing 來完成,就像下面:

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

不過這樣會有增加啟動時間的缺點。因為不是在你個人測試中手動引入需要的檔案。

9、測試路徑 Testing Routes

就像跟其他Rails應用程式一樣,你可以測試路徑,路徑測試在 test/controllers/ 或者部分在 controller 測試裡面。

備註: 如果你的應用程式有很複雜的路徑,Rails提供一個很好用的測試小幫手來測試他們。

關於Rails中,更多可用的路徑斷言,可以看這個API文件 ActionDispatch::Assertions::RoutingAssertions

10、測試畫面 Testing Views

測試請求中,藉由斷言(assert)關鍵HTML元素的存在和內容,是一個在你測試應用程式畫面(views)中很常見的方式,就像路徑測試,畫面(views)測試在 test/controllers/,或者一部分在 controller 測試裡面, assert_select ,這個方法允許你查詢藉由使用簡單且強大的語法,去查詢回應的HTML元素。

有兩種形式的 assert_select

assert_select(selector, [equality], [message]) 測試選擇器所選的元素是否符合 equality 指定條件, selector 可以是CSS選擇表達式(字符串),或者是有代入值的表達式。

assert_select(element, selector, [equality], [message]) 測試選擇器選中的的元素和 _element_ (Nokogiri::XML::Node 的實體或是Nokogiri::XML::NodeSet) 及其他們的子代是否符合 equality 指定的條件。

例如,可以使用下面的斷言檢測 title 元素的內容:

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

你也可以用嵌套 assert_select 區塊,為了達到更深入的調查

在下面的範例,內層的 assert_select 區塊,會在外層區塊選中的元素集合中,查詢 li.menu_item 這個元素。

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

除此之外,還可以遍歷外層 assert_select 選中的元素集合,這樣就可以在集合的每個元素上運行內層 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 其他畫面(View)的斷言

這邊有幾個很常用在畫面(View)測試的斷言:

Assertion Purpose
assert_select_email 檢查電子信件的內文。
assert_select_encoded 檢查編碼後的HTML。 先解碼各元素的內容,然後在代碼區塊中處理解碼後的各個元素。
css_select(selector) or 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、測試小幫手 Testing Helpers

小幫手是一個簡單的模塊,裡面會有一些方法,這些方法你可以在畫面(view)中使用。

為了測試小幫手,你需要做的就是確認小幫手裡面的方法,有沒有跟你預期的一樣,與小助手相關的測試在 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

12、測試你的信件 Testing Your Mailers

測試信件類別需要一些特別的工具來做這件事情

12.1 檢查信件運作正常

你的信件類別:和其他Rails的應用程式一樣,應該要被測試以確保他們能如預期的工作。

測試信件類別的目標是確保:

  • 信件是可以運作的 (新增、寄送)
  • 信件內容是正確的 (主題、寄信人、內容…等等)
  • 確保信件是在正確的時間點被寄出去

12.1.1 全面測試

有兩個方向去測試信件,單元測試和功能測試,在單元測試,單獨執行寄送信件,嚴格的控制輸入,並且和已知值(夾具feature)做比較。在功能測試,不用測的這麼仔細,只要確保 controllermodel 有正確的運作使用信件流程,並在正確的時間點寄出信件。

12.2 單元測試

為了測試你的信件有如預期般的運作,你可以使用單元測試,把信件流程真正得到的結果和預先寫好的值進行比較。

12.2.1 夾具(feature)的另外功用

在單元測試中,夾具(feature)用於設定期望得到的值。因為這些夾具(feature)是範例信件,不是 Active Record 數據,所以要和其他夾具(feature)分開,放在單獨的子目錄中。這個子目錄位於 test/fixtures 資料夾中,其名稱與信件程序對應。例如,信件 UserMailer 使用的夾具(feature)存在 test/fixtures/user_mailer 資料夾中。

生成信件流程時,生成器會為其中每個動作生成相應的stub fixtures。如果沒使用生成器,要手動創建這些文件。

12.2.2 基本的測試用例

下面是一個單元測試,用來測試叫做 UserMailerinvite 動作,這個動作是用來向朋友發送邀請,這段程式碼改進了生成器為 invite 動作生成的測試。

require "test_helper"

class UserMailerTest < ActionMailer::TestCase
  test "invite" do
    # Create the email and store it for further assertions
    email = UserMailer.create_invite("me@example.com",
                                     "friend@example.com", 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 ["me@example.com"], email.from
    assert_equal ["friend@example.com"], email.to
    assert_equal "You have been invited by me@example.com", email.subject
    assert_equal read_fixture("invite").join, email.body.to_s
  end
end

在這個測試,我們新增一個信件、並把返回物件賦值給email變數,首先,我們確保信件已經發送了,再來確認信件包含預期的內容, read_fixture 這個輔助方法的作用是從指定的文件中讀取內容。

標註: email.body.to_s 只會在HTML和文本兩者其中之一存在的時候存在,如果 mailer 兩個都有提供,你可以使用 email.text_part.body.to_semail.html_part.body.to_s,來針對特定的部分測試夾具(fixture)。

這裡是 invite 夾具(fixture)的內容:

Hi friend@example.com,

You have been invited.

Cheers!

現在我們來更深入的了解你的信件測試,在 config/environments/test.rb 文件中,有這麼一行設置 :ActionMailer::Base.delivery_method = :test。這行設定把發送信件的方法設為 :test ,所以信件並不會真的發送出去(避免測試時騷擾用戶),而是添加到一個陣列中(ActionMailer::Base.deliveries)。

備註: ActionMailer::Base.deliveries 陣列只會在 ActionMailer::TestCaseActionDispatch::IntegrationTest 測試中自動重新設定,如果你想要在測試之外有乾淨的陣列,你可以用手動重設定 ActionMailer::Base.deliveries.clear

12.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("me@example.com", "friend@example.com")

    # Test that the email got enqueued with the correct arguments
    assert_enqueued_email_with UserMailer, :create_invite, args: ["me@example.com", "friend@example.com"] do
      email.deliver_later
    end
  end
end

這個範例會斷言,透過將參數的 hash 作為 args 傳遞,信件已使用叫做 arguments 的正確信件方法排隊:

require "test_helper"

class UserMailerTest < ActionMailer::TestCase
  test "invite" do
    # Create the email and store it for further assertions
    email = UserMailer.create_invite(from: "me@example.com", to: "friend@example.com")

    # Test that the email got enqueued with the correct named arguments
    assert_enqueued_email_with UserMailer, :create_invite, args: [{ from: "me@example.com",
                                                                    to: "friend@example.com" }] 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("me@example.com", "friend@example.com")

    # Test that the email got enqueued with the correct mailer parameters and arguments
    assert_enqueued_email_with UserMailer, :create_invite, params: { all: "good" },
                                                           args: ["me@example.com", "friend@example.com"] 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: "friend@example.com").create_invite

    # Test that the email got enqueued with the correct mailer parameters
    assert_enqueued_email_with UserMailer.with(to: "friend@example.com"), :create_invite do
      email.deliver_later
    end
  end
end

12.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: "friend@example.com" }
    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: "friend@example.com"
    assert_emails 1 do
      click_on "Invite"
    end
  end
end

備註: assert_emails 方法不依賴特定的傳遞方法,並且適用於使用 deliver_nowdeliver_later 方法傳遞的信件。如果我們明確的想要斷言信件已經排好隊,可以使用 assert_enqueued_email_with (examples above) 或者 assert_enqueued_emails 方法, 更多資訊可以在這邊找到 documentation here

13、工作測試 Testing Jobs

因為自定義的工作在應用的不同層排隊,所以我們既要測試工作本身(入隊後的行為),也要測試是否正確入隊了。

13.1 基本的測試用例

預設情況下,當你生成一個工作,一個相對應的測試會自動生成在 test/jobs 資料夾,下面是付款工作的測試範例:

require "test_helper"

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

這個測試非常簡單,並且只要斷言這個工作會如預期般的結束

預設下, ActiveJob::TestCase 將會把排隊調整器設定為 :test,因此你的工作會排隊進行,他還會確保在任何測試執行之前,先清除之前執行和排隊的工作,因此我們可以放心的假設當前測試範圍內沒有執行任何工作。

13.2 在其他組建內客製斷言和測試作業

Active Job 自帶了很多自定義的斷言,可以簡化測試。可用的斷言列表參見ActiveJob::TestHelper

不管工作是在哪裡調用的(例如在controller中),最好都要測試工作能正確入隊或執行。這時就體現了 Active Job 提供的自定義斷言的用處。例如,在model中:

require "test_helper"

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

  test "billing job scheduling" do
    assert_enqueued_with(job: BillingJob) do
      product.charge(account)
    end
  end
end

14、Testing Action Cable

因為在你的應用程式裡, Action Cable 被使用於不同的層級,所以你需要同時測試通道(channels)、連結類別(connection classes),而且要確認其他實體(entities)是否廣播(broadcast)正確的消息。

14.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

看這個API文件得到更多資訊 ActionCable::Connection::TestCase

14.2 通道測試用例

預設下,當你產生一個通道(channel),還會在 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

看這個API文件得到更多的資訊 ActionCable::Channel::TestCase

14.3 其他組建內部的客製化斷言和測試廣播

Action Cable 自帶了一堆字定義的斷言,可用於減少測試的冗長情況,這邊有完整可用的斷言列表,看這個API文件 ActionCable::TestHelper

確保在其他組建內(e.g. 你的 controllers 裡面)有廣播正確的訊息,是一個好作法,這就是 Action Cable 自定義斷言非常好用的地方,例如在model中:

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

15、測試預先加載 Testing Eager Loading

通常,應用程式不在急於在 開發 development測試 test 環境中加載,以加快速度,但我們會在 生產 production 環境的時候來做加載。

如果專案中某些文件因為某些原因不能被加載,你最好在部署到 生產 production 環境前檢測到他,對嗎?

15.1 持續整合 CI

如果你的專案有 持續整合 CI,在 持續整合 CI 中的預先加載,就是一個簡單的方式去確保應用程式有預先加載。

持續整合 CI 通常會設定一些環境變數,用來指示測試套件應該在哪裡運行,例如下面這樣,環境變數設定為 CI

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

從Rails 7開始,新生成的應用程式以這種方式設定。

15.2 Bare Test Suites

如果你的專案沒有 持續整合 CI,你仍然可以在測試插件中,呼叫 Rails.application.eager_load! 來達成預先加載。

15.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

15.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

16、其他測試資源 Additional Testing Resources

16.1 測試與時間有關的程式碼

Rails內建有提供一些小幫手方法,可以讓你斷言你設定的時間有如預期。

這裡有一個範例是使用 travel_to 小幫手:

# Lets say that 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
  assert_equal Date.new(2004, 10, 24), user.activation_date # inside the `travel_to` block `Date.current` is mocked
  assert user.applicable_for_gifting?
end
assert_equal Date.new(2004, 10, 24), user.activation_date # The change was visible only inside the `travel_to` block.

可以看這個時間小幫手的API文件,來得到更多的資訊 ActiveSupport::Testing::TimeHelpers API Documentation