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_password 和 test_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_method 和 send 兩種函式,但形式上對名稱幾乎是沒有限制的。

接下來,讓我們來看第一個斷言:

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] ) 確保 obj 在 collection 裡面。
assert_not_includes( collection, obj, [msg] ) 確保 obj 不在 collection 裡面。
assert_in_delta( expected, actual, [delta], [msg] ) 確保 expected 和 actual 的差值在 delta 範圍中。
assert_not_in_delta( expected, actual, [delta], [msg] ) 確保 expected 和 actual 的差值不在 delta 範圍中。
assert_in_epsilon ( expected, actual, [epsilon], [msg] ) 確保 expected 和 actual 的差值相對誤差小於 epsilon.
assert_not_in_epsilon ( expected, actual, [epsilon], [msg] ) 確保 expected 和 actual 的差值相對誤差沒有小於 epsilon.
assert_throws( symbol, [msg] ) { block } 確保 指定區塊會丟出指定的符號。
assert_raises( exception1, exception2, ... ) { block } 確保 指定區塊會引發指定的例外訊息。
assert_instance_of( class, obj, [msg] ) 確保 obj 是 class 的實體。
assert_not_instance_of( class, obj, [msg] ) 確保 obj 不是 class 的實體。
assert_kind_of( class, obj, [msg] ) 確保 obj 是 class 或其子代的實體。
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-0和 test-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.rb、db/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::Blob 和 ActiveStorage::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 container, Capybara 需要更多關於 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_screenshot 和 take_failed_screenshot, take_failed_screenshot 是自動包含在Rails裡面的 before_teardown , take_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模擬 action 是 index 的請求,確認請求成功並且確保可以生成正確的回應主體。

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

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

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

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

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

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

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

再一個例子: 呼叫 :create 的 action 來創造新的文章,用 JSON 請求,在 params 為 title 傳進新的文本:

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中,最常使用的是 get 、 post 、 put 和 delete。

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

8.3 測試 XHR (Ajax) 請求

測試 Ajax 請求,可以在 get 、 post 、 patch 、 put 和 delete 方法中設定 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 headers 和 CGI 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 提供的 setup 和 teardown 方法。

為了簡潔,現在先忽略其他測試。我們測試應該如下所示:

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 一樣, setup 和 teardown 方法都可以支援區塊、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_to 、 pluralize。

12、測試你的信件 Testing Your Mailers

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

12.1 檢查信件運作正常

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

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

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

12.1.1 全面測試

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

12.2 單元測試

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

12.2.1 夾具(feature)的另外功用

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

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

12.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("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_s 或 email.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::TestCase 和 ActionDispatch::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_now 、 deliver_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。