# Go 語言單元測試學習筆記 ## 關於測試 ### 列舉幾種測試 #### 單元測試 (Unit Testing) 單元測試是最小的測試單位,所以執行速度快且可靠,通常由開發人員自行撰寫 單元測試的高度涵蓋是自動化測試 (Test Automation) 的核心 ##### 準則 1. 一個測試案例只測一種方法 2. 最小的測試單位 3. 不與外部(包括檔案、資料庫、網路、服務、物件、類別)直接相依 4. 不具備邏輯 5. 測試案例之間相依性為零 ##### 排除外部依賴 單元測試關注的是測試程式本身邏輯,所以必須要把外部依賴(Database、File System IO)全部排除。常會使用Mock Data(假資料)來替代從外部依賴獲取資料的流程 ##### 涵蓋率建議 1. 確保含有大部分重要邏輯的程式有被涵蓋到即可 2. 需求不確定或經常變動的情況下,寫單元測試的價值可能不高,因為測試也必須經常調整 <br> #### 整合測試 (Integration Testing) 整合測試比單元測試要高一級,是測試兩個以上的模組之間的交互作用符合預期,對模擬環境的完整度有較高要求 #### 回歸測試 (Regression Testing) 回歸測試是指重複執行既有的全部或部分的相同測試,需要根據需求、時程等問題選擇不同的執行策略 ### 測試的開發方法 #### 先開發再測試 略 #### 測試驅動開發(TDD) 1. 寫測試:編寫測試,加入test case (此時test會fail) 2. 寫程式:開始寫code, 目的是要讓 test pass 3. 優化程式碼:並循環以上步驟refactor 你的code, 但test 還是要pass ![](https://miro.medium.com/v2/resize:fit:720/format:webp/1*FGH_G1cQgpgilniCKY8NUQ.png) ### 寫測試的好處 * 易於維持程式碼品質 * 確保每次改動或刪除程式碼不會影響到非預期範圍 * 可以透過測試很快了解某功能,包含呼叫情境,傳入的參數及預期的結果值(文件不一定會是最新的,但測試一定是) * 撰寫過程中可幫助程式開發:降低過度耦合、趨於物件導向原則 # Go寫單元測試 以下將使用幾個範例講解寫測試的方式和介紹一些套件的使用 ## testing 使用Go自帶的`testing`測試框架,配合`go test`指令來實現測試 #### 簡單範例([參考](https://medium.com/drunk-wis/golang-%E9%97%9C%E6%96%BC%E5%96%AE%E5%85%83%E6%B8%AC%E8%A9%A6-unit-test-%E6%B8%AC%E4%BB%80%E9%BA%BC-99fcd2e8e27f)) 單元測試檔案必須為`_test.go`結尾、測試 func 須符合 `Test...` 的規則 這樣下`go test`指令時才會有效被執行 * 資料夾結構 ``` validator // 資料夾名稱 ├── validator.go // 功能所在的檔案 └── validator_test.go // 撰寫針對該檔案的測試 ``` * 撰寫功能 此功能可用來驗證傳入的字串是否為合法的uuid ```go= package validator import "github.com/google/uuid" func IsValidUUID(u string) bool { _, err := uuid.Parse(u) return err == nil } ``` * 撰寫單元測試 可以利用 `gotests` 工具自動產生單元測試: 游標停在欲測試的func上 -> 鍵盤按`ctl+shift+P`彈出選單 -> 點選「Go:Generate Unit Tests For Function」 ![](https://i.imgur.com/Nb5znYI.gif) 將會根據該func自動產生測試的檔案以及基礎的程式碼,再自行補上要測試的case後即可使用 ```go= package validator import "testing" func TestIsValidUUID(t *testing.T) { type args struct { u string } tests := []struct { name string args args want bool }{ // test cases { "caseReturnTrue", args{ u: "a0a8aea5-cc40-4293-b1f3-c3bc4e53d941", }, true, }, { "caseReturnFalse", args{ u: "a0a8aea5-cc40-4293-b1f3-", }, false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := IsValidUUID(tt.args.u); got != tt.want { t.Errorf("IsValidUUID() = %v, want %v", got, tt.want) } }) } } ``` :::info 呼叫 testing.T 的 Error, Errorf, FailNow, Fatal, FatalIf 方法,說明測試不透過 呼叫 Log 方法用來記錄測試的資訊 ::: 當然,也可以自行撰寫測試: Test Func 建議使用有意義的命名,且須符合`Test{Xxx...}`的規則 ```go= package validator import ( "testing" "github.com/stretchr/testify/assert" ) func TestIsValidUUID_ReturnTrue(t *testing.T) { rlt := IsValidUUID("a0a8aea5-cc40-4293-b1f3-c3bc4e53d941") assert.True(t, rlt) } func TestIsValidUUID_ReturnFalse(t *testing.T) { rlt := IsValidUUID("a0a8aea5-cc40-4293-b1f3-c3bc4-") assert.False(t, rlt) } ``` > testify [`assert`](https://github.com/stretchr/testify) package <br> * 執行測試(所有案例) *go test -v {filePath}*,沒有加的話就是找當前路徑下的`_test`檔案 預設會執行檔案內的所有測試案例 ```shell $ go test -v ./validator ``` ```shell === RUN TestIsValidUUID_ReturnTrue --- PASS: TestIsValidUUID_ReturnTrue (0.00s) === RUN TestIsValidUUID_ReturnFalse --- PASS: TestIsValidUUID_ReturnFalse (0.00s) PASS ok go_traning/unit_test/validator 0.358s ``` 由於指令中加了`-v`,會顯示測試的詳細過程(不然只會有PASS、ok的那兩行) <br> * 執行測試(指定執行某個案例,順便故意改成不通過) *go test -v -run {testFuncName} {filePath}*,其中 *{testFuncName}* 是為正則表達式 ```shell $ go test -v -run TestIsValidUUID_ReturnTrue ./validator ``` ```shell === RUN TestIsValidUUID_ReturnTrue validator_test.go:45: Error Trace: /Users/esther_lin/Desktop/Go traning/goUnitTest/validator/validator_test.go:45 Error: Should be true Test: TestIsValidUUID_ReturnTrue --- FAIL: TestIsValidUUID_ReturnTrue (0.00s) FAIL exit status 1 FAIL go_traning/unit_test/validator 0.353s ``` 因為是正則表達的關係,如果是執行以下指令: ```shell $ go test -v -run TestIsValidUUID_Return ./validator ``` *...ReturnTrue* 以及 *...ReturnFalse* 兩個func都會被執行 若要完全指定,使用 *{testFuncName$}* 即可只執行符合該名稱的測試 #### 測試涵蓋率 ```shell $ go test -cover PASS coverage: 100.0% of statements ok go_traning/unit_test/mock/db 0.336s ``` ### 踩雷分享 1. 測試時,會掃到同資料夾下的引用,如果裡面有init就會被強制執行(自己的init則不會) * 舉例 `ex.go` ```go package testfunc import ( "package_name/configs" // 引用的package如果裡面有init,在測試時會被執行 "fmt" ) // 自己的init則不會在測試中被執行到 func init() { fmt.Println(configs.AppConfig.App.Env) } func GetName() string { return "Hello" } ``` `ex_test.go` ```go func TestGetName(t *testing.T) { tests := []struct { name string want string }{ { "1", "Hello", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := GetName(); got != tt.want { t.Errorf("GetName() = %v, want %v", got, tt.want) } }) } } ``` `configs/app.go` ```go func init(){ loadAppConfig() } func loadAppConfig(){ AppConfig.App.Env = os.Getenv("Env") if len(AppConfig.App.Env) == 0{ panic("無效的環境參數") } } ``` 執行時,會有錯誤 ```shell $ go test -timeout 30s -run ^TestGetName$ crown_connector/testfunc panic: 無效的環境參數 goroutine 1 [running]: crown_connector/configs.loadAppConfig() /file_path/configs/app.go:69 +0x2a9 crown_connector/configs.init.0() /file_path/configs/app.go:53 +0x17 FAIL crown_connector/testfunc 0.359s FAIL ``` 是因為呼叫到了`/configs` 裡的 `init()`,要避免這個情況,必須把init改為手動呼叫func初始化 <br> ## Mock / Stubs 做出一個模擬的(假的) Function 或 Object 來取代原本程式邏輯內部相對應的 Function 或 Object,如DB回傳、外部API回傳...等 * stub: 驗證目標回傳值,以及驗證目標物件狀態的改變 * mock:驗證目標物件與外部相依介面的互動方式 ex.驗證是否某func被call的次數符合預期 ### [gomock](https://github.com/golang/mock) [(Doc)](https://pkg.go.dev/github.com/golang/mock/gomock#pkg-overview) Go官方提供的套件,可以直接針對檔案產出對應的 mock 檔 但要注意,mock 是針對 interface 產生,檔案中必須要有定義 interface 否則無法使用(對寫法有限制) #### 簡單範例([參考](https://boy921.medium.com/%E4%BD%BF%E7%94%A8-gomock-%E6%96%BC-unit-test-8ea51b75dd75)) 1. 先定義一個 GetNameByIndex 為 interface 的 Method,作為晚點要被替換掉的 func `get_name.go` ```go= package db type DB interface { GetNameByIndex(index int) string } func GetName(db DB, index int) string { return db.GetNameByIndex(index) } ``` 2. 產生mock檔 如果沒有用過 `mockgen` 的話,要先安裝: ```shell $ go install github.com/golang/mock/mockgen@v1.6.0 ``` 要是套件安裝失敗,可以試試這兩個指令 ```shell $ export GOPATH=$HOME/go $ export PATH=$PATH:$GOROOT/bin:$GOPATH/bin ``` 接著使用指令來產生 mock 檔案 ```shell $ mockgen -destination get_name_mock.go -package db -source get_name.go ``` * -source:根據這個檔案去生成 mock * -destination:要生成 mock 檔的位置,沒寫就直接呈現在terminal裡,不會生成檔案 * -package:mock 檔的 package 名稱,預設為 `mock_` 前綴 source 的 package 名稱 <br> 生成的內容會像這樣: `get_name_mock.go` ```go= // Code generated by MockGen. DO NOT EDIT. // Source: get_name.go // Package db is a generated GoMock package. package db import ( reflect "reflect" gomock "github.com/golang/mock/gomock" ) // MockDB is a mock of DB interface. type MockDB struct { ctrl *gomock.Controller recorder *MockDBMockRecorder } // MockDBMockRecorder is the mock recorder for MockDB. type MockDBMockRecorder struct { mock *MockDB } // NewMockDB creates a new mock instance. func NewMockDB(ctrl *gomock.Controller) *MockDB { mock := &MockDB{ctrl: ctrl} mock.recorder = &MockDBMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockDB) EXPECT() *MockDBMockRecorder { return m.recorder } // GetNameByIndex mocks base method. func (m *MockDB) GetNameByIndex(index int) string { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetNameByIndex", index) ret0, _ := ret[0].(string) return ret0 } // GetNameByIndex indicates an expected call of GetNameByIndex. func (mr *MockDBMockRecorder) GetNameByIndex(index interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNameByIndex", reflect.TypeOf((*MockDB)(nil).GetNameByIndex), index) } ``` 3. 撰寫單元測試 建立 Test func 後,宣告一個 gomock controller ```go func TestGetName(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() // 最後要關掉它 } ``` 接著宣告 *MockDB* 的物件(以下命名為 *m*),將 *ctrl* 帶入 再根據 *GetNameByIndex* 方法去設定 (多個) 帶入的參數以及回傳的數值 `EXPECT()` 代表**期望** *GetNameByIndex* 傳入相當於 `Eq()` 內的參數時 * 回傳 `Return` 中指定的值 * `DoAndReturn` 執行指定的行為後回傳 ```go // _mock.go 產生時定義好的func // func名稱固定為 NewMock{interfaceName},New出來的物件型態固定為 Mock{interfaceName} m := NewMockDB(ctrl) // 設定只要帶入 index = 1 就睡一秒後再回傳 superman m. EXPECT(). GetNameByIndex(gomock.Eq(1)). // 由 _mock.go 所實作 DoAndReturn(func(_ int) string { // 此 func 型態必須符合 GetNameByIndex time.Sleep(1000) return "superman" }) // 設定只要帶入 index = 2 就回傳 spiderman 這個字串 m. EXPECT(). GetNameByIndex(gomock.Eq(2)). Return("spiderman") ``` :::info gomock 會假定這個 mock 至少會被調用一次,若無,測試時會報錯「missing call(s)」 欲避免此情況,可以在設定後方加上 `.AnyTimes()` 相關指令可以參考 [Go Mock -gomock- 簡明教程](https://www.readfog.com/a/1641094754324287488) ::: 設定完 mock 後,**要把 *MockDB* 物件帶入要測試的那個 func 裡** 因為它有 implement interface 中的 Method (*GetNameByIndex*),所以能順利取代原本真正要去存取DB的 *GetNameByIndex* [(觀念參考)](https://pjchender.dev/golang/interfaces/) ```go Convey(testCase.testName, t, func() { name := GetName(m, testCase.arg) // call 要測試的那個 function So(name, ShouldEqual, testCase.want) }) ``` > [goconvey](https://github.com/smartystreets/goconvey) package 4. 開測 完整的測試程式碼像這樣: `get_name_test.go` ```go= package db import ( "testing" "time" "github.com/golang/mock/gomock" . "github.com/smartystreets/goconvey/convey" ) func TestGetName(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() m := NewMockDB(ctrl) m. EXPECT(). GetNameByIndex(gomock.Eq(1)). DoAndReturn(func(_ int) string { time.Sleep(1000) return "superman" }) m. EXPECT(). GetNameByIndex(gomock.Eq(2)). Return("spiderman") testCases := []struct { testName string want string arg int }{ { "超人", "superman", 1, }, { "蜘蛛人", "spiderman", 2, }, } for _, testCase := range testCases { Convey(testCase.testName, t, func() { name := GetName(m, testCase.arg) So(name, ShouldEqual, testCase.want) }) } } ``` 執行結果: ```shell $ go test -v === RUN TestGetName 超人 ✔ 1 total assertion 蜘蛛人 ✔ 2 total assertions --- PASS: TestGetName (0.00s) PASS ok go_traning/unit_test/mock/db 0.302s ``` # Go專案 單元測試整合CICD ## SonarQube ### 設定 Comma-delimited list of paths to unit test report files. Paths may be absolute or relative to the project root. ``` sonar.go.tests.reportPaths=report.json sonar.go.coverage.reportPaths=coverage.out ``` > [官方文件](https://docs.sonarqube.org/latest/analyzing-source-code/test-coverage/test-execution-parameters/)、[流程參考](https://community.sonarsource.com/t/sonargo-code-coverage-0/19473/9) ### 產生測試報告 #### go test 方法一 (xml) 1. 產生覆蓋率文件 ```shell $ go test -v ./... -coverprofile=coverage.out ``` `coverage.out` ```out mode: set go_traning/unit_test/db/get_name.go:15.51,18.2 1 0 go_traning/unit_test/db/get_name.go:20.39,22.2 1 1 go_traning/unit_test/db/get_name_mock.go:25.49,29.2 3 1 go_traning/unit_test/db/get_name_mock.go:32.47,34.2 1 1 go_traning/unit_test/db/get_name_mock.go:37.51,42.2 4 1 go_traning/unit_test/db/get_name_mock.go:45.78,48.2 2 1 go_traning/unit_test/validator/validator.go:5.33,8.2 2 1 ``` 2. 轉換為sonar可解讀呈現的檔案格式 需安裝 [`gocov`](https://github.com/axw/gocov) [`gocov-xml`](https://github.com/AlekSi/gocov-xml) 兩個工具 > ```shell $ gocov convert cover.out $ gocov-xml > report.xml ``` `report.xml` <details> <summary>點擊查看完整XML檔案</summary> <pre><code> <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE coverage SYSTEM "http://cobertura.sourceforge.net/xml/coverage-04.dtd"> <coverage line-rate="0.9285714" branch-rate="0" lines-covered="13" lines-valid="14" branches-covered="0" branches-valid="0" complexity="0" version="" timestamp="1676262029871"> <packages> <package name="go_traning/unit_test/db" line-rate="0.9166667" branch-rate="0" complexity="0" line-count="12" line-hits="11"> <classes> <class name="-" filename="db/get_name.go" line-rate="1" branch-rate="0" complexity="0" line-count="1" line-hits="1"> <methods> <method name="GetName" signature="" line-rate="1" branch-rate="0" complexity="0" line-count="0" line-hits="0"> <lines> <line number="21" hits="1"></line> </lines> </method> </methods> <lines> <line number="21" hits="1"></line> </lines> </class> <class name="UserDB" filename="db/get_name.go" line-rate="0" branch-rate="0" complexity="0" line-count="1" line-hits="0"> <methods> <method name="GetNameByIndex" signature="" line-rate="0" branch-rate="0" complexity="0" line-count="0" line-hits="0"> <lines> <line number="17" hits="0"></line> </lines> </method> </methods> <lines> <line number="17" hits="0"></line> </lines> </class> <class name="-" filename="db/get_name_mock.go" line-rate="1" branch-rate="0" complexity="0" line-count="3" line-hits="3"> <methods> <method name="NewMockDB" signature="" line-rate="1" branch-rate="0" complexity="0" line-count="0" line-hits="0"> <lines> <line number="26" hits="1"></line> <line number="27" hits="1"></line> <line number="28" hits="1"></line> </lines> </method> </methods> <lines> <line number="26" hits="1"></line> <line number="27" hits="1"></line> <line number="28" hits="1"></line> </lines> </class> <class name="MockDB" filename="db/get_name_mock.go" line-rate="1" branch-rate="0" complexity="0" line-count="5" line-hits="5"> <methods> <method name="EXPECT" signature="" line-rate="1" branch-rate="0" complexity="0" line-count="0" line-hits="0"> <lines> <line number="33" hits="1"></line> </lines> </method> <method name="GetNameByIndex" signature="" line-rate="1" branch-rate="0" complexity="0" line-count="0" line-hits="0"> <lines> <line number="38" hits="1"></line> <line number="39" hits="1"></line> <line number="40" hits="1"></line> <line number="41" hits="1"></line> </lines> </method> </methods> <lines> <line number="33" hits="1"></line> <line number="38" hits="1"></line> <line number="39" hits="1"></line> <line number="40" hits="1"></line> <line number="41" hits="1"></line> </lines> </class> <class name="MockDBMockRecorder" filename="db/get_name_mock.go" line-rate="1" branch-rate="0" complexity="0" line-count="2" line-hits="2"> <methods> <method name="GetNameByIndex" signature="" line-rate="1" branch-rate="0" complexity="0" line-count="0" line-hits="0"> <lines> <line number="46" hits="1"></line> <line number="47" hits="1"></line> </lines> </method> </methods> <lines> <line number="46" hits="1"></line> <line number="47" hits="1"></line> </lines> </class> </classes> </package> <package name="go_traning/unit_test/validator" line-rate="1" branch-rate="0" complexity="0" line-count="2" line-hits="2"> <classes> <class name="-" filename="validator/validator.go" line-rate="1" branch-rate="0" complexity="0" line-count="2" line-hits="2"> <methods> <method name="IsValidUUID" signature="" line-rate="1" branch-rate="0" complexity="0" line-count="0" line-hits="0"> <lines> <line number="6" hits="1"></line> <line number="7" hits="1"></line> </lines> </method> </methods> <lines> <line number="6" hits="1"></line> <line number="7" hits="1"></line> </lines> </class> </classes> </package> </packages> <sources> <source>/Users/esther_lin/Desktop/Go traning/goUnitTest</source> </sources> </coverage> </pre></code> </details> #### go test 方法二 (json) ```shell $ go test "./..." -coverprofile="coverage.out" -covermode=count -json > report.json ``` `report.json` <details> <summary>點擊查看完整 json檔案</summary> <pre><code> {"Time":"2023-02-13T14:27:15.686684+08:00","Action":"output","Package":"go_traning/unit_test","Output":"? \tgo_traning/unit_test\t[no test files]\n"} {"Time":"2023-02-13T14:27:15.686865+08:00","Action":"skip","Package":"go_traning/unit_test","Elapsed":0} {"Time":"2023-02-13T14:27:16.484719+08:00","Action":"run","Package":"go_traning/unit_test/db","Test":"TestGetName"} {"Time":"2023-02-13T14:27:16.484966+08:00","Action":"output","Package":"go_traning/unit_test/db","Test":"TestGetName","Output":"=== RUN TestGetName\n"} {"Time":"2023-02-13T14:27:16.48499+08:00","Action":"output","Package":"go_traning/unit_test/db","Test":"TestGetName","Output":"\n"} {"Time":"2023-02-13T14:27:16.484999+08:00","Action":"output","Package":"go_traning/unit_test/db","Test":"TestGetName","Output":" 超人 \u001b[32m✔\u001b[0m\n"} {"Time":"2023-02-13T14:27:16.485009+08:00","Action":"output","Package":"go_traning/unit_test/db","Test":"TestGetName","Output":"\n"} {"Time":"2023-02-13T14:27:16.485016+08:00","Action":"output","Package":"go_traning/unit_test/db","Test":"TestGetName","Output":"\u001b[31m\u001b[0m\u001b[33m\u001b[0m\u001b[32m\n"} {"Time":"2023-02-13T14:27:16.485029+08:00","Action":"output","Package":"go_traning/unit_test/db","Test":"TestGetName","Output":"1 total assertion\u001b[0m\n"} {"Time":"2023-02-13T14:27:16.485049+08:00","Action":"output","Package":"go_traning/unit_test/db","Test":"TestGetName","Output":"\n"} {"Time":"2023-02-13T14:27:16.485056+08:00","Action":"output","Package":"go_traning/unit_test/db","Test":"TestGetName","Output":"\n"} {"Time":"2023-02-13T14:27:16.485063+08:00","Action":"output","Package":"go_traning/unit_test/db","Test":"TestGetName","Output":" 蜘蛛人 \u001b[32m✔\u001b[0m\n"} {"Time":"2023-02-13T14:27:16.48507+08:00","Action":"output","Package":"go_traning/unit_test/db","Test":"TestGetName","Output":"\n"} {"Time":"2023-02-13T14:27:16.485078+08:00","Action":"output","Package":"go_traning/unit_test/db","Test":"TestGetName","Output":"\u001b[31m\u001b[0m\u001b[33m\u001b[0m\u001b[32m\n"} {"Time":"2023-02-13T14:27:16.48509+08:00","Action":"output","Package":"go_traning/unit_test/db","Test":"TestGetName","Output":"2 total assertions\u001b[0m\n"} {"Time":"2023-02-13T14:27:16.485098+08:00","Action":"output","Package":"go_traning/unit_test/db","Test":"TestGetName","Output":"\n"} {"Time":"2023-02-13T14:27:16.48515+08:00","Action":"output","Package":"go_traning/unit_test/db","Test":"TestGetName","Output":"--- PASS: TestGetName (0.00s)\n"} {"Time":"2023-02-13T14:27:16.485162+08:00","Action":"pass","Package":"go_traning/unit_test/db","Test":"TestGetName","Elapsed":0} {"Time":"2023-02-13T14:27:16.485172+08:00","Action":"output","Package":"go_traning/unit_test/db","Output":"PASS\n"} {"Time":"2023-02-13T14:27:16.485224+08:00","Action":"output","Package":"go_traning/unit_test/db","Output":"coverage: 91.7% of statements\n"} {"Time":"2023-02-13T14:27:16.486132+08:00","Action":"output","Package":"go_traning/unit_test/db","Output":"ok \tgo_traning/unit_test/db\t0.317s\tcoverage: 91.7% of statements\n"} {"Time":"2023-02-13T14:27:16.486194+08:00","Action":"pass","Package":"go_traning/unit_test/db","Elapsed":0.317} {"Time":"2023-02-13T14:27:16.730648+08:00","Action":"run","Package":"go_traning/unit_test/validator","Test":"TestIsValidUUID_ReturnTrue"} {"Time":"2023-02-13T14:27:16.730777+08:00","Action":"output","Package":"go_traning/unit_test/validator","Test":"TestIsValidUUID_ReturnTrue","Output":"=== RUN TestIsValidUUID_ReturnTrue\n"} {"Time":"2023-02-13T14:27:16.730803+08:00","Action":"output","Package":"go_traning/unit_test/validator","Test":"TestIsValidUUID_ReturnTrue","Output":" validator_test.go:45: \n"} {"Time":"2023-02-13T14:27:16.730818+08:00","Action":"output","Package":"go_traning/unit_test/validator","Test":"TestIsValidUUID_ReturnTrue","Output":" \tError Trace:\t/Users/esther_lin/Desktop/Go traning/goUnitTest/validator/validator_test.go:45\n"} {"Time":"2023-02-13T14:27:16.730837+08:00","Action":"output","Package":"go_traning/unit_test/validator","Test":"TestIsValidUUID_ReturnTrue","Output":" \tError: \tShould be true\n"} {"Time":"2023-02-13T14:27:16.73089+08:00","Action":"output","Package":"go_traning/unit_test/validator","Test":"TestIsValidUUID_ReturnTrue","Output":" \tTest: \tTestIsValidUUID_ReturnTrue\n"} {"Time":"2023-02-13T14:27:16.730913+08:00","Action":"output","Package":"go_traning/unit_test/validator","Test":"TestIsValidUUID_ReturnTrue","Output":"--- FAIL: TestIsValidUUID_ReturnTrue (0.00s)\n"} {"Time":"2023-02-13T14:27:16.730923+08:00","Action":"fail","Package":"go_traning/unit_test/validator","Test":"TestIsValidUUID_ReturnTrue","Elapsed":0} {"Time":"2023-02-13T14:27:16.730938+08:00","Action":"run","Package":"go_traning/unit_test/validator","Test":"TestIsValidUUID_ReturnFalse"} {"Time":"2023-02-13T14:27:16.730949+08:00","Action":"output","Package":"go_traning/unit_test/validator","Test":"TestIsValidUUID_ReturnFalse","Output":"=== RUN TestIsValidUUID_ReturnFalse\n"} {"Time":"2023-02-13T14:27:16.730961+08:00","Action":"output","Package":"go_traning/unit_test/validator","Test":"TestIsValidUUID_ReturnFalse","Output":"--- PASS: TestIsValidUUID_ReturnFalse (0.00s)\n"} {"Time":"2023-02-13T14:27:16.730972+08:00","Action":"pass","Package":"go_traning/unit_test/validator","Test":"TestIsValidUUID_ReturnFalse","Elapsed":0} {"Time":"2023-02-13T14:27:16.730981+08:00","Action":"output","Package":"go_traning/unit_test/validator","Output":"FAIL\n"} {"Time":"2023-02-13T14:27:16.731117+08:00","Action":"output","Package":"go_traning/unit_test/validator","Output":"coverage: 100.0% of statements\n"} {"Time":"2023-02-13T14:27:16.732104+08:00","Action":"output","Package":"go_traning/unit_test/validator","Output":"FAIL\tgo_traning/unit_test/validator\t0.509s\n"} {"Time":"2023-02-13T14:27:16.732145+08:00","Action":"fail","Package":"go_traning/unit_test/validator","Elapsed":0.509} </pre></code> </details> #### goconvey 適合本地查看測試狀態,需使用該套件寫斷言 1. 安裝 ```shell $ go install github.com/smartystreets/goconvey ``` 2. 下此指令後,會自動以瀏覽器開啟介面(http://localhost:8080) ```shell $ goconvey ``` * 成功的 ![](https://i.imgur.com/uzCeDbK.png) * 失敗的 ![](https://i.imgur.com/ZqPAkxp.png) 3. 從terminal中可以看到,只要程式有異動就會持續刷新測試結果 ```shell 2023/02/13 15:01:53 integration.go:122: File system state modified, publishing current folders... 10054679803 10054679830 2023/02/13 15:01:53 goconvey.go:159: Received request from watcher to execute tests... 2023/02/13 15:01:54 executor.go:69: Executor status: 'executing' 2023/02/13 15:01:54 coordinator.go:46: Executing concurrent tests: go_traning/unit_test 2023/02/13 15:01:54 coordinator.go:46: Executing concurrent tests: go_traning/unit_test/db 2023/02/13 15:01:54 coordinator.go:46: Executing concurrent tests: go_traning/unit_test/validator 2023/02/13 15:01:55 shell.go:89: Coverage output: ? go_traning/unit_test [no test files] 2023/02/13 15:01:55 shell.go:91: Run without coverage 2023/02/13 15:01:56 parser.go:24: [passed]: go_traning/unit_test/validator 2023/02/13 15:01:56 parser.go:24: [no test files]: go_traning/unit_test 2023/02/13 15:01:56 parser.go:24: [passed]: go_traning/unit_test/db 2023/02/13 15:01:56 executor.go:69: Executor status: 'idle' ``` # Go專案 測試實例 ## 踩雷 ### 小心外部引用的初始化func #### 問題描述 執行測試時,如果被呼叫的那個 func 檔案所在位置的路徑下(包含自身),有 import 內部套件比如DB方法、自訂義logger等等,且當中含有 `init()` 函式;該函式會於測試中被執行,導致測試會因為找不到連線物件或環境變數而執行失敗,**即使被測試的func本身並沒呼叫相關方法也一樣** #### 範例 現在有個 GetExample 非常單純的功能要被測試,但同個檔案內有其他的func會去call DB,所以 **import 了 DB** `internal/odds.go` ```go import ( "fmt" "go_traning/unit_test/db" ) // 要被測試的範例 func GetExample() bool { return true } // 其他不相干的 func func GetOdds(eventInfo EventInfo) (result []Market, err error) { // 從DB取得原始資料 rawOdds, _ := db.GetOddsByEventID(eventInfo.SourceEventID) // 功能邏輯 // ... ``` DB 資料夾下有 `init()` 作用於取環境參數後去進行連線,若取不到服務會 panic `db/init.go` ```go package db import "os" func init() { if os.Getenv("DB_HOST") == "" { panic("缺少環境參數") } // DB連線邏輯 // ... } ``` 幫 GetExample 寫了一個測試案例,內容也很單純 `internal/odds_test.go` ```go func TestGetExample(t *testing.T) { tests := []struct { name string want bool }{ { "true", true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := GetExample(); got != tt.want { t.Errorf("GetExample() = %v, want %v", got, tt.want) } }) } } ``` 執行後...Opps! 怎麼 run 不起來 ```shell panic: 缺少環境參數 goroutine 1 [running]: go_traning/unit_test/db.init.0() /Users/esther_lin/Desktop/Go traning/goUnitTest/db/init.go:7 +0x47 FAIL go_traning/unit_test/internal/odds_before 0.291s FAIL ``` #### 解決辦法 1. 避免使用 `init()`,改為定義好 func 讓 `main.go` 去 call 2. 將功能拆細並收攏於不同的 package 內,減少引用混雜的機會 ## To be continue... ## 參考資料 [TDD (Test-Driven Development) 測試驅動開發(入門篇)](https://medium.com/%E6%88%91%E6%83%B3%E8%A6%81%E8%AE%8A%E5%BC%B7/tdd-test-driven-development-%E6%B8%AC%E8%A9%A6%E9%A9%85%E5%8B%95%E9%96%8B%E7%99%BC-%E5%85%A5%E9%96%80%E7%AF%87-e3f6f15c6651) [30天快速上手TDD系列 第 2 篇](https://ithelp.ithome.com.tw/articles/10102264) [為什麼程式需要單元測試? - 概念篇](https://www.gss.com.tw/blog/why-program-need-unit-test-intro) [[C#][Unit Test] 04. Mock (仿製資料)](https://progressbar.tw/posts/34) [go test命令(Go语言测试命令)完全攻略](http://c.biancheng.net/view/124.html) [A Quick Way to Generate Go Tests in Visual Studio Code](https://betterprogramming.pub/a-quick-way-to-generate-go-tests-in-visual-studio-code-b7c675b88dac) [Golang Unit Test(二)](https://medium.com/skyler-record/golang-unit-test-%E4%BA%8C-ce1e4bb329a5) [Golang Test - 單元測試、Mock與http handler 測試](https://yuanchieh.page/posts/2021/2021-03-18-golang-test/) [Go Mock -gomock- 簡明教程](https://www.readfog.com/a/1641094754324287488) [How to write stronger unit tests with a custom go-mock matcher](https://dev.to/techschoolguru/how-to-write-stronger-unit-tests-with-a-custom-go-mock-matcher-55pc) [Testing with GoMock: A Tutorial](https://gist.github.com/thiagozs/4276432d12c2e5b152ea15b3f8b0012e) [跟煎魚學Go - 1.4 使用 Gomock 进行单元测试](https://eddycjy.gitbook.io/golang/di-1-ke-za-tan/gomock) [SonarQube and code coverage](https://community.sonarsource.com/t/sonargo-code-coverage-0/19473/9) [Go单测从零到溜系列5—goconvey的使用 ](https://www.liwenzhou.com/posts/Go/unit-test-5/)