前言:說起“軟件測試”四個字,很多開發(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()
,我們可以編寫出以下幾個測試用例:
- 輸入正數(shù)犹芹,比如
1
崎页、1.2
、0.99
腰埂,期待返回值與輸入相同飒焦;- 輸入負數(shù),比如
-1
屿笼、-1.2
牺荠、-0.99
,期待返回值與輸入相反驴一;- 輸入
0
休雌,期待返回0
;- 輸入非數(shù)值類型肝断,比如
&
杈曲、[
等,期待拋出TypeError
胸懈。
把上面的測試用例放到一個測試模塊里担扑,就是一個完整的單元測試。如果單元測試通過箫荡,說明我們測試的這個函數(shù)能夠正常工作魁亦。如果單元測試不通過,要么函數(shù)有bug羔挡,要么測試條件輸入不正確洁奈,總之间唉,需要修復使單元測試能夠通過。
單元測試通過后有什么意義呢利术?如果我們對get_positive_number()
函數(shù)代碼做了修改呈野,只需要再跑一遍單元測試,如果通過印叁,說明我們的修改不會對get_positive_number()
函數(shù)原有的行為造成影響被冒,如果測試不通過,說明我們的修改與原有行為不一致轮蜕,要么修改代碼昨悼,要么修改測試。
二跃洛、如何測試
下面這部分將為我們直觀地展示如何理解測試率触、如何去測試(例子出自ruby-china
)。
1汇竭、理解測試
測試不是一個新概念葱蝗,相反部分社區(qū)可能過度狂熱,制造了太多的測試框架和庫细燎,增加了很多復雜性两曼,以至于讓人敬而遠之。其實測試只是一個簡單的概念玻驻,下面的例子將嘗試說明這一點悼凑。
先看一個例子,假如我們需要實現(xiàn)一個方法sing_dance(n)
击狮,要求 n
是一個整數(shù)佛析,如果n
是3
的倍數(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
Minitest
是Rails
默認使用的測試庫臭墨。
下面以miniTest + mocha + factory_bot_rails
組成的測試環(huán)境來介紹如何測試赔嚎。
-
minTest
:rails
的測試框架 -
factory_bot_rails
: 生成測試數(shù)據(jù) -
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::Test
(ActiveSupport::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í)行的,我們只要確保它返會true
或false
即可。我們只需要考慮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
下面說一說stubs
、expects
與mock
的區(qū)別
我們可以將stubs
與expects
當做是偽造方法坡椒, 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/