Minitest 加點一杯 Mocha 帶走
相信大家平常都有在寫測試吧?咦沒有嗎?!俗話說有寫有保祐,如果還不知道怎麼入門的話,可以參考同事寫的這篇《為你自己學 Ruby on Rails:寫測試讓你更有信心》。不過呢,今天要業配的不是書中範例使用的 RSpec,而是華文圈較少有人著墨的 minitest。
Minitest, Test Unit 傻傻分不清楚?
會混淆也是理所當然的!以大部分人熟悉的 Rails 來說,在不做任何設定修改的預設狀態下,執行 generator,會看到類似以下的結果:
https://imgur.com/sZVPTqx 打開 Rails 產生的測試檔案來看看,可以知道 Rails 幫我們長出了繼承自 ActiveSupport::TestCase
物件的 BookTest
物件:
# test/models/book_test.rb
class BookTest < ActiveSupport::TestCase
end
再往下找 Rails 原始碼的話,就會發現 ActiveSupport::TestCase
本身就是繼承自 minitest:
# activesupport/lib/active_support/test_case.rb
module ActiveSupport
class TestCase < ::Minitest::Test
end
end
也就是說骨子裡明明是 minitest,但餵給 generator 的參數卻是命名為 :test_unit
,這樣不會搞混才奇怪 😂
不重要的考古(但資料查了很久,請捧場看一下)
那這樣問題就來了,這個 :test_unit
是從哪來的呢?這就需要講古了,因為真的很混亂,如果有哪裡寫錯了還請不吝指正 🙇
時間先回到 Ruby 的上古時代。當時,一個叫作 test-unit 的 gem 的 1.2.3 版本被收入了 Ruby 1.8 的標準函式庫(standard library),其模組名稱為 Test::Unit
。這個名詞等下會一直出現。
在仔細比較過 Ruby 1.8 和 Ruby 1.9.3 的原始碼後可以發現:Ruby 1.9.3 的 Test::Unit
根本被 core team 魔改成了 minitest,和原本的 test-unit 1.2.3 已經沒有太大關係了 😱
這段還有後話,就是 Ruby 2.2 將
Test::Unit
和Minitest
都移出了標準函式庫(不由官方維護,只作為內建的測試工具)。當初有希望兩者能夠被整合的聲音,不過時至今日,Ruby 原始碼裡依然是兩套並行,有興趣的人可以參考當時的討論。
講完 Ruby,再拉回到 Rails 這邊。Rails 一開始便採用了(當時內建於 Ruby 的)Test::Unit
作為其測試工具,並在 Rails 2 開始提供繼承自 Test::Unit
的 ActiveSupport::TestCase
模組。
至於前面用到的 generator,則是 Rails 3 加入的功能。這樣看來,參數名稱當時選用 :test_unit
也是合情合理。一直到了 Rails 4,才將 Test::Unit
全面汰換為 Minitest
,:test_unit
的名稱大概也就這樣原原本本沿用下來了。
言歸正傳
那麼,minitest 和 RSpec 有什麼差別呢?不管怎麼說,最重要的還是寫測試本身,用什麼工具倒是其次(但編輯器當然只有 vim 好!)。RSpec 由於有完備的生態圈而受到業界青睞,不過筆者個人的 side project 喜歡用 minitest,原因主要是:
- 直接內建於 Rails,不用另外設定
- 可以寫純 Ruby,不用另外記 DSL 語法
- 執行速度超快(個人體感)
- 預設的執行順序是隨機的,讓你自然而然學會寫出獨立、互不影響的測試
- 喜歡的開源專案很多都是用 minitest
而當專案沒有文件時,測試就是文件
基本使用
為了方便 demo,我們先來做一個簡單的 Ruby 專案(是 Ruby,不是 Rails 喔)。
先把 minitest 放進 Gemfile 裡:
# Gemfile
source "https://rubygems.org"
gem "minitest"
再來把 minitest/autorun
require 進來:
# demo.rb
require "minitest/autorun"
執行 bundle install
,事前準備就完成了。檔案目錄大概會長得像這樣:
jodeci@~/codebase/lurofan: tree
.
├── Gemfile
├── Gemfile.lock
└── demo.rb
來寫第一個測試
還記得前面介紹的,Rails 內建的 ActiveSupport::TestCase
嗎?它本身就是繼承自 Minitest::Test
,我們也來這樣實做:
# demo.rb
class LurofanTest < Minitest::Test
end
對,這是個測試滷肉飯的程式 XD 馬上來寫第一個測試看看:
# demo.rb
class LurofanTest < Minitest::Test
def test_default_price
lurofan = Lurofan.new
assert_equal 50, lurofan.price
end
end
assert_equal
這個方法很重要,它可以滿足我們幾乎 90% 的需求。範例用中文來說的話,就是「確認滷肉飯的價錢是 50 元」的意思。
要注意的是,測試方法必須以 test_
前綴。Rails 有另外提供下面這種語法,但筆者自己是偏好當成一般的 Ruby 方法寫就好。
test "default price" do
end
馬上來執行看看:
jodeci@~/codebase/lurofan: ruby demo.rb
Run options: --seed 45041
# Running:
E
Error:
LurofanTest#test_default_price:
NameError: uninitialized constant LurofanTest::Lurofan
Did you mean? LurofanTest
demo.rb:5:in `test_default_price'
bin/rails test demo.rb:4
當然很愉快地噴錯了,因為我們還沒寫 Lurofan
的本體。由於只是 demo,我們直接接在同一個檔案裡面補上去就好:
# demo.rb
class Lurofan
attr_reader :price
def initialize(price: 50)
@price = price
end
end
再來執行一次,測試就成功了:
jodeci@~/codebase/lurofan: ruby demo.rb
Run options: --seed 51576
# Running:
.
Finished in 0.001230s, 813.0081 runs/s, 813.0081 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
除了 assert_equal
之外,minitest 也提供了檢驗布林值的 assert
、檢驗空值的 assert_nil
等各種 assertion 方法,詳細可以參考官方文件。
孤立測試
有注意到兩次執行結果中的這一行嗎?
# 第一次
Run options: --seed 45041
# 第二次
Run options: --seed 51576
範例檔只有一個測試方法 test_default_price
所以感覺不明顯,但 minitest 其中一個好處,就是它每次執行時,test_
開頭的測試方法是會隨機執行,沒有固定順序的。Rspec 也有這個功能,但它不是預設值,你需要另外加 --option random
的參數進去才會打亂。
打亂有什麼好處呢?這是 isolation testing 的概念;我們希望每個 test_xxx
方法都能被獨立執行,不受外部條件影響,當程式出錯時,也方便快速鎖定問題的源頭。將測試執行順序打亂就是一個成本很低的「及早發現問題」的方式,而當這是測試框架的預設行為時,成本就變得更低了。
minitest/spec
覺得 minitest 太陽春了嗎?懷念 Rspec 的語法嗎?雖然對我來說這是賣點沒問題,minitest 也支援 minitest/spec
的寫法:
describe Lurofan do
before do
@lurofan = Lurofan.new
end
it "default price is 50" do
@lurofan.price.must_equal 50
end
end
這裡則是使用 expectation 的方式來做檢驗。雖然骨子裡和 assertion 是一樣的東西,但對有 Rspec 經驗的人來說可能比較親切?
以上兩種風格都各有優缺點,也有主觀的喜好。筆者個人是偏好非 DSL 的寫法,理由主要是「測試程式就是普通的 Ruby 程式」,不需要把測試程式當成特殊的東西,平常怎麼寫就怎麼測,refactor 起來也很直覺,例如剛才的範例可以改寫成這樣:
require "minitest/autorun"
class Lurofan
attr_reader :price
def initialize(price: 50)
@price = price
end
def has_pork?
true
end
end
class LurofanTest < Minitest::Test
def test_default_price
assert_equal 50, subject.price
end
def test_has_pork
assert subject.has_pork?
end
private
def subject
Lurofan.new
end
end
mocha
筆者個人雖然是 minitest 的愛用者,也不得不承認它內建的 mocking/stubbing 工具很不好用(有興趣的人可以參考附錄的教學文,應該會想摔鍵盤)。所幸我們有 mocha 可以解決這個問題。總之我們先把 mocha 裝進剛才的專案裡 :
# Gemfile
source "https://rubygems.org"
gem "minitest"
gem "mocha"
# demo.rb
require "minitest/autorun"
require "mocha/minitest"
class Lurofan
# ...
end
class LurofanTest < Minitest::Test
# ...
end
mocha 的文件滿詳細的,這邊只簡單介紹一下常用的 stubs
和 expects
就好。還是用剛才的滷肉飯來舉例,我們來加進折扣的功能:
require "minitest/autorun"
require "mocha/minitest"
class Lurofan
attr_reader :price
def initialize(price: 50)
@price = price
end
def discount_price
self.price - DiscountService.call
end
end
class LurofanTest < Minitest::Test
def test_discount_price
DiscountService.expects(:call).returns(10)
assert_equal 90, Lurofan.new(price: 100).discount_price
end
end
在物件導向的概念中,每個物件都只想到它自己。
對於 LurofanTest
來說,它只關心 discount_price
算出來的價錢是否正確,而不關心 DiscountService
是怎麼算出折扣來的(那應該是 DiscountServiceTest
要去擔心的事情)。因此我們直接去 stub 折扣的結果,只要確認最後兩者相減的結果是正確的就可以了。
stubs 和 expects 的差別
stubs
和 expects
的用法大同小異,那麼差別是在哪裡呢?大概可以這樣去理解:使用 expects
表示你關心所模擬的方法「有被執行到」,而使用 stubs
則表示你只是要把「模擬的值」準備好備用,有沒有被實際執行到反而不重要。
在範例中我們選擇使用 expects
,正是因為對 Lurofan
來說,重要的是「DiscountService 有被呼叫到」。大家在設計測試時也可以多想想,怎樣的寫法才是最適合的。
結語
Rspec 雖然是業界主流,但 minitest 也有其輕便好用的地方。作為一名 Ruby 工程師,兩者兼修不是壞事尤其是要去研究開源專案的原始碼時。對筆者來說,使用 minitest 最大的好處就是可以直接用平常寫 Ruby 的思路,不需要「再多學一套」。最後,重要的事要說三次:不管你用什麼工具,記得要寫測試 XD
參考資料
- Getting Started with Minitest
- My experience with Minitest and RSpec
- 7 REASONS I’M STICKING WITH MINITEST AND FIXTURES IN RAILS
- Bow Before MiniTest
- Mocking in Ruby with Minitest
- Back to Basics: Writing Unit Tests First
(Photo by rawpixel on Unsplash)