rails Minitest

前言:說起“軟件測試”四個字,很多開發(fā)人員能夠想到黑盒測試蘑志、白盒測試累奈,還有集成測試贬派、系統(tǒng)測試、壓力測試等澎媒,可能許多人沒有想到會有單元測試搞乏。同時開發(fā)人員也會習慣性地將這些測試全部歸類為測試人員的工作。絕大多數(shù)的開發(fā)人員都是忙于把手頭的工作做好戒努,按時完成公司的任務请敦,并不會把單元測試納入工作范疇。許多人會說储玫,連功能開發(fā)都忙不過來了侍筛,哪有時間去做單元測試,況且還要寫測試代碼撒穷,那不是重復寫一遍代碼功能嗎匣椰,實際真的是這樣嗎?(本文主要以介紹單元測試為主)

一端礼、測試概念

1禽笑、測試是什么

回想我們在開發(fā)一個功能時的過程,寫完某個功能的代碼齐媒,大多數(shù)人經(jīng)常會刷新一下頁面點一下功能蒲每,或者在 Rails console 里手動調(diào)用方法來查看結果是否正確。這其實也是測試的一種表現(xiàn)形式(手動測試喻括,俗稱肉測)邀杏。從這點來說,測試無處不在唬血。因為你總要驗證你的功能是否正確望蜡。

2、手動測試的效率

既然“測試”這件事情是必須要做的拷恨,那我們考慮的就不是 “測不測”的問題了脖律,而是 “怎么更有效率地測” 。測試一般發(fā)生在新的代碼或代碼修改之后(沒有修改自然不會引入新的錯誤)腕侄,為了保險起見小泉,在代碼修改達到一定量的時候,需要把被影響的功能全部測試一遍冕杠。

這就產(chǎn)生兩個問題是:

1微姊、這一定是一個非常重復的過程。
2分预、如何界定什么是 “被影響的功能” 兢交?

人做重復勞動是非常低效且容易犯錯的,而且在大量重復勞動下很容易草草了事笼痹,比如 “我覺得這次改動對這個功能或其他功能沒什么影響配喳,不測也行”酪穿。主觀的判斷并不能在技術層面上做到保證,這會產(chǎn)生一些潛在的 bug晴裹,成為以后開發(fā)過程的拖累被济。

3、自動化測試的好處

一定要做而且重復的工作息拜,自然是交給程序來做更好溉潭。為一個功能寫測試代碼净响,第一次會花時間少欺,但以后就可以非常快速地檢查這個功能是否正確馋贤,在項目變復雜后也不用擔心添加新功能或者重構會不會破壞已有的功能(這也是我們系統(tǒng)中經(jīng)常遇到的bug類型之一)赞别。

4、單元測試

單元測試是用來對一個模塊配乓、一個函數(shù)或者一個類來進行正確性檢驗的測試工作仿滔。比如對一個獲取正數(shù)的函數(shù)get_positive_number(),我們可以編寫出以下幾個測試用例:

  1. 輸入正數(shù)犹芹,比如1崎页、1.20.99腰埂,期待返回值與輸入相同飒焦;
  2. 輸入負數(shù),比如-1屿笼、-1.2牺荠、-0.99,期待返回值與輸入相反驴一;
  3. 輸入0休雌,期待返回0
  4. 輸入非數(shù)值類型肝断,比如&杈曲、[等,期待拋出TypeError胸懈。

把上面的測試用例放到一個測試模塊里担扑,就是一個完整的單元測試。如果單元測試通過箫荡,說明我們測試的這個函數(shù)能夠正常工作魁亦。如果單元測試不通過,要么函數(shù)有bug羔挡,要么測試條件輸入不正確洁奈,總之间唉,需要修復使單元測試能夠通過。

單元測試通過后有什么意義呢利术?如果我們對get_positive_number()函數(shù)代碼做了修改呈野,只需要再跑一遍單元測試,如果通過印叁,說明我們的修改不會對get_positive_number()函數(shù)原有的行為造成影響被冒,如果測試不通過,說明我們的修改與原有行為不一致轮蜕,要么修改代碼昨悼,要么修改測試。

二跃洛、如何測試

下面這部分將為我們直觀地展示如何理解測試率触、如何去測試(例子出自ruby-china)。

1汇竭、理解測試

測試不是一個新概念葱蝗,相反部分社區(qū)可能過度狂熱,制造了太多的測試框架和庫细燎,增加了很多復雜性两曼,以至于讓人敬而遠之。其實測試只是一個簡單的概念玻驻,下面的例子將嘗試說明這一點悼凑。

先看一個例子,假如我們需要實現(xiàn)一個方法sing_dance(n)击狮,要求 n是一個整數(shù)佛析,如果n3的倍數(shù),就返回'Sing'彪蓬;如果n是 5 的倍數(shù)寸莫,就返回'Dance';其余則返回n 本身档冬。這個方法沒什么實際作用膘茎,但用來做例子很合適,我們假設這個方法是某個生產(chǎn)應用的關鍵算法酷誓。

這個方法很簡單披坏,一會就能寫出來:

# sing_dance.rb
def sing_dance(n)
  if n % 3 == 0
    'Sing'
  elsif n % 5 == 0
    'Dance'
  else
    n
  end
end

要驗證這個方法是否正確,可以在終端執(zhí)行這個方法查看結果:

> require './sing_dance.rb'
> sing_dance 1
=> 1
> sing_dance 2
=> 2
> sing_dance 3
=> "Sing"
> sing_dance 4
=> 4
> sing_dance 5
=> "Dance"

看起來沒問題盐数,于是就把這個方法用到產(chǎn)品環(huán)境中了……然后有一天棒拂,需求更改了,要求增加一個邏輯:如果 n 同時是 3 和 5 的倍數(shù),就返回 SingDance帚屉。而當前的實現(xiàn)只會返回 Sing

> sing_dance 15
=> "Sing"

于是修改這個方法:

# sing_dance.rb
def sing_dance(n)
  if n % 3 == 0 and n % 5 == 0
    'SingDance'
  elsif n % 3 == 0
    'Sing'
  elsif n % 5 == 0
    'Dance'
  else
    n
  end
end

然后到終端調(diào)試:

> sing_dance 15
=> "SingDance"

但是這個修改有沒有破壞以前的行為呢谜诫?這時候再用以前的數(shù)據(jù)調(diào)試一下:

> sing_dance 1
=> 1
> sing_dance 2
=> 2
> sing_dance 3
=> "Sing"
...

這里遇到一個問題,我們在重復以前的調(diào)試內(nèi)容攻旦。重復一兩次還沒問題喻旷,三次以上就很煩人了。并且隨著代碼量上升牢屋,越來越難確定修改會影響什么地方的邏輯且预,容易引入bug。高效程序員會將調(diào)試代碼固化下來烙无,寫成測試代碼锋谐。新建一個文件,寫入測試代碼:

# sing_dance_test.rb
require './sing_dance.rb'
sing_dance(1) == 1 ? print('.') : raise("sing_dance 1 should be 1")
sing_dance(3) == 'Sing' ? print('.') : raise("sing_dance 3 should be Sing")
sing_dance(5) == 'Dance' ? print('.') : raise("sing_dance 5 should be Dance")
puts 'done'

這個腳本會對比程序輸出和預期結果皱炉,如果結果一致就會打印一個點.怀估,否則會拋出異常,中止測試并打印錯誤信息合搅。

$ ruby sing_dance_test.rb
...done

我們可以故意把方法寫錯,看看有什么結果:

# sing_dance.rb
def sing_dance(n)
  n
end

再次運行歧蕉,結果就是:

$ ruby sing_dance_test.rb
.sing_dance_test.rb:4:in `<main>': sing_dance 3 should be Sing (RuntimeError)

有了測試腳本的幫助灾部,我們就能知道對代碼的修改有沒有破壞以前的邏輯。修改了代碼之后惯退,別忘了加上新增部分功能的測試:

sing_dance(15) == 'SingDance' ? print('.') : raise("sing_dance 15 should be SingDance")
2赌髓、assert(斷言)

之前的測試代碼里面有不少重復代碼,例如 print催跪,raise 等等锁蠕。我們可以把這些跟測試用例沒有直接關系的代碼抽取出通用方法,這類方法有一個慣用名稱 assert懊蒸,于是測試代碼簡化成:

def assert(test, msg = nil)
  test ? print '.' : raise(msg)
end
assert sing_dance(1) == 1, "sing_dance 1 should be 1"
assert sing_dance(3) == 'Sing', "sing_dance 3 should be Sing"
assert sing_dance(5) == 'Dance', "sing_dance 5 should be Dance"
assert sing_dance(15) == 'SingDance', "sing_dance 15 should be SingDance"
puts 'done'

測試代碼多了之后荣倾,會發(fā)現(xiàn)有一類測試有固定的模式,例如上面的測試就是判斷一個方法的輸出跟另一個值是否相等骑丸,這樣又可以抽取出一個 assert_equal 方法:

def assert(test, msg = nil)
  test ? print '.' : raise(msg)
end

def assert_equal(except, actual, msg = nil)
  assert(except == actual, msg)
end

assert_equal 1, sing_dance(1), "sing_dance 1 should be 1"
assert_equal 'Sing', sing_dance(3), "sing_dance 3 should be Sing"
assert_equal 'Dance', sing_dance(5), "sing_dance 5 should be Dance"
assert_equal 'SingDance', sing_dance(15), "sing_dance 15 should be SingDance"
puts 'done'

常見的assert_* 方法還有:

assert_nil(object, msg) 測試對象是否為 nil舌仍。
assert_empty(object, msg) 測試對象調(diào)用 .empty? 是否返回 true。
assert_includes(collection, object, msg) 測試集合 collection 是否包含 object通危。

這些方法都不過是 assert的包裝铸豁,只要知道 assert 的原理,這些輔助方法都能自己實現(xiàn)菊碟,或者實現(xiàn)其他適合場景的斷言方法节芥。
現(xiàn)在每個主流語言都會有一個測試庫,在 Ruby 中就是 Minitest逆害。測試庫除了包含一些斷言方法外头镊,還提供測試代碼隔離增炭、測試環(huán)境重置、更好的錯誤提示等功能拧晕。

為了項目的可維護性隙姿,也為了節(jié)約自己的時間,應該積極的擁抱測試厂捞。但也不要忘了測試只是輔助開發(fā)的工具输玷,不要本末倒置,使用太復雜的測試工具增加維護難度靡馁。同時不要為了測試而添加不必要的代碼欲鹏。

三、miniTest

MinitestRails 默認使用的測試庫臭墨。
下面以miniTest + mocha + factory_bot_rails 組成的測試環(huán)境來介紹如何測試赔嚎。

  1. minTest: rails的測試框架
  2. factory_bot_rails: 生成測試數(shù)據(jù)
  3. mocha: 各種模擬方法
1、miniTest
測試文件路徑:
$ ls test # 在項目根目錄下有個test文件
factories/ # 測試數(shù)據(jù)(factory_bot_rails)
requests/ # 接口測試
models/  #模型測試
test_helper.rb #測試的默認配置文件
測試數(shù)據(jù)庫配置:
# 在config/database.yml 文件中配置
test:
  database: postgres_test
  username: postgres
  password:123456
  host: localhost
  port: 5432
寫第一個測試
# test/models/app/app_user_test.rb
require 'test_helper'
class App::UserTest < ActiveSupport::TestCase
  # Rails為我們提供了兩種測試寫法
  # 方式一
  test 'the truth' do
    assert true
  end

  # 方式二
  def test_the_truth
    assert true
  end
end

我們用App::UserTest 類定義一個測試用例(test case)胧弛,它繼承自 ActiveSupport::TestCase尤误,在繼承了Minitest::TestActiveSupport::TestCase 的超類)的類中定義的方法后,只要名稱以 test_開頭(區(qū)分大小寫)结缚,就是一個可執(zhí)行的“測試”损晤。

執(zhí)行測試:
bin/rails test test/models/app/user_test.rb:6 
# 1、可以加上行號6红竭,即只執(zhí)行第六行代碼對應的測試尤勋;
# 2、也可指定測試方法的名稱 -n test_the_truth 或者 -name test_the_truth
# 3茵宪、如果什么都不加最冰,則執(zhí)行文件user_test.rb下的所有測試用例。
執(zhí)行成功的結果:
# 執(zhí)行的結果輸出一目了然稀火,無任何異常
Run options: --seed 3259
# Running:
...............................................................................................................
Finished in 1.469483s, 75.5368 runs/s, 11.5687 assertions/s.
111 runs, 17 assertions, 0 failures, 0 errors, 0 skips
執(zhí)行失敗的結果:
Run options: --seed 16319
# Running:
..................F

Failure:
App::UserTest#test_to_s [.../test/models/app/user_test.rb:31]:
Expected "王總監(jiān)" to not be equal to "王總監(jiān)".

bin/rails test test/models/app/user_test.rb:30

............................................................................................

Finished in 1.498823s, 74.0581 runs/s, 11.3422 assertions/s.
111 runs, 17 assertions, 1 failures, 0 errors, 0 skips

斷言部分已經(jīng)在上面介紹過了暖哨, Minitest也為我們提供了多種斷言,更多內(nèi)容請參考Rails測試指南憾股。

2鹿蜀、數(shù)據(jù)工廠factory_bot
配置:
# Gemfile
group :development, :test do
  gem 'factory_bot_rails'
end

# test/test_helper.rb文件中引入
class ActiveSupport::TestCase
  include FactoryBot::Syntax::Methods
end
文件位置:

默認情況下,factory_bot_rails將自動加載 在以下位置定義的數(shù)據(jù)

factories.rb
test/factories.rb
spec/factories.rb
factories/*.rb
test/factories/*.rb # 我們在用的目錄
spec/factories/*.rb

factory_bot定義數(shù)據(jù):

# test/factories/app_user.rb
FactoryBot.define do
  factory :app_user, class: 'App::User' do
    id 10001
    name {'張三'}
    user_type: 1
  end
end

使用:

test 'user name'
  user = create(:app_user)
  assert_equal '張三', user.name
end

更多用法參考:https://github.com/thoughtbot/factory_bot/wiki

3服球、mocha

配置:

# Gemfile
gem 'mocha'

# test/test_helper.rb
require 'mocha/minitest'

使用場景:

# 例如有下面一個實例方法
# app/models/app/user.rb
def get_user_type
  return 1 if self.is_valid?
  return 2 if self.user_type == 2
  return 0
end

我們該如何測試呢茴恰?我們在測試方法get_user_type,但發(fā)現(xiàn)這個方法里面調(diào)用了另外一個實例方法斩熊。在這里往枣,我們并不需要過多的考慮is_valid?方法是如何執(zhí)行的,我們只要確保它返會truefalse即可。我們只需要考慮get_user_type方法的執(zhí)行邏輯是正確的就行分冈,而 is_valid?方法自有其對應的測試方法圾另,不需要get_user_type對其測試,這樣我們就能將方法與方法的測試隔離開雕沉,使其不會相互影響集乔。

 # test/models/app/user_test.rb
 test 'get user type' do
    user = create(:app_user)
    user.expects(:is_valid?).returns(true)
    assert_equal 1, user.get_user_type
    ...
 end

下面說一說stubsexpectsmock的區(qū)別

我們可以將stubsexpects當做是偽造方法坡椒, mock是偽造對象扰路。
stubs不限制它所偽造的方法的調(diào)用次數(shù):0~n
expects限制調(diào)用次數(shù):1 次。當被執(zhí)行多次或0次都回報錯倔叼。如果加上了at_least_once則多次調(diào)用時將不會再報錯汗唱。

stubs(模擬調(diào)用):

在寫測試的過程中,我們常常會希望某個方法返回我們希望的值丈攒,不管它如何執(zhí)行的哩罪,這時可以用stubs

在下面這段代碼中巡验,我們需要測試可以成功申請支付寶退款际插,而實際代碼中,申請支付寶退款是一個http請求深碱,沒有真實的訂單號我們一定會申請失敗腹鹉,所以我們模擬一下它的返回。

class PayService
  def do_drawback(order)
    if apply(order)
      return order.update(state: 1)
    else
      return false
    end
  end

  def apply(order)
    RestClient.post(url, order.id) # 調(diào)用成功會返回 true
  end
end
test 'apply pay drawback success' do
  #any_instance表示該service的任意實例對象
  PayService.any_instance.stubs(:apply).returns(true)

  order = Order.first
  service = PayService.new()
  service.do_drawback
  assert_equal order.state, 1
end
expects( 期待調(diào)用)

expects模擬的類或?qū)嵗椒ū仨氄{(diào)用一次敷硅,否則會報錯'not all expectations were satisfied unsatisfied expectations: - expected exactly once, not yet invoked:'

某個功能在執(zhí)行過程中會調(diào)用一個其他系統(tǒng)服務,或者某個功能會插入一個任務到異步隊列愉阎。這是我們需要秉承一個原則:自己的功能自己測绞蹦。即我不關心其他服務的功能是否正確,我認為只要我成功調(diào)用了就是正確的榜旦。

#一個消息隊列的pusher
class Msg::Publisher
  def self.publish(key, msg = {})
    # push 消息體到隊列
  end
end
class Order
  def submit
    # ...業(yè)務邏輯
     Msg::Publisher.puhlish('pay/order/submit', {xxx})
  end
end

test 'send a message if order submit success' do
  #注意幽七,期待調(diào)用的方法一定要寫在實際調(diào)用前
  #這段代碼表示期待Msg::Publisher的publish方法在本測試中至少調(diào)用一次,并且第一個參數(shù)是"pay/order/submit"溅呢,any_parameters表示后面的可以是任意參數(shù)
  Msg::Publisher.expects(:publish).with("pay/order/submit",any_parameters).at_least_once.returns(true)

  order = Order.new
  order.submit
  # order.submit
  #如果Msg::Publisher沒有調(diào)用publish澡屡,測試結果會是失敗

  # 如果未加at_least_once,卻曾經(jīng)調(diào)用兩次order.submit咐旧,也會導致失敗
  # ‘unsatisfied expectations:- expected exactly once, invoked twice’
end
mock對象

有時某個方法可能會需要一個很復雜的參數(shù)驶鹉,或者某個方法返回的一個結果對象會影響剩余方法的執(zhí)行,這時我們可以使用mock

def require_key_code
    if self.app_key_set && self.app_key_set.is_valid?
      return 'no'
    end
    return 'no' if self.app_user_type.in?([12,13])
    return 'yes' if self.app_user_type.in?([2,3,21])
    self.next_verify_at && self.next_verify_at > Time.now ? 'no' : 'yes'
 end

test 'require key code' do
    #創(chuàng)建一個Mock對象铣墨,設置它的is_valid?方法返回false
    key_set = mock()
    key_set.stubs(:is_valid?).returns(false) 

    #設置app_user查到任意實例對象調(diào)用app_key_set方法都返回mock
    App::User.any_instance.stubs(:app_key_set).returns(key_set)
    assert_equal @employee_user.require_key_code, 'yes'
    ...
    ...
end

mocha的更多內(nèi)容和事例請參考:https://github.com/freerange/mocha/

四室埋、總結:

對于測試覆蓋率問題,不要嘗試去達到100%
單元測試可以有效地測試某個程序模塊的行為姚淆;
單元測試的測試用例要覆蓋基本邏輯孕蝉、邊界條件和異常;
單元測試代碼要非常簡單腌逢,如果測試代碼太復雜降淮,那么測試代碼本身就可能有bug。
(歡迎補充搏讶、提問)
參考:
https://ruby-china.github.io/rails-guides/testing.html
https://github.com/freerange/mocha/
https://github.com/thoughtbot/factory_bot_rails
https://github.com/thoughtbot/factory_bot
https://chloerei.com/2015/10/26/testing-guide/
https://ruby-china.org/

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末佳鳖,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子窍蓝,更是在濱河造成了極大的恐慌腋颠,老刑警劉巖,帶你破解...
    沈念sama閱讀 222,000評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件吓笙,死亡現(xiàn)場離奇詭異淑玫,居然都是意外死亡,警方通過查閱死者的電腦和手機面睛,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,745評論 3 399
  • 文/潘曉璐 我一進店門絮蒿,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人叁鉴,你說我怎么就攤上這事土涝。” “怎么了幌墓?”我有些...
    開封第一講書人閱讀 168,561評論 0 360
  • 文/不壞的土叔 我叫張陵但壮,是天一觀的道長。 經(jīng)常有香客問我常侣,道長蜡饵,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 59,782評論 1 298
  • 正文 為了忘掉前任胳施,我火速辦了婚禮溯祸,結果婚禮上,老公的妹妹穿的比我還像新娘舞肆。我一直安慰自己焦辅,他們只是感情好,可當我...
    茶點故事閱讀 68,798評論 6 397
  • 文/花漫 我一把揭開白布椿胯。 她就那樣靜靜地躺著筷登,像睡著了一般。 火紅的嫁衣襯著肌膚如雪压状。 梳的紋絲不亂的頭發(fā)上仆抵,一...
    開封第一講書人閱讀 52,394評論 1 310
  • 那天跟继,我揣著相機與錄音,去河邊找鬼镣丑。 笑死舔糖,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的莺匠。 我是一名探鬼主播金吗,決...
    沈念sama閱讀 40,952評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼趣竣!你這毒婦竟也來了摇庙?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 39,852評論 0 276
  • 序言:老撾萬榮一對情侶失蹤遥缕,失蹤者是張志新(化名)和其女友劉穎卫袒,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體单匣,經(jīng)...
    沈念sama閱讀 46,409評論 1 318
  • 正文 獨居荒郊野嶺守林人離奇死亡夕凝,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,483評論 3 341
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了户秤。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片码秉。...
    茶點故事閱讀 40,615評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖鸡号,靈堂內(nèi)的尸體忽然破棺而出转砖,到底是詐尸還是另有隱情,我是刑警寧澤鲸伴,帶...
    沈念sama閱讀 36,303評論 5 350
  • 正文 年R本政府宣布府蔗,位于F島的核電站,受9級特大地震影響汞窗,放射性物質(zhì)發(fā)生泄漏礁竞。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,979評論 3 334
  • 文/蒙蒙 一杉辙、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧捶朵,春花似錦蜘矢、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,470評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至红碑,卻和暖如春舞吭,著一層夾襖步出監(jiān)牢的瞬間泡垃,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,571評論 1 272
  • 我被黑心中介騙來泰國打工羡鸥, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留蔑穴,地道東北人。 一個月前我還...
    沈念sama閱讀 49,041評論 3 377
  • 正文 我出身青樓惧浴,卻偏偏與公主長得像存和,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子衷旅,可洞房花燭夜當晚...
    茶點故事閱讀 45,630評論 2 359