Golang高質(zhì)量單測之Table-Driven:從入門到真香!

導(dǎo)語?|?一個開發(fā)人員,如何能自覺自愿寫單測?那必然是相信收益>成本、單測節(jié)省的未來修bug時間>寫單測所花費的時間。為了保證上述不等式成立,這邊建議您考慮table-driven方法,快速、無痛寫出高質(zhì)量單測,以降低“我要寫單測”這事的心理門檻,最終達到信手拈來、一直寫一直爽的神奇效果。
一、什么是table-driven
表驅(qū)動法(Table-Driven Approach)這個概念,并不是Golang或者測試領(lǐng)域獨有的;它是個編程模式,屬于數(shù)據(jù)驅(qū)動編程的一種。
表驅(qū)動法的核心在于:把易變的數(shù)據(jù)部分,從穩(wěn)定的處理數(shù)據(jù)的流程里分離,放進表里;而不是直接混雜在if-else/switch-case的多個分支里。
簡單舉例:寫一個func,輸入第index天,輸出這天是星期幾。假如一周只有兩三天,那么直接用if-else/switch-case,倒也ok。
但如果一周有七天,這代碼就有些離譜了:
// GetWeekDay returns the week day name of a week day index.func GetWeekDay(index int) string {if index == 0 {return "Sunday"}if index == 1 {return "Monday"}if index == 2 {return "Tuesday"}if index == 3 {return "Wednesday"}if index == 4 {return "Thursday"}if index == 5 {return "Friday"}if index == 6 {return "Saturday"}return "Unknown"}
顯然,控制流程的邏輯并不復(fù)雜,是個簡單粗暴的映射(0->Sunday,1-> Monday……);分支與分支之間的唯一區(qū)別,在于可變的數(shù)據(jù),而不是流程本身。
那如果把數(shù)據(jù)拆分出來,放入表的多個行里(表一般用數(shù)組實現(xiàn);數(shù)組的一項即是表的一行),將大量的重復(fù)流程消消樂,代碼就簡潔很多:
// GetWeekDay returns the week day name of a week day index.func GetWeekDay(index int) string {if index < 0 || index > 6 {return "Unknown"}weekDays := []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}return weekDays[index]}
把這套方法搬到單測領(lǐng)域,也是如此。
一個測試用例,一般包括以下部分:
穩(wěn)定的流程
定義測試用例
定義輸入數(shù)據(jù)和期望的輸出數(shù)據(jù)
跑測試用例,拿到實際輸出
比較期望輸出和實際輸出
易變的數(shù)據(jù)
輸入的數(shù)據(jù)
期望的輸出數(shù)據(jù)
而table-driven單測法,就是將流程沉淀為一個可復(fù)用的模板、并交由機器自動生成;人類則只需要準(zhǔn)備數(shù)據(jù)部分,將自己的多條不同的數(shù)據(jù)一行行填充到表里,交給流程模板去構(gòu)造子測試用例、查表、跑數(shù)據(jù)、比對結(jié)果,寫單測這事就大功告成了。
二、為啥單測要table-driven?
在了解了table-driven的概念后,你多半能預(yù)見到,table-driven單測可帶來以下好處:
寫得快:人類只需準(zhǔn)備數(shù)據(jù),無需構(gòu)造流程。
可讀性強:將數(shù)據(jù)構(gòu)造成表,結(jié)構(gòu)更清晰,一行一行的數(shù)據(jù)變化對比分明。
子測試用例互相獨立:每條數(shù)據(jù)是表里的一行,被流程模板構(gòu)造成一個獨立的子測試用例。
可調(diào)試性強:因為每行數(shù)據(jù)被構(gòu)造成子測試用例,可以單獨跑、單獨調(diào)試。
可擴展/可維護性強:改一個子測試用例,就是改表里的一行數(shù)據(jù)。
接下來,通過舉例對比TestGetWeekDay的不同單測風(fēng)格,就能愈發(fā)看出 table-driven的好處。
例子一:低質(zhì)量單測之平鋪多個test case
從0->Sunday、1->Monday……到6->Saturday,給每條數(shù)據(jù)都寫一個單獨的test case:
// test case for index=0func TestGetWeekDay_Sunday(t *testing.T) {index := 0want := "Sunday"if got := GetWeekDay(index); got != want {t.Errorf("GetWeekDay() = %v, want %v", got, want)}}// test case for index=1func TestGetWeekDay_Monday(t *testing.T) {index := 1want := "Monday"if got := GetWeekDay(index); got != want {t.Errorf("GetWeekDay() = %v, want %v", got, want)}}...
一眼望去,重復(fù)代碼太多,可維護性差;另外,這些針對同一個方法的test case,被拆成并列的多個,跟其他方法的test case放在同一文件里平鋪的話,缺乏結(jié)構(gòu)化的組織,可讀性差。
例子二:低質(zhì)量單測之平鋪多個subtest
實際上,從Go 1.7開始,一個test case里可以有多個子測試(subtest),這些子測試用t.Run方法創(chuàng)建:
func TestGetWeekDay(t *testing.T) {// a subtest named "index=0"t.Run("index=0", func(t *testing.T) {index := 0want := "Sunday"if got := GetWeekDay(index); got != want {t.Errorf("GetWeekDay() = %v, want %v", got, want)}})// a subtest named "index=1"t.Run("index=1", func(t *testing.T) {index := 1want := "Monday"if got := GetWeekDay(index); got != want {t.Errorf("GetWeekDay() = %v, want %v", got, want)}})...}
比第一個例子簡潔一些,并且子測試之間仍相互獨立,可單獨跑、單獨調(diào)試。如圖,在IDE里(我本地是GoLand 2021.3),可以單獨run/debug每個subtest:

go test的log,也支持結(jié)構(gòu)化輸出subtest運行結(jié)果:

但是,當(dāng)subtest很多的時候,仍然要手寫很多重復(fù)的流程代碼,比較臃腫,也不好維護。
例子三:高質(zhì)量單測之table-driven
要生成table-driven單測模板非常簡單,只需在GoLand里右鍵方法名>Generate>Test for function:

GoLand會自動生成如下模板,而我們只需填充紅框部分,也即最核心的,用于驅(qū)動單測的數(shù)據(jù)表:

不難看出,這個模板在例子二的基礎(chǔ)上,繼續(xù)削減重復(fù)代碼,不再平鋪subtest,而是將公共流程放入一個循環(huán),用數(shù)據(jù)表中的多行數(shù)據(jù)驅(qū)動循環(huán)遍歷,并為每行數(shù)據(jù)構(gòu)造一個subtest跑一遍。
所以,只需在上圖的紅框里,以表的形式填充數(shù)據(jù),這個test case就寫好了:

每行數(shù)據(jù)被t.Run構(gòu)造出了一個獨立的subtest,能被單獨run/debug:

也能被go test打印出結(jié)構(gòu)化的log:

三、怎么寫table-driven單測?
其實,在上述例子三里,已經(jīng)能看出table-driven單測的基本寫法:

數(shù)據(jù)表里的每一行數(shù)據(jù),一般包含:subtest的名字、輸入、期望的輸出。
填充好的代碼如下:
func TestGetWeekDay(t *testing.T) {type args struct {index int}tests := []struct {name stringargs argswant string}{{name: "index=0", args: args{index: 0}, want: "Sunday"},{name: "index=1", args: args{index: 1}, want: "Monday"},{name: "index=2", args: args{index: 2}, want: "Tuesday"},{name: "index=3", args: args{index: 3}, want: "Wednesday"},{name: "index=4", args: args{index: 4}, want: "Thursday"},{name: "index=5", args: args{index: 5}, want: "Friday"},{name: "index=6", args: args{index: 6}, want: "Saturday"},{name: "index=-1", args: args{index: -1}, want: "Unknown"},{name: "index=8", args: args{index: 8}, want: "Unknown"},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {if got := GetWeekDay(tt.args.index); got != tt.want {t.Errorf("GetWeekDay() = %v, want %v", got, tt.want)}})}}
注意給每行子測試一個有意義的name,作為它的標(biāo)識。否則,自己測的時候可讀性差不說,GoLand的單獨測試也不認識它了:

四、高階玩法
(一)table-driven+parallel
默認情況下,一個測試用例的所有subtests是串行執(zhí)行的。如果需要并行,則要在t.Run里顯式地寫明t.Parallel,才能使這個subtest與其他帶t.Parallel的subtets一起并行執(zhí)行:
for _, tt := range tests {tt := tt // 新變量 ttt.Run(tt.name, func (t *testing.T) {t.Parallel() // 并行測試t.Logf("name: %s; args: %d; want: %s", tt.name, tt.args.index, tt.want)if got := GetWeekDay(tt.args.index); got != tt.want {t.Errorf("GetWeekDay() = %v, want %v", got, tt.want)}})}
此處需注意,在循環(huán)內(nèi),多加了一句tt:=tt。如果不加它,將會掉進Go語言循環(huán)變量的一個經(jīng)典大坑。這是因為:
for循環(huán)迭代器的變量tt,是被每次循環(huán)所共用的。也即,tt一直是同一個 tt;每次循環(huán)只改變了tt的值,而地址和變量名一直沒變。
每個加了t.Parallel的subtest,被傳給自己的go routine后不會馬上執(zhí)行,而是會暫停,等待與其并行的所有subtest都初始化完成。
那么,當(dāng)Go調(diào)度器真正開始執(zhí)行所有subtest的時候,外面的for循環(huán)已經(jīng)跑完了;其迭代器變量tt的值,已經(jīng)拿到了循環(huán)的最后一個值。
于是,所有subtest的go routine都拿到了同一個tt值,也即循環(huán)的最后一個值。
最坑的是,如果你不打印一些log,還發(fā)現(xiàn)不了這個問題,因為雖然每次循環(huán)都在檢查最后一組輸入輸出,但如果這組值是能pass的,那么所有測試全部能pass,暴露不了問題:

為了解決這個問題,最常用的方法,就是上述代碼里的tt:=tt,也即,每次循環(huán)的代碼塊內(nèi)部,都新建一個變量來保存當(dāng)前的tt值。(當(dāng)然,新變量可以叫tt也可以叫其他名字;如果叫tt,那么這個新tt的作用域是在當(dāng)次循環(huán)內(nèi)部,覆蓋了外面那個所有循環(huán)共用的tt。)
(二)table-driven+assert
Go的標(biāo)準(zhǔn)庫本身不提供斷言,但我們可以借助testify測試庫的assert子庫,引入斷言,使得代碼更簡潔、可讀性更強。
例如,在上述TestGetWeekDay中,本來我們是用下面語句做判斷:
if got != tt.want {t.Errorf("GetWeekDay() = %v, want %v", got, tt.want)}
如果assert,判斷代碼可以簡化為:
assert.Equal(t, tt.want, got, "should be equal")完整代碼如下:
func TestGetWeekDay(t *testing.T) {type args struct {index int}tests := []struct {name stringargs argswant string}{{name: "index=0", args: args{index: 0}, want: "Sunday"},{name: "index=1", args: args{index: 1}, want: "Monday"},{name: "index=2", args: args{index: 2}, want: "Tuesday"},{name: "index=3", args: args{index: 3}, want: "Wednesday"},{name: "index=4", args: args{index: 4}, want: "Thursday"},{name: "index=5", args: args{index: 5}, want: "Friday"},{name: "index=6", args: args{index: 6}, want: "Saturday"},{name: "index=-1", args: args{index: -1}, want: "Unknown"},{name: "index=8", args: args{index: 8}, want: "Unknown"},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {got := GetWeekDay(tt.args.index)assert.Equal(t, tt.want, got, "should be equal")})}}
錯誤日志的輸出也更加結(jié)構(gòu)清晰。例如,我們將table數(shù)據(jù)的第一行改為下面這樣,使這個subtest出錯:
{name: "index=0", args: args{index: 0}, want: "NotSunday"},將得到以下錯誤日志:

此外,還可以將assert邏輯作為一個func類型的字段,直接放在table的每行數(shù)據(jù)里:
func TestGetWeekDay(t *testing.T) {type args struct {index int}tests := []struct {name stringargs argsassert func(got string)}{{name: "index=0",args: args{index: 0},assert: func(got string) {assert.Equal(t, "Sunday", got, "should be equal")}},{name: "index=1",args: args{index: 1},assert: func(got string) {assert.Equal(t, "Monday", got, "should be equal")}},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {got := GetWeekDay(tt.args.index)if tt.assert != nil {tt.assert(got)}})}}
(三)table-driven+mock
當(dāng)被測的方法存在第三方依賴,如數(shù)據(jù)庫、其他服務(wù)接口等等,在寫單測的時候,可以將外部依賴抽象為接口,再用mock來模擬外部依賴的各種行為。
我們可以借助Go官方的gomock框架,用其mockgen工具生成接口對應(yīng)的Mock類源文件,再在測試用例中,使用gomock包結(jié)合這些Mock類進行打樁測試。
例如,我們可以改造之前的GetWeekDay func,把它作為WeekDayClient結(jié)構(gòu)體的一個方法,并需要依賴一個外部接口WeekDayService,才能拿到結(jié)果:
package maintype WeekDayService interface {GetWeekDay(int) string}type WeekDayClient struct {svc WeekDayService}func (c *WeekDayClient) GetWeekDay(index int) string {return c.svc.GetWeekDay(index)}
使用mockgen工具,為接口生成mock:
mockgen -source=weekday_srv.go -destination=weekday_srv_mock.go -package=main然后,把GoLand自動生成的單測模板改一改,加入mock和assert的邏輯:
package mainimport ("github.com/golang/mock/gomock""github.com/stretchr/testify/assert""testing")func TestWeekDayClient_GetWeekDay(t *testing.T) {// dependency fieldstype fields struct {svc *MockWeekDayService}// input argstype args struct {index int}// teststests := []struct {name stringfields fieldsargs argsprepare func(f *fields)assert func(got string)}{{name: "index=0",args: args{index: 0},prepare: func(f *fields) {f.svc.EXPECT().GetWeekDay(gomock.Any()).Return("Sunday")},assert: func(got string) {assert.Equal(t, "Sunday", got, "should be equal")}},{name: "index=1",args: args{index: 1},prepare: func(f *fields) {f.svc.EXPECT().GetWeekDay(gomock.Any()).Return("Monday")},assert: func(got string) {assert.Equal(t, "Monday", got, "should be equal")}},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {// arrangectrl := gomock.NewController(t)defer ctrl.Finish()f := fields{svc: NewMockWeekDayService(ctrl),}if tt.prepare != nil {tt.prepare(&f)}// actc := &WeekDayClient{svc: f.svc,}got := c.GetWeekDay(tt.args.index)// assertif tt.assert != nil {tt.assert(got)}})}}
其中:
fields是WeekDayClient struct里的字段,為了mock,單測時將里面的外部依賴svc的原本類型WeekDayService,替換為mockgen生成的MockWeekDayService。
在每個subtest數(shù)據(jù)里,加一個func類型的prepare字段,可將fields作為入?yún)ⅲ趐repare時對fields.svc的多種行為進行mock。
在每個t.Run的準(zhǔn)備階段,創(chuàng)建mock控制器、用該控制器創(chuàng)建mock對象、調(diào)prepare對mock對象做行為注入、最后將該mock對象作為接口的實現(xiàn),供WeekDayClient作為外部依賴使用。
(四)自定義模板
如果覺得GoLand Generate>Test for xx自動生成的table-driven測試模板不夠好用,可以考慮用GoLand Live Template自定義模板。
例如,若我代碼里很多方法都類似上文中的GetWeekDay,那我可以抽取通用部分,做成一個table-driven+parallel+mock+assert的代碼模板:
func Test$NAME$(t *testing.T) {// dependency fieldstype fields struct {}// input argstype args struct {}// teststests := []struct {name stringfields fieldsargs argsprepare func(f *fields)assert func(got string)}{// TODO: Add test cases.}for _, tt := range tests {tt := ttt.Run(tt.name, func(t *testing.T) {// run in parallelt.Parallel()// arrangectrl := gomock.NewController(t)defer ctrl.Finish()f := fields{}if tt.prepare != nil {tt.prepare(&f)}// act// TODO: add test logic// assertif tt.assert != nil {tt.assert($GOT$)}})}}
然后打開GoLand>Preference>Editor>Live Template,新建一個自定義的模板:

把代碼貼在Template text里,并且Define適用范圍部分勾選Go,然后保存。
那么,在后續(xù)寫代碼時,我們只要敲出這個Live Template的名字,就能召喚出這段代碼模板:

然后,把里面的$$變量部分和TODO業(yè)務(wù)邏輯改一改,就能使用了。
?作者簡介
雷暢
騰訊后臺開發(fā)工程師
騰訊后臺開發(fā)工程師,畢業(yè)于復(fù)旦大學(xué),目前負責(zé)騰訊云性能測試服務(wù)的后臺開發(fā),具有豐富的云原生監(jiān)控系統(tǒng)和性能測試系統(tǒng)的開發(fā)經(jīng)驗。
?推薦閱讀
在線教程!C++如何在云應(yīng)用中快速實現(xiàn)編譯優(yōu)化?


