Golang 单元测试详尽指引
文末有彩蛋。
作者:yukkizhang,騰訊 CSIG 專項技術(shù)測試工程師
本篇文章站在測試的角度,旨在給行業(yè)平臺乃至其他團隊的開發(fā)同學(xué),進行一定程度的單元測試指引,讓其能夠快速的明確單元測試的方式方法。 本文主要從單元測試出發(fā),對Golang的單元測試框架、Stub/Mock框架進行簡單的介紹和選型推薦,列舉出幾種針對于Mock場景的最佳實踐,并以具體代碼示例進行說明。
一、單元測試
1. 單元測試是什么
單元是應(yīng)用的最小可測試部件。在過程化編程中,一個單元就是單個程序、函數(shù)、過程等;對于面向?qū)ο缶幊?#xff0c;最小單元就是方法,包括基類、超類、抽象類等中的方法。單元測試就是軟件開發(fā)中對最小單位進行正確性檢驗的測試工作。
不同地方對單元測試有的定義可能會有所不同,但有一些基本共識:
單元測試是比較底層的,關(guān)注代碼的局部而不是整體。
單元測試是開發(fā)人員在寫代碼時候?qū)懙摹?/p>
單元測試需要比其他測試運行得快。
2. 單元測試的意義
提高代碼質(zhì)量。代碼測試都是為了幫助開發(fā)人員發(fā)現(xiàn)問題從而解決問題,提高代碼質(zhì)量。
盡早發(fā)現(xiàn)問題。問題越早發(fā)現(xiàn),解決的難度和成本就越低。
保證重構(gòu)正確性。隨著功能的增加,重構(gòu)(修改老代碼)幾乎是無法避免的。很多時候我們不敢重構(gòu)的原因,就是擔(dān)心其它模塊因為依賴它而不工作。有了單元測試,只要在改完代碼后運行一下單測就知道改動對整個系統(tǒng)的影響了,從而可以讓我們放心的重構(gòu)代碼。
簡化調(diào)試過程。單元測試讓我們可以輕松地知道是哪一部分代碼出了問題。
簡化集成過程。由于各個單元已經(jīng)被測試,在集成過程中進行的后續(xù)測試會更加容易。
優(yōu)化代碼設(shè)計。編寫測試用例會迫使開發(fā)人員仔細思考代碼的設(shè)計和必須完成的工作,有利于開發(fā)人員加深對代碼功能的理解,從而形成更合理的設(shè)計和結(jié)構(gòu)。
單元測試是最好的文檔。單元測試覆蓋了接口的所有使用方法,是最好的示例代碼。而真正的文檔包括注釋很有可能和代碼不同步,并且看不懂。
3. 單元測試用例編寫的原則
3.1 理論原則
快。單元測試是回歸測試,可以在開發(fā)過程的任何時候運行,因此運行速度必須快
一致性。代碼沒有改變的情況下,每次運行得結(jié)果應(yīng)該保持確定且一致
原子性。結(jié)果只有兩種情況:Pass / Fail
用例獨立。執(zhí)行順序不影響;用例間沒有狀態(tài)共享或者依賴關(guān)系;用例沒有副作用(執(zhí)行前后環(huán)境狀態(tài)一致)
單一職責(zé)。一個用例只負責(zé)一個場景
隔離。功能可能依賴于數(shù)據(jù)庫、web 訪問、環(huán)境變量、系統(tǒng)時間等;一個單元可能依賴于另一部分代碼,用例應(yīng)該解除這些依賴
可讀性。用例的名稱、變量名等應(yīng)該具有可讀性,直接表現(xiàn)出該測試的目標(biāo)
自動化。單元測試需要全自動執(zhí)行。測試程序不應(yīng)該有用戶輸入;測試結(jié)果應(yīng)該能直接被電腦獲取,不應(yīng)該由人來判斷。
3.2 規(guī)約原則
在實際編寫代碼過程中,不同的團隊會有不同團隊的風(fēng)格,只要團隊內(nèi)部保持有一定的規(guī)約即可,比如:
單元測試文件名必須以 xxx_test.go 命名
方法必須是 TestXxx 開頭,建議風(fēng)格保持一致(駝峰或者下劃線)
方法參數(shù)必須 t *testing.T
測試文件和被測試文件必須在一個包中
3.3 衡量原則
單元測試是要寫額外的代碼的,這對開發(fā)同學(xué)的也是一個不小的工作負擔(dān),在一些項目中,我們合理的評估單元測試的編寫,我認為我們不能走極端,當(dāng)然理論上來說全寫肯定時好的,但是從成本,效率上來說我們必須做出權(quán)衡,衡量原則如下:
優(yōu)先編寫核心組件和邏輯模塊的測試用例
邏輯類似的組件如果存在多個,優(yōu)先編寫其中一種邏輯組件的測試用例
發(fā)現(xiàn) Bug 時一定先編寫測試用例進行 Debug
關(guān)鍵 util 工具類要編寫測試用例,這些 util 工具適用的很頻繁,所以這個原則也叫做熱點原則,和第 1 點相呼應(yīng)。
測試用戶應(yīng)該獨立,一個文件對應(yīng)一個,而且不同的測試用例之間不要互相依賴。
測試用例的保持更新
4. 單元測試用例設(shè)計方法
4.1 規(guī)范(規(guī)格)導(dǎo)出法
規(guī)范(規(guī)格)導(dǎo)出法將需求”翻譯“成測試用例。
例如,一個函數(shù)的設(shè)計需求如下:
函數(shù):一個計算平方根的函數(shù) 輸入:實數(shù) 輸出:實數(shù) 要求:當(dāng)輸入一個 0 或者比 0 大的實數(shù)時,返回其正的平方根;當(dāng)輸入一個小于 0 的實數(shù)時,顯示錯誤信息“平方根非法—輸入之小于 0”,并返回 0;庫函數(shù)printf()可以用來輸出錯誤信息。
在這個規(guī)范中有 3 個陳述,可以用兩個測試用例來對應(yīng):
測試用例 1:輸入 4,輸出 2。
測試用例 2:輸入-1,輸出 0。
4.2 等價類劃分法
等價類劃分法假定某一特定的等價類中的所有值對于測試目的來說是等價的,所以在每個等價類中找一個之作為測試用例。
按照 [輸入條件][有效等價類][無效等價類] 建立等價類表,列出所有劃分出的等價類
為每一個等價類規(guī)定一個唯一的編號
設(shè)計一個新的測試用例,使其盡可能多地覆蓋尚未被覆蓋地有效等價類。重復(fù)這一步,直到所有的有效等價類都被覆蓋為止
設(shè)計一個新的測試用例,使其僅覆蓋一個尚未被覆蓋的無效等價類。重復(fù)這一步,直到所有的無效等價類都被覆蓋為止
例如,注冊郵箱時要求用 6~18 個字符,可使用字母、數(shù)字、下劃線,需以字母開頭。
測試用例:
4.3 邊界值分析法
邊界值分析法使用與等價類測試方法相同的等價類劃分,只是邊界值分析假定 錯誤更多地存在于兩個劃分的邊界上。
邊界值測試在軟件變得復(fù)雜的時候也會變得不實用。邊界值測試對于非向量類型的值(如枚舉類型的值)也沒有意義。
例如,和4.1相同的需求:劃分(ii)的邊界為 0 和最大正實數(shù);劃分(i)的邊界為最小負實數(shù)和 0。由此得到以下測試用例:
輸入 {最小負實數(shù)}
輸入 {絕對值很小的負數(shù)}
輸入 0
輸入 {絕對值很小的正數(shù)}
輸入 {最大正實數(shù)}
4.4 基本路徑測試法
基本路徑測試法是在程序控制流圖的基礎(chǔ)上,通過分析控制構(gòu)造的環(huán)路復(fù)雜性,導(dǎo)出基本可執(zhí)行路徑集合,從而設(shè)計測試用例的方法。設(shè)計出的測試用例要保證在測試中程序的每個可執(zhí)行語句至少執(zhí)行一次。
基本路徑測試法的基本步驟:
程序的控制流圖:描述程序控制流的一種圖示方法。
程序圈復(fù)雜度:McCabe 復(fù)雜性度量。從程序的環(huán)路復(fù)雜性可導(dǎo)出程序基本路徑集合中的獨立路徑條數(shù),這是確定程序中每個可執(zhí)行語句至少執(zhí)行一次所必須的測試用例數(shù)目的上界。
導(dǎo)出測試用例:根據(jù)圈復(fù)雜度和程序結(jié)構(gòu)設(shè)計用例數(shù)據(jù)輸入和預(yù)期結(jié)果。
準(zhǔn)備測試用例:確保基本路徑集中的每一條路徑的執(zhí)行。
二、Golang 的測試框架
Golang 有這幾種比較常見的測試框架:
從測試用例編寫的簡易難度上來說:testify 比 GoConvey 簡單;GoConvey 比 Go 自帶的 testing 包簡單?! 〉跍y試框架的選擇上,我們更推薦 GoConvey。因為:
GoConvey 和其他 Stub/Mock 框架的兼容性相比 Testify 更好。
Testify 自帶 Mock 框架,但是用這個框架 Mock 類需要自己寫。像這樣重復(fù)有規(guī)律的部分在 GoMock 中是一鍵自動生成的。
1. Go 自帶的 testing 包
testing 為 Go 語言 package 提供自動化測試的支持。通過 go test 命令,能夠自動執(zhí)行如下形式的任何函數(shù):
func?TestXxx(*testing.T)注意:Xxx 可以是任何字母數(shù)字字符串,但是第一個字母不能是小寫字母。
在這些函數(shù)中,使用 Error、Fail 或相關(guān)方法來發(fā)出失敗信號。
要編寫一個新的測試套件,需要創(chuàng)建一個名稱以 _test.go 結(jié)尾的文件,該文件包含 TestXxx 函數(shù),如上所述。將該文件放在與被測試文件相同的包中。該文件將被排除在正常的程序包之外,但在運行 go test 命令時將被包含。有關(guān)詳細信息,請運行 go help test 和 go help testflag 了解。
1.1 第一個例子
被測代碼:
func?Fib(n?int)?int?{if?n?<?2?{return?n}return?Fib(n-1)?+?Fib(n-2) }測試代碼:
func?TestFib(t?*testing.T)?{var?(in???????=?7expected?=?13)actual?:=?Fib(in)if?actual?!=?expected?{t.Errorf("Fib(%d)?=?%d;?expected?%d",?in,?actual,?expected)} }執(zhí)行 go test . ,輸出:
$?go?test?. ok??????chapter09/testing????0.007s表示測試通過。我們將 Sum 函數(shù)改為:
func?Fib(n?int)?int?{if?n?<?2?{return?n}return?Fib(n-1)?+?Fib(n-1) }再執(zhí)行 go test . ,輸出:
$?go?test?. ---?FAIL:?TestSum?(0.00s)t_test.go:16:?Fib(10)?=?64;?expected?13 FAIL FAIL????chapter09/testing????0.009s1.2 Table-Driven 測試
Table-Driven 的方式將多個 case 在同一個測試函數(shù)中測到:
func?TestFib(t?*testing.T)?{var?fibTests?=?[]struct?{in???????int?//?inputexpected?int?//?expected?result}{{1,?1},{2,?1},{3,?2},{4,?3},{5,?5},{6,?8},{7,?13},}for?_,?tt?:=?range?fibTests?{actual?:=?Fib(tt.in)if?actual?!=?tt.expected?{t.Errorf("Fib(%d)?=?%d;?expected?%d",?tt.in,?actual,?tt.expected)}} }Go 自帶 testing 包的更多用法
2. GoConvey:簡單斷言
Convey 適用于書寫單元測試用例,并且可以兼容到 testing 框架中,go test命令或者使用goconvey命令訪問localhost:8080的 Web 測試界面都可以查看測試結(jié)果。
Convey("Convey?return?:?",?t,?func()?{So(...) })一般 Convey 用So來進行斷言,斷言的方式可以傳入一個函數(shù),或者使用自帶的ShouldBeNil、ShouldEqual、ShouldNotBeNil函數(shù)等。
2.1. 基本用法
被測代碼:
func?StringSliceEqual(a,?b?[]string)?bool?{if?len(a)?!=?len(b)?{return?false}if?(a?==?nil)?!=?(b?==?nil)?{return?false}for?i,?v?:=?range?a?{if?v?!=?b[i]?{return?false}}return?true }測試代碼
import?("testing".?"github.com/smartystreets/goconvey/convey" )func?TestStringSliceEqual(t?*testing.T)?{Convey("TestStringSliceEqual的描述",?t,?func()?{a?:=?[]string{"hello",?"goconvey"}b?:=?[]string{"hello",?"goconvey"}So(StringSliceEqual(a,?b),?ShouldBeTrue)}) }2.2. 雙層嵌套
import?("testing".?"github.com/smartystreets/goconvey/convey" )func?TestStringSliceEqual(t?*testing.T)?{Convey("TestStringSliceEqual",?t,?func()?{Convey("true?when?a?!=?nil??&&?b?!=?nil",?func()?{a?:=?[]string{"hello",?"goconvey"}b?:=?[]string{"hello",?"goconvey"}So(StringSliceEqual(a,?b),?ShouldBeTrue)})Convey("true?when?a?==?nil??&&?b?==?nil",?func()?{So(StringSliceEqual(nil,?nil),?ShouldBeTrue)})}) }內(nèi)層的 Convey 不需要再傳入 t *testing.T 參數(shù)
GoConvey 的更多用法
3. testify
testify 提供了 assert 和 require,讓你可以簡潔地寫出if xxx { t.Fail() }
3.1. assert
func?TestSomething(t?*testing.T)?{//斷言相等assert.Equal(t,?123,?123,?"they?should?be?equal")//斷言不相等assert.NotEqual(t,?123,?456,?"they?should?not?be?equal")//對于nil的斷言assert.Nil(t,?object)//對于非nil的斷言if?assert.NotNil(t,?object)?{//?now?we?know?that?object?isn't?nil,?we?are?safe?to?make//?further?assertions?without?causing?any?errorsassert.Equal(t,?"Something",?object.Value)}3.2. require
require 和 assert 失敗、成功條件完全一致,區(qū)別在于 assert 只是返回布爾值(true、false),而 require 不符合斷言時,會中斷當(dāng)前運行
3.3. 常用的函數(shù)
func?Equal(t?TestingT,?expected,?actual?interface{},?msgAndArgs?...interface{})?bool func?NotEqual(t?TestingT,?expected,?actual?interface{},?msgAndArgs?...interface{})?boolfunc?Nil(t?TestingT,?object?interface{},?msgAndArgs?...interface{})?bool func?NotNil(t?TestingT,?object?interface{},?msgAndArgs?...interface{})?boolfunc?Empty(t?TestingT,?object?interface{},?msgAndArgs?...interface{})?bool func?NotEmpty(t?TestingT,?object?interface{},?msgAndArgs?...interface{})?boolfunc?NoError(t?TestingT,?err?error,?msgAndArgs?...interface{})?bool func?Error(t?TestingT,?err?error,?msgAndArgs?...interface{})?boolfunc?Zero(t?TestingT,?i?interface{},?msgAndArgs?...interface{})?bool func?NotZero(t?TestingT,?i?interface{},?msgAndArgs?...interface{})?boolfunc?True(t?TestingT,?value?bool,?msgAndArgs?...interface{})?bool func?False(t?TestingT,?value?bool,?msgAndArgs?...interface{})?boolfunc?Len(t?TestingT,?object?interface{},?length?int,?msgAndArgs?...interface{})?boolfunc?NotContains(t?TestingT,?s,?contains?interface{},?msgAndArgs?...interface{})?bool func?NotContains(t?TestingT,?s,?contains?interface{},?msgAndArgs?...interface{})?bool func?Subset(t?TestingT,?list,?subset?interface{},?msgAndArgs?...interface{})?(ok?bool) func?NotSubset(t?TestingT,?list,?subset?interface{},?msgAndArgs?...interface{})?(ok?bool)func?FileExists(t?TestingT,?path?string,?msgAndArgs?...interface{})?bool func?DirExists(t?TestingT,?path?string,?msgAndArgs?...interface{})?booltestify 的更多用法
三、Stub/Mock 框架
Golang 有以下 Stub/Mock 框架:
GoStub
GoMock
Monkey
一般來說,GoConvey 可以和 GoStub、GoMock、Monkey 中的一個或多個搭配使用。
Testify 本身有自己的 Mock 框架,可以用自己的也可以和這里列出來的 Stub/Mock 框架搭配使用。
1. GoStub
GoStub 框架的使用場景很多,依次為:
基本場景:為一個全局變量打樁
基本場景:為一個函數(shù)打樁
基本場景:為一個過程打樁
復(fù)合場景:由任意相同或不同的基本場景組合而成
1.1. 為一個全局變量打樁
假設(shè) num 為被測函數(shù)中使用的一個全局整型變量,當(dāng)前測試用例中假定 num 的值大于 100,比如為 150,則打樁的代碼如下:
stubs?:=?Stub(&num,?150) defer?stubs.Reset()stubs 是 GoStub 框架的函數(shù)接口 Stub 返回的對象,該對象有 Reset 操作,即將全局變量的值恢復(fù)為原值。
1.2. 為一個函數(shù)打樁
假設(shè)我們產(chǎn)品的既有代碼中有下面的函數(shù)定義:
func?Exec(cmd?string,?args?...string)?(string,?error)?{... }我們可以對 Exec 函數(shù)打樁,代碼如下所示:
stubs?:=?StubFunc(&Exec,"xxx-vethName100-yyy",?nil) defer?stubs.Reset()1.3. 為一個過程打樁
當(dāng)一個函數(shù)沒有返回值時,該函數(shù)我們一般稱為過程。很多時候,我們將資源清理類函數(shù)定義為過程。
我們對過程 DestroyResource 的打樁代碼為:
stubs?:=?StubFunc(&DestroyResource) defer?stubs.Reset()GoStub 的更多用法以及 GoStub+GoConvey 的組合使用方法
2. GoMock
GoMock 是由 Golang 官方開發(fā)維護的測試框架,實現(xiàn)了較為完整的基于 interface 的 Mock 功能,能夠與 Golang 內(nèi)置的 testing 包良好集成,也能用于其它的測試環(huán)境中。GoMock 測試框架包含了 GoMock 包和 mockgen 工具兩部分,其中 GoMock 包完成對樁對象生命周期的管理,mockgen 工具用來生成 interface 對應(yīng)的 Mock 類源文件。
2.1. 定義一個接口
我們先定義一個打算 mock 的接口 Repository。
Repository 是領(lǐng)域驅(qū)動設(shè)計中戰(zhàn)術(shù)設(shè)計的一個元素,用來存儲領(lǐng)域?qū)ο?#xff0c;一般將對象持久化在數(shù)據(jù)庫中,比如 Aerospike,Redis 或 Etcd 等。對于領(lǐng)域?qū)觼碚f,只知道對象在 Repository 中維護,并不 care 對象到底在哪持久化,這是基礎(chǔ)設(shè)施層的職責(zé)。微服務(wù)在啟動時,根據(jù)部署參數(shù)實例化 Repository 接口,比如 AerospikeRepository,RedisRepository 或 EtcdRepository。
package?dbtype?Repository?interface?{Create(key?string,?value?[]byte)?errorRetrieve(key?string)?([]byte,?error)Update(key?string,?value?[]byte)?errorDelete(key?string)?error }2.2. 生成 mock 類文件
這下該 mockgen 工具登場了。mockgen 有兩種操作模式:源文件和反射。
源文件模式通過一個包含 interface 定義的文件生成 mock 類文件,它通過 -source 標(biāo)識生效,-imports 和 -aux_files 標(biāo)識在這種模式下也是有用的。舉例:
mockgen?-source=foo.go?[other?options]反射模式通過構(gòu)建一個程序用反射理解接口生成一個 mock 類文件,它通過兩個非標(biāo)志參數(shù)生效:導(dǎo)入路徑和用逗號分隔的符號列表(多個 interface)。舉例:
mockgen?database/sql/driver?Conn,Driver生成的 mock_repository.go 文件:
//?Automatically?generated?by?MockGen.?DO?NOT?EDIT! //?Source:?infra/db?(interfaces:?Repository)package?mock_dbimport?(gomock?"github.com/golang/mock/gomock" )//?MockRepository?is?a?mock?of?Repository?interface type?MockRepository?struct?{ctrl?????*gomock.Controllerrecorder?*MockRepositoryMockRecorder }//?MockRepositoryMockRecorder?is?the?mock?recorder?for?MockRepository type?MockRepositoryMockRecorder?struct?{mock?*MockRepository }//?NewMockRepository?creates?a?new?mock?instance func?NewMockRepository(ctrl?*gomock.Controller)?*MockRepository?{mock?:=?&MockRepository{ctrl:?ctrl}mock.recorder?=?&MockRepositoryMockRecorder{mock}return?mock }//?EXPECT?returns?an?object?that?allows?the?caller?to?indicate?expected?use func?(_m?*MockRepository)?EXPECT()?*MockRepositoryMockRecorder?{return?_m.recorder }//?Create?mocks?base?method func?(_m?*MockRepository)?Create(_param0?string,?_param1?[]byte)?error?{ret?:=?_m.ctrl.Call(_m,?"Create",?_param0,?_param1)ret0,?_?:=?ret[0].(error)return?ret0 } ...2.3. 使用 mock 對象進行打樁測試
2.3.1. 導(dǎo)入 mock 相關(guān)的包
import?("testing".?"github.com/golang/mock/gomock""test/mock/db"... )2.3.2. mock 控制器
mock 控制器通過 NewController 接口生成,是 mock 生態(tài)系統(tǒng)的頂層控制,它定義了 mock 對象的作用域和生命周期,以及它們的期望。多個協(xié)程同時調(diào)用控制器的方法是安全的。當(dāng)用例結(jié)束后,控制器會檢查所有剩余期望的調(diào)用是否滿足條件。
控制器的代碼如下所示:
ctrl?:=?NewController(t) defer?ctrl.Finish()mock 對象創(chuàng)建時需要注入控制器,如果有多個 mock 對象則注入同一個控制器,如下所示:
ctrl?:=?NewController(t) defer?ctrl.Finish() mockRepo?:=?mock_db.NewMockRepository(ctrl) mockHttp?:=?mock_api.NewHttpMethod(ctrl)2.3.3. mock 對象的行為注入
對于 mock 對象的行為注入,控制器是通過 map 來維護的,一個方法對應(yīng) map 的一項。因為一個方法在一個用例中可能調(diào)用多次,所以 map 的值類型是數(shù)組切片。當(dāng) mock 對象進行行為注入時,控制器會將行為 Add。當(dāng)該方法被調(diào)用時,控制器會將該行為 Remove。
假設(shè)有這樣一個場景:先 Retrieve 領(lǐng)域?qū)ο笫?#xff0c;然后 Create 領(lǐng)域?qū)ο蟪晒?#xff0c;再次 Retrieve 領(lǐng)域?qū)ο缶湍艹晒?。這個場景對應(yīng)的 mock 對象的行為注入代碼如下所示:
mockRepo.EXPECT().Retrieve(Any()).Return(nil,?ErrAny) mockRepo.EXPECT().Create(Any(),?Any()).Return(nil) mockRepo.EXPECT().Retrieve(Any()).Return(objBytes,?nil)objBytes 是領(lǐng)域?qū)ο蟮男蛄谢Y(jié)果,比如:
obj?:=?Movie{...} objBytes,?err?:=?json.Marshal(obj) ...當(dāng)批量 Create 對象時,可以使用 Times 關(guān)鍵字:
mockRepo.EXPECT().Create(Any(),?Any()).Return(nil).Times(5)當(dāng)批量 Retrieve 對象時,需要注入多次 mock 行為:
mockRepo.EXPECT().Retrieve(Any()).Return(objBytes1,?nil) mockRepo.EXPECT().Retrieve(Any()).Return(objBytes2,?nil) mockRepo.EXPECT().Retrieve(Any()).Return(objBytes3,?nil) mockRepo.EXPECT().Retrieve(Any()).Return(objBytes4,?nil) mockRepo.EXPECT().Retrieve(Any()).Return(objBytes5,?nil)GoMock 的更多用法以及 GoStub+GoConvey+GoMock 的組合使用方法
3. Monkey
至此,我們已經(jīng)知道:
全局變量可通過 GoStub 框架打樁
過程可通過 GoStub 框架打樁
函數(shù)可通過 GoStub 框架打樁
interface 可通過 GoMock 框架打樁
但還有兩個問題比較棘手:
方法(成員函數(shù))無法通過 GoStub 框架打樁,當(dāng)產(chǎn)品代碼的 OO 設(shè)計比較多時,打樁點可能離被測函數(shù)比較遠,導(dǎo)致 UT 用例寫起來比較痛
過程或函數(shù)通過 GoStub 框架打樁時,對產(chǎn)品代碼有侵入性
Monkey 是 Golang 的一個猴子補丁(monkeypatching)框架,在運行時通過匯編語句重寫可執(zhí)行文件,將待打樁函數(shù)或方法的實現(xiàn)跳轉(zhuǎn)到樁實現(xiàn),原理和熱補丁類似。通過 Monkey,我們可以解決函數(shù)或方法的打樁問題,但 Monkey 不是線程安全的,不要將 Monkey 用于并發(fā)的測試中。
Monkey 框架的使用場景很多,依次為:
基本場景:為一個函數(shù)打樁
基本場景:為一個過程打樁
基本場景:為一個方法打樁
復(fù)合場景:由任意相同或不同的基本場景組合而成
特殊場景:樁中樁的一個案例
另有 GoMonkey 框架https://github.com/agiledragon/gomonkey,對比Monkey來說,寫法更簡單,有興趣的讀者可以嘗試使用。
3.1. 為一個函數(shù)打樁
Exec 是 infra 層的一個操作函數(shù),實現(xiàn)很簡單,代碼如下所示:
func?Exec(cmd?string,?args?...string)?(string,?error)?{cmdpath,?err?:=?exec.LookPath(cmd)if?err?!=?nil?{fmt.Errorf("exec.LookPath?err:?%v,?cmd:?%s",?err,?cmd)return?"",?infra.ErrExecLookPathFailed}var?output?[]byteoutput,?err?=?exec.Command(cmdpath,?args...).CombinedOutput()if?err?!=?nil?{fmt.Errorf("exec.Command.CombinedOutput?err:?%v,?cmd:?%s",?err,?cmd)return?"",?infra.ErrExecCombinedOutputFailed}fmt.Println("CMD[",?cmdpath,?"]ARGS[",?args,?"]OUT[",?string(output),?"]")return?string(output),?nil }Monkey 的 API 非常簡單和直接,我們直接看打樁代碼:
import?("testing".?"github.com/smartystreets/goconvey/convey".?"github.com/bouk/monkey""infra/osencap" )const?any?=?"any"func?TestExec(t?*testing.T)?{Convey("test?has?digit",?t,?func()?{Convey("for?succ",?func()?{outputExpect?:=?"xxx-vethName100-yyy"guard?:=?Patch(osencap.Exec,func(_?string,?_?...string)?(string,?error)?{return?outputExpect,?nil})defer?guard.Unpatch()output,?err?:=?osencap.Exec(any,?any)So(output,?ShouldEqual,?outputExpect)So(err,?ShouldBeNil)})}) }Patch 是 Monkey 提供給用戶用于函數(shù)打樁的 API:
第一個參數(shù)是目標(biāo)函數(shù)的函數(shù)名
第二個參數(shù)是樁函數(shù)的函數(shù)名,習(xí)慣用法是匿名函數(shù)或閉包
返回值是一個 PatchGuard 對象指針,主要用于在測試結(jié)束時刪除當(dāng)前的補丁
3.2. 為一個過程打樁
當(dāng)一個函數(shù)沒有返回值時,該函數(shù)我們一般稱為過程。很多時候,我們將資源清理類函數(shù)定義為過程。我們對過程 DestroyResource 的打樁代碼為:
guard?:=?Patch(DestroyResource,?func(_?string)?{}) defer?guard.Unpatch()3.3. 為一個方法打樁
當(dāng)微服務(wù)有多個實例時,先通過 Etcd 選舉一個 Master 實例,然后 Master 實例為所有實例較均勻的分配任務(wù),并將任務(wù)分配結(jié)果 Set 到 Etcd,最后 Master 和 Node 實例 Watch 到任務(wù)列表,并過濾出自身需要處理的任務(wù)列表。
我們用類 Etcd 的方法 Get 來模擬獲取任務(wù)列表的功能,入?yún)?instanceId:
type?Etcd?struct?{}func?(e?*Etcd)?Get(instanceId?string)?[]string?{taskList?:=?make([]string,?0)...return?taskList我們對 Get 方法的打樁代碼如下:
var?e?*Etcd guard?:=?PatchInstanceMethod(reflect.TypeOf(e),"Get",func(_?*Etcd,?_?string)?[]string?{return?[]string{"task1",?"task5",?"task8"}}) defer?guard.Unpatch()PatchInstanceMethod API 是 Monkey 提供給用戶用于方法打樁的 API:
在使用前,先要定義一個目標(biāo)類的指針變量 x
第一個參數(shù)是 reflect.TypeOf(x)
第二個參數(shù)是字符串形式的函數(shù)名
返回值是一個 PatchGuard 對象指針,主要用于在測試結(jié)束時刪除當(dāng)前的補丁
Monkey 的更多用法以及 Monkey 和前幾種框架的組合使用方法
四、Mock 場景最佳實踐
1. 實例函數(shù) Mock:Monkey
Monkey 用于對依賴的函數(shù)進行 Mock 替換,從而可以完成僅針對當(dāng)前模塊的單元測試。
例子:
test包是真實的函數(shù)mock_test包是即將用于 mock 的函數(shù)
test.go:
package?testimport?"fmt"func?PrintAdd(a,?b?uint32)?string?{return?fmt.Sprintf("a:%v+b:%v",?a,?b) }type?SumTest?struct?{ }func?(*SumTest)PrintSum(a,?b?uint32)?string?{return?fmt.Sprintf("a:%v+b:%v",?a,?b) }mock_test.go:
package?mock_testimport?"fmt" import?"test/24_mock/test"func?PrintAdd(a,?b?uint32)?string?{return?fmt.Sprintf("a:%v+b:%v=%v",?a,?b,?a+b) }//對應(yīng)test文件夾下的PrintSum func?PrintSum(_?*test.SumTest,?a,?b?uint32)?string?{return?fmt.Sprintf("a:%v+b:%v=%v",?a,?b,a+b) }main.go:
func?test1()?{monkey.Patch(test.PrintAdd,?mock_test.PrintAdd)p?:=?test.PrintAdd(1,?2)fmt.Println(p)monkey.UnpatchAll()?//解除所有替換p?=?test.PrintAdd(1,?2)fmt.Println(p) }func?test2()?{structSum?:=?&test.SumTest{}//para1:獲取實例的反射類型,para2:被替換的方法名,para3:替換方法monkey.PatchInstanceMethod(reflect.TypeOf(structSum),?"PrintSum",?mock_test.PrintSum)p?:=?structSum.PrintSum(1,?2)fmt.Println(p)monkey.UnpatchAll()?//解除所有替換p?=?structSum.PrintSum(1,?2)fmt.Println(p) }2. 未實現(xiàn)函數(shù) Mock:GoMock
假設(shè)場景:Company公司、Person人。
公司可以開會。
公司內(nèi)部的人繼承了Talker討論者接口,擁有SayHello說話的方法。
假如現(xiàn)在要測試這個場景,在所有類都實現(xiàn)的情況下,測試應(yīng)該是這樣的:
//正常測試 func?TestCompany_Meeting(t?*testing.T)?{ //直接調(diào)用Person類的New方法,創(chuàng)建一個Person對象talker?:=?NewPerson("小微",?"語音服務(wù)助手")company?:=?NewCompany(talker)t.Log(company.Meeting("lyt",?"intern")) }但現(xiàn)在Person類并未實現(xiàn),則可以通過 GoMock 工具來模擬一個Person對象。
定義一個Talker.go
package?pojotype?Talker?interface?{SayHello(word,?role?string)?(response?string) }根據(jù)該接口,用mockgen命令生成一個 Mock 對象
mockgen?[-source]?[-destination]?[-package]?...?Talker.go接著進行測試用例的編寫:
NewController(): 新建 Mock 控制器
NewMockXXX(): 新建 Mock 對象,這里是調(diào)用 NewMockTalker()
talker.EXPECT().XXX().XXX()..:撰寫一些斷言測試
之前 Mock 建立的對象傳入到待測方法當(dāng)中
測試結(jié)果通過 testing 框架返回
3. 系統(tǒng)內(nèi)置函數(shù) Mock:Monkey
monkey.Patch(json.Unmarshal, mockUnmarshal),用 Monkey 的 patch 來 mock 系統(tǒng)內(nèi)置函數(shù)
func?mockUnmarshal(b?[]byte,?v?interface{})?error{v?=?&Common.LoginMessage{UserId:?1,UserName:?"admin",UserPwd:?"admin",}return?nil }如果需要取消替換,可以使用
monkey.UnPatch(target?interface{})?//解除單個Patch monkey.UnPatchAll()????????//解除所有Patch4. 數(shù)據(jù)庫行為 Mock
func?TestSql(t?*testing.T)?{db,?mock,?err?:=?sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))if?err?!=?nil?{fmt.Println("fail?to?open?sqlmock?db:?",?err)}defer?db.Close()rows?:=?sqlmock.NewRows([]string{"id",?"pwd"}).AddRow(1,?"apple").AddRow(2,?"banana")mock.ExpectQuery("SELECT?id,?pwd?FROM?users").WillReturnRows(rows)res,?err?:=?db.Query("SELECT?id,?pwd?FROM?users")if?err?!=?nil?{fmt.Println("fail?to?match?expected?sql.")return}defer?res.Close()for?res.Next()?{var?id?intvar?pwd?stringres.Scan(&id,?&pwd)fmt.Printf("Sql?Result:\tid?=?%d,?password?=?%s.\n",id,?pwd)}if?res.Err()?!=?nil?{fmt.Println("Result?Return?Error!",?res.Err())} }5. 服務(wù)器行為 Mock:Monkey
使用 net/http/httptest 模擬服務(wù)器行為
func?TestHttp(t?*testing.T)?{handler?:=?func(w?http.ResponseWriter,?r?*http.Request)?{io.WriteString(w,?"{?\"status\":?\"expected?service?response\"}")}req?:=?httptest.NewRequest("GET",?"https://test.net",?nil)w?:=?httptest.NewRecorder()handler(w,?req)//處理該Requestresp?:=?w.Result()body,?_?:=?ioutil.ReadAll(resp.Body)fmt.Println(resp.StatusCode)fmt.Println(resp.Header.Get("Content-Type"))fmt.Println(string(body)) }這里只使用了 Monkey 的 Patch 進行簡單測試,但在更一般的情況下,更多的函數(shù)還是通過實例函數(shù)來編寫的,對這部分函數(shù)要用PatchInstanceMethod才可以進行替換。
func PatchInstanceMethod(target reflect.Type, methodName string, replacement interface{})接收三個參數(shù):
reflect.Tpye通過新建一個待測實例對象,調(diào)用 reflect 包的TypeOf()方法就可以得到
methodName是待測實例對象的函數(shù)名
replacement是用于替換的函數(shù)
實現(xiàn)如下:
var?ts?*utils.Transfer monkey.PatchInstanceMethod(reflect.TypeOf(ts),?"WritePkg",?func(_?*utils.Transfer,?_?[]byte)?error?{return?nil })假設(shè)有如下一個函數(shù)ServerProcessLogin,用于接收用戶名密碼,向當(dāng)前連接的服務(wù)器請求登陸,測試如下:
func?TestServerProcessLogin(t?*testing.T)?{mess?:=?&Common.Message{Type:?Common.LoginMesType,Data:?"default",}user?:=?&UserProcess{Conn:?nil,}//對涉及到的單元以外系統(tǒng)函數(shù)打Patchmonkey.Patch(json.Unmarshal,?mockUnmarshal)monkey.Patch(json.Marshal,?mockMarshal)//單元測試不涉及實際服務(wù)器,故對實例函數(shù)Login,WritePkg打Patchvar?udao?*model.UserDaomonkey.PatchInstanceMethod(reflect.TypeOf(udao),?"Login",?func(_?*model.UserDao,?_?int,?_?string)?(*Common.User,error)?{return?&Common.User{UserId:?1,UserName:?"admin",UserPwd:?"admin",},?nil})var?ts?*utils.Transfermonkey.PatchInstanceMethod(reflect.TypeOf(ts),"WritePkg",?func(_?*utils.Transfer,?_?[]byte)?error?{return?nil})//執(zhí)行測試convey.Convey("Test?Server?Login.",?t,?func()?{err?:=?user.ServerProcessLogin(mess)convey.So(err,?convey.ShouldBeNil)})monkey.UnpatchAll()return }//用于替換的函數(shù) func?mockUnmarshal(b?[]byte,?v?interface{})?error{v?=?&Common.LoginMessage{UserId:?1,UserName:?"admin",UserPwd:?"admin",}return?nil }func?mockMarshal(v?interface{})?([]byte,?error)?{var?rer?=?[]byte{'a','d','m','i','n',}return?rer,?nil }五、具體案例:聊天室
1. 概覽
該項目是一個具有登錄、查看在線用戶、私聊、群聊等功能的命令行聊天室 Demo。
項目分為 Client、Server 子項目,都通過 model、Controllor(Processor)、View(Main)來進行功能劃分。還有一個 Common 包放置通用性的工具類。
├─Client │ ├─main │ ├─model │ ├─processor │ └─utils ├─Common └─Server├─main├─model├─processor└─utils預(yù)期目的:對實現(xiàn)的功能模塊補充單元測試代碼,度量確保每一個模塊的功能的正確性、完整性、健壯性,并在未來修改代碼后也能第一時間自測驗收。
單元測試應(yīng)包括模塊接口測試、模塊局部數(shù)據(jù)結(jié)構(gòu)測試、模塊異常處理測試。
對于接口測試,應(yīng)對接口的傳入?yún)?shù)測試樣例設(shè)計進行全面的考察,判斷每一個參數(shù)是否有是有必要的,參數(shù)間有沒有冗余,進入函數(shù)體前引用的指針是否有錯等等。
對于局部數(shù)據(jù)結(jié)構(gòu)測試,應(yīng)檢查局部數(shù)據(jù)結(jié)構(gòu)是為了保證臨時存儲在模塊內(nèi)的數(shù)據(jù)在程序執(zhí)行過程中完整性、正確性。局部數(shù)據(jù)結(jié)構(gòu)往往是錯誤的根源,應(yīng)仔細設(shè)計測試用例。
對于異常處理,主要有如下幾種常見錯誤:
輸出的出錯信息提示不足
對異常沒有進行處理
出錯信息與實際不相符
出錯信息中未能準(zhǔn)確定位出錯信息
以上幾種錯誤,都是模塊中經(jīng)常會出現(xiàn)的錯誤,要針對這些錯誤來進行邊界條件測試檢查,只有異常處理機制正確,日后軟件的維護和迭代才會更加高效。
在本案例中,Model 層對服務(wù)層提供的接口不多,就WritePkg,ReadPkg兩個核心函數(shù),在服務(wù)層對其進行封裝抽象為具體的業(yè)務(wù)邏輯。由于涉及網(wǎng)絡(luò)連接,所以對其進行的測試必須編寫樁函數(shù)。在服務(wù)層,涉及到對多個網(wǎng)絡(luò)連接調(diào)用、數(shù)據(jù)庫調(diào)用其它模塊依賴,所以也要為其進行 Mock。
由于涉及 Mock 和樁函數(shù)編寫,可以使用GoStub、Monkey兩個包進行這些工作,它們較簡潔地實現(xiàn)了很多實用的測試方式,只需要用戶編寫依賴的接口文件、用于替換的 Mock 函數(shù),就可以僅在測試過程中替換掉系統(tǒng)函數(shù)或者其它依賴的功能模塊,使得單元測試起到它應(yīng)有的作用。
2. Model 層、數(shù)據(jù)庫相關(guān)測試
由于是單元測試,所以需要獲取一個 Mock 數(shù)據(jù)庫實例,測試增刪改查 SQL 語句是否可執(zhí)行。userDao_test.go代碼如下:
const?(sql1?=?"SELECT?id,?pwd?FROM?users"sql2?=?"DELETE?FROM?users?where?id?>?600?and?id?<?700"sql3?=?"update?users?set?pwd?=?newPwd?where?id?=?1?and?id?=?2"sql4?=?"INSERT?INTO?users?(id,?pwd)?VALUES?(405,?'Lyt')" )func?TestGetUserById(t?*testing.T)?{db,?mock,?err?:=?sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))if?err?!=?nil?{fmt.Println("fail?to?open?sqlmock?db:?",?err)}defer?db.Close()rows1?:=?sqlmock.NewRows([]string{"id",?"pwd"}).AddRow(1,?"apple").AddRow(2,?"banana")rows2?:=?sqlmock.NewRows([]string{"id",?"pwd"}).AddRow(601,?"goland").AddRow(602,?"java")rows3?:=?sqlmock.NewRows([]string{"id",?"pwd"}).AddRow(1,?"newPwd").AddRow(2,?"newPwd")rows4?:=?sqlmock.NewRows([]string{"id",?"pwd"}).AddRow(405,?"Lyt")mock.ExpectQuery(sql1).WillReturnRows(rows1)mock.ExpectQuery(sql2).WillReturnRows(rows2)mock.ExpectQuery(sql3).WillReturnRows(rows3)mock.ExpectQuery(sql4).WillReturnRows(rows4)assert.New(t)var?tests?=?[]struct{inputSql?stringexpected?interface{}}?{{sql1,nil},{sql2,nil},{sql3,nil},{sql4,?nil},}for?_,?test?:=?range?tests?{res,?err?:=?db.Query(test.inputSql)assert.Equal(t,?err,?test.expected)for?res.Next()?{var?id?intvar?pwd?stringres.Scan(&id,?&pwd)fmt.Printf("Sql?Result:\tid?=?%d,?password?=?%s.\n",id,?pwd)}assert.Equal(t,?res.Err(),?test.expected)} }3. 私聊功能測試
由于涉及底層數(shù)據(jù)庫交互時需要發(fā)送 JSON 轉(zhuǎn)碼字符串(WritePkg函數(shù)),因此將其 Mock 處理,只需關(guān)注本函數(shù)邏輯是否正確即可。smsProcess_test.go如下:
func?TestSmsProcess_SendOnePerson(t?*testing.T)?{var?conn?net.Conntf?:=?&utils.Transfer{Conn:?conn,}monkey.PatchInstanceMethod(reflect.TypeOf(tf),?"WritePkg",?func(_?*utils.Transfer,_?[]byte)?error{return?nil})convey.Convey("test?send?one?person:",?t,?func()?{err?:=?tf.WritePkg([]byte{})convey.So(err,?convey.ShouldBeNil)fmt.Println("OK.")}) }4. 登錄功能測試
登錄涉及服務(wù)器連接操作,服務(wù)器的連接邏輯可通過httptest包來進行檢測,Mock 一個 HTTP 連接,示例代碼如下:
func?TestHttp(t?*testing.T)?{handler?:=?func(w?http.ResponseWriter,?r?*http.Request)?{//?here?we?write?our?expected?response,?in?this?case,?we?return?a//?JSON?string?which?is?typical?when?dealing?with?REST?APIsio.WriteString(w,?"{?\"status\":?\"expected?service?response\"}")}req?:=?httptest.NewRequest("GET",?"https://test.net",?nil)w?:=?httptest.NewRecorder()handler(w,?req)resp?:=?w.Result()body,?_?:=?ioutil.ReadAll(resp.Body)fmt.Println(resp.StatusCode)fmt.Println(resp.Header.Get("Content-Type"))fmt.Println(string(body)) }為登錄模塊編寫用于測試替換的函數(shù)以及單元測試主體,userProcess_test.go代碼如下:
func?mockUnmarshal(b?[]byte,?v?interface{})?error?{v?=?&Common.LoginMessage{UserId:???1,UserName:?"admin",UserPwd:??"admin",}return?nil }func?mockMarshal(v?interface{})?([]byte,?error)?{var?rer?=?[]byte{'a',?'d',?'m',?'i',?'n',}return?rer,?nil }func?TestServerProcessLogin(t?*testing.T)?{mess?:=?&Common.Message{Type:?Common.LoginMesType,Data:?"default",}user?:=?&UserProcess{Conn:?nil,}//對涉及到的單元以外系統(tǒng)函數(shù)打Patchmonkey.Patch(json.Unmarshal,?mockUnmarshal)monkey.Patch(json.Marshal,?mockMarshal)//對實例函數(shù)打Patchvar?udao?*model.UserDaomonkey.PatchInstanceMethod(reflect.TypeOf(udao),?"Login",?func(_?*model.UserDao,?_?int,?_?string)?(*Common.User,?error)?{return?&Common.User{UserId:???1,UserName:?"admin",UserPwd:??"admin",},?nil})var?ts?*utils.Transfermonkey.PatchInstanceMethod(reflect.TypeOf(ts),?"WritePkg",?func(_?*utils.Transfer,?_?[]byte)?error?{return?nil})//執(zhí)行測試convey.Convey("Test?Server?Login.",?t,?func()?{err?:=?user.ServerProcessLogin(mess)convey.So(err,?convey.ShouldBeNil)})monkey.UnpatchAll()return }5. 工具類測試
utils_test.go func?mockRead(conn?net.Conn,?_?[]byte)?(int,?error)?{return?4,?nil }func?mockMarshal(v?interface{})?([]byte,?error)?{return?[]byte{'a',?'b',?'c',?'d'},?nil }func?mockUnmarshal(data?[]byte,?v?interface{})?error?{return?nil }func?TestTransfer_ReadPkg(t?*testing.T)?{monkey.Patch(net.Conn.Read,?mockRead)monkey.Patch(json.Marshal,?mockMarshal)monkey.Patch(json.Unmarshal,?mockUnmarshal)listen,?_?:=?net.Listen("tcp",?"localhost:8888")defer?listen.Close()go?net.Dial("tcp",?"localhost:8888")var?c?net.Connfor?{c,?_?=?listen.Accept()if?c?!=?nil?{break}}transfer?:=?&Transfer{Conn:?c,Buf?:?[8096]byte{'a',?'b',?'c',?'d'},}convey.Convey("test?ReadPkg",?t,?func()?{mes,?err?:=?transfer.ReadPkg()convey.So(err,?convey.ShouldBeNil)convey.So(mes,?convey.ShouldEqual,?"ab")})monkey.UnpatchAll()}func?TestTransfer_WritePkg(t?*testing.T)?{monkey.Patch(json.Marshal,?mockMarshal)monkey.Patch(json.Unmarshal,?mockUnmarshal)transfer?:=?&Transfer{Conn:?nil,Buf?:?[8096]byte{},}convey.Convey("test?ReadPkg",?t,?func()?{err?:=?transfer.WritePkg([]byte{'a',?'b'})convey.So(err,?convey.ShouldBeNil)})monkey.UnpatchAll() }6. 項目總結(jié)
在編寫樁模塊時會發(fā)現(xiàn),模塊之間的調(diào)用關(guān)系在工程規(guī)模并不大的本案例中,也依然比較復(fù)雜,需要開發(fā)相應(yīng)樁函數(shù),代碼量會增加許多,也會消耗一些開發(fā)人員的時間,因此反推到之前流程的開發(fā)實踐中,可以得出結(jié)論就是提高模塊的內(nèi)聚度可簡化單元測試,如果每個模塊只能完成一個,所需測試用例數(shù)目將顯著減少,模塊中的錯誤也更容易發(fā)現(xiàn)。
Go 單元測試框架是相當(dāng)易用的,其它的第三方庫基本都是建立在 testing 原生框架的基礎(chǔ)上進行的增補擴充,在日常開發(fā)中,原生包可以滿足基本需求,但同樣也有缺陷,原生包不提供斷言的語法使得代碼中的這種片段非常多:
if?err?!=?nil{//... }所以引入了 convey、assert 包的斷言語句,用于簡化判斷邏輯,使得程序更加易讀。
在完成項目單測時,遇到了不少問題,比較重要的比如由于架構(gòu)分層不夠清晰,還是有部分耦合代碼,導(dǎo)致單測時需要屏蔽的模塊太多,代碼寫起來不便。因此還是需要倒推到開發(fā)模塊之前,就要設(shè)計更好的結(jié)構(gòu),在開發(fā)的過程中遵循相應(yīng)的規(guī)則,通過測試先行的思想,使開發(fā)的工程具有更好的可測試性。
開發(fā)過程中遇到的場景肯定不局限于本文所討論的范圍,有關(guān)更豐富的最佳實踐案例可以參照:
go-sqlmock
go-mock
六、結(jié)語
1. 實踐小結(jié)
單元測試大多是由開發(fā)人員進行編寫,本篇文章旨在指引,不在于面面俱到,具體的單元測試框架的使用語法,開發(fā)同學(xué)可以自行 Google。
以測試的角度,推行單元測試是不易的,最佳的方式莫過于開發(fā)人員,在一定的指引之后,以實際項目出發(fā)進行實踐,然后自行總結(jié)具體的 case,有針對性、有感染力進行內(nèi)部分享,測試同學(xué)及時提供測試用例的指引和規(guī)范的約束。
2. 特別鳴謝
兩位實習(xí)生羅宇韜和鐘梓軒,在暑假實習(xí)期間,協(xié)助整理了 Golang 單測的代碼示例。
3. 推薦閱讀
書籍《google 的軟件測試之道》
書籍《單元測試的藝術(shù)》
歡迎關(guān)注我們的視頻號:騰訊程序員
最新視頻:10月24日到底是啥日子?
總結(jié)
以上是生活随笔為你收集整理的Golang 单元测试详尽指引的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 你真的知道怎么实现一个延迟队列吗 ?
- 下一篇: 1024程序员节 | 我在腾讯自研数据库