Minitest 加點一杯 Mocha 帶走

相信大家平常都有在寫測試吧?咦沒有嗎?!俗話說有寫有保祐,如果還不知道怎麼入門的話,可以參考同事寫的這篇《為你自己學 Ruby on Rails:寫測試讓你更有信心》。不過呢,今天要業配的不是書中範例使用的 RSpec,而是華文圈較少有人著墨的 minitest

Minitest, Test Unit 傻傻分不清楚?

會混淆也是理所當然的!以大部分人熟悉的 Rails 來說,在不做任何設定修改的預設狀態下,執行 generator,會看到類似以下的結果:

test_unit 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.8Ruby 1.9.3 的原始碼後可以發現:Ruby 1.9.3 的 Test::Unit 根本被 core team 魔改成了 minitest,和原本的 test-unit 1.2.3 已經沒有太大關係了 😱

這段還有後話,就是 Ruby 2.2 將 Test::UnitMinitest 都移出了標準函式庫(不由官方維護,只作為內建的測試工具)。當初有希望兩者能夠被整合的聲音,不過時至今日,Ruby 原始碼裡依然是兩套並行,有興趣的人可以參考當時的討論

講完 Ruby,再拉回到 Rails 這邊。Rails 一開始便採用了(當時內建於 Ruby 的)Test::Unit 作為其測試工具,並在 Rails 2 開始提供繼承自 Test::UnitActiveSupport::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 的文件滿詳細的,這邊只簡單介紹一下常用的 stubsexpects 就好。還是用剛才的滷肉飯來舉例,我們來加進折扣的功能:

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 的差別

stubsexpects 的用法大同小異,那麼差別是在哪裡呢?大概可以這樣去理解:使用 expects 表示你關心所模擬的方法「有被執行到」,而使用 stubs 則表示你只是要把「模擬的值」準備好備用,有沒有被實際執行到反而不重要。

在範例中我們選擇使用 expects,正是因為對 Lurofan 來說,重要的是「DiscountService 有被呼叫到」。大家在設計測試時也可以多想想,怎樣的寫法才是最適合的。

結語

Rspec 雖然是業界主流,但 minitest 也有其輕便好用的地方。作為一名 Ruby 工程師,兩者兼修不是壞事尤其是要去研究開源專案的原始碼時。對筆者來說,使用 minitest 最大的好處就是可以直接用平常寫 Ruby 的思路,不需要「再多學一套」。最後,重要的事要說三次:不管你用什麼工具,記得要寫測試 XD

參考資料

(Photo by rawpixel on Unsplash)