<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

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

          共 11328字,需瀏覽 23分鐘

           ·

          2022-02-20 17:15


          導(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)定的流程


          1. 定義測試用例

          2. 定義輸入數(shù)據(jù)和期望的輸出數(shù)據(jù)

          3. 跑測試用例,拿到實際輸出

          4. 比較期望輸出和實際輸出


          • 易變的數(shù)據(jù)


          1. 輸入的數(shù)據(jù)

          2. 期望的輸出數(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 := 0   want := "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 := 1 want := "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 := 0      want := "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 := 1 want := "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 string      args args      want 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 // 新變量 tt   t.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 string      args args      want 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   string      args   args      assert 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 main
          type 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 main
          import ( "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "testing")
          func TestWeekDayClient_GetWeekDay(t *testing.T) { // dependency fields type fields struct { svc *MockWeekDayService } // input args type args struct { index int } // tests tests := []struct { name string fields fields args args prepare 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) { // arrange ctrl := gomock.NewController(t) defer ctrl.Finish() f := fields{ svc: NewMockWeekDayService(ctrl), } if tt.prepare != nil { tt.prepare(&f) }
          // act c := &WeekDayClient{ svc: f.svc, } got := c.GetWeekDay(tt.args.index)
          // assert if 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 fields   type fields struct {   }   // input args   type args struct {   }   // tests   tests := []struct {      name    string      fields  fields      args    args      prepare func(f *fields)      assert  func(got string)   }{      // TODO: Add test cases.   }   for _, tt := range tests {      tt := tt      t.Run(tt.name, func(t *testing.T) {         // run in parallel         t.Parallel()
          // arrange ctrl := gomock.NewController(t) defer ctrl.Finish() f := fields{} if tt.prepare != nil { tt.prepare(&f) }
          // act // TODO: add test logic
          // assert if 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)驗。



          ?推薦閱讀


          10分鐘搞懂!消息隊列選型全方位對比

          在線教程!C++如何在云應(yīng)用中快速實現(xiàn)編譯優(yōu)化?

          CGO讓Go與C手牽手,打破雙方“壁壘”!

          前端推薦!玩轉(zhuǎn)Webpack共需幾步?



          瀏覽 121
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  强上app在线观看一区二区三区 | 久久夜色精品国产噜噜亚洲AV | 欧美激情一区二区 | 北条麻妃一区二区三区成人片 | 影音先锋三级资源 |