Try   HackMD

Go 語言單元測試學習筆記

關於測試

列舉幾種測試

單元測試 (Unit Testing)

單元測試是最小的測試單位,所以執行速度快且可靠,通常由開發人員自行撰寫
單元測試的高度涵蓋是自動化測試 (Test Automation) 的核心

準則
  1. 一個測試案例只測一種方法
  2. 最小的測試單位
  3. 不與外部(包括檔案、資料庫、網路、服務、物件、類別)直接相依
  4. 不具備邏輯
  5. 測試案例之間相依性為零
排除外部依賴

單元測試關注的是測試程式本身邏輯,所以必須要把外部依賴(Database、File System IO)全部排除。常會使用Mock Data(假資料)來替代從外部依賴獲取資料的流程

涵蓋率建議
  1. 確保含有大部分重要邏輯的程式有被涵蓋到即可
  2. 需求不確定或經常變動的情況下,寫單元測試的價值可能不高,因為測試也必須經常調整

整合測試 (Integration Testing)

整合測試比單元測試要高一級,是測試兩個以上的模組之間的交互作用符合預期,對模擬環境的完整度有較高要求

回歸測試 (Regression Testing)

回歸測試是指重複執行既有的全部或部分的相同測試,需要根據需求、時程等問題選擇不同的執行策略

測試的開發方法

先開發再測試

測試驅動開發(TDD)

  1. 寫測試:編寫測試,加入test case (此時test會fail)
  2. 寫程式:開始寫code, 目的是要讓 test pass
  3. 優化程式碼:並循環以上步驟refactor 你的code, 但test 還是要pass

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

寫測試的好處

  • 易於維持程式碼品質
  • 確保每次改動或刪除程式碼不會影響到非預期範圍
  • 可以透過測試很快了解某功能,包含呼叫情境,傳入的參數及預期的結果值(文件不一定會是最新的,但測試一定是)
  • 撰寫過程中可幫助程式開發:降低過度耦合、趨於物件導向原則

Go寫單元測試

以下將使用幾個範例講解寫測試的方式和介紹一些套件的使用

testing

使用Go自帶的testing測試框架,配合go test指令來實現測試

簡單範例(參考)

單元測試檔案必須為_test.go結尾、測試 func 須符合 Test... 的規則
這樣下go test指令時才會有效被執行

  • 資料夾結構
validator             // 資料夾名稱
├── validator.go      // 功能所在的檔案
└── validator_test.go // 撰寫針對該檔案的測試
  • 撰寫功能

此功能可用來驗證傳入的字串是否為合法的uuid

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」

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

將會根據該func自動產生測試的檔案以及基礎的程式碼,再自行補上要測試的case後即可使用

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) } }) } }

呼叫 testing.T 的 Error, Errorf, FailNow, Fatal, FatalIf 方法,說明測試不透過
呼叫 Log 方法用來記錄測試的資訊

當然,也可以自行撰寫測試:

Test Func 建議使用有意義的命名,且須符合Test{Xxx...}的規則

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 package


  • 執行測試(所有案例)

go test -v {filePath},沒有加的話就是找當前路徑下的_test檔案
預設會執行檔案內的所有測試案例

$ go test -v ./validator 
=== 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的那兩行)


  • 執行測試(指定執行某個案例,順便故意改成不通過)

go test -v -run {testFuncName} {filePath},其中 {testFuncName} 是為正則表達式

$ go test -v -run TestIsValidUUID_ReturnTrue ./validator
=== 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

因為是正則表達的關係,如果是執行以下指令:

$ go test -v -run TestIsValidUUID_Return ./validator

ReturnTrue 以及 ReturnFalse 兩個func都會被執行
若要完全指定,使用 {testFuncName$} 即可只執行符合該名稱的測試

測試涵蓋率

$ go test -cover
PASS
coverage: 100.0% of statements
ok      go_traning/unit_test/mock/db    0.336s

踩雷分享

  1. 測試時,會掃到同資料夾下的引用,如果裡面有init就會被強制執行(自己的init則不會)
  • 舉例

ex.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

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

func init(){
    loadAppConfig()
}

func loadAppConfig(){
    AppConfig.App.Env = os.Getenv("Env")
    if len(AppConfig.App.Env) == 0{
        panic("無效的環境參數")
    }
}

執行時,會有錯誤

$ 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初始化

Mock / Stubs

做出一個模擬的(假的) Function 或 Object 來取代原本程式邏輯內部相對應的 Function 或 Object,如DB回傳、外部API回傳

  • stub: 驗證目標回傳值,以及驗證目標物件狀態的改變
  • mock:驗證目標物件與外部相依介面的互動方式 ex.驗證是否某func被call的次數符合預期

gomock (Doc)

Go官方提供的套件,可以直接針對檔案產出對應的 mock 檔
但要注意,mock 是針對 interface 產生,檔案中必須要有定義 interface 否則無法使用(對寫法有限制)

簡單範例(參考)

  1. 先定義一個 GetNameByIndex 為 interface 的 Method,作為晚點要被替換掉的 func

get_name.go

package db type DB interface { GetNameByIndex(index int) string } func GetName(db DB, index int) string { return db.GetNameByIndex(index) }
  1. 產生mock檔

如果沒有用過 mockgen 的話,要先安裝:

$ go install github.com/golang/mock/mockgen@v1.6.0

要是套件安裝失敗,可以試試這兩個指令

$ export GOPATH=$HOME/go
$ export PATH=$PATH:$GOROOT/bin:$GOPATH/bin

接著使用指令來產生 mock 檔案

$ mockgen -destination get_name_mock.go -package db -source get_name.go
  • -source:根據這個檔案去生成 mock
  • -destination:要生成 mock 檔的位置,沒寫就直接呈現在terminal裡,不會生成檔案
  • -package:mock 檔的 package 名稱,預設為 mock_ 前綴 source 的 package 名稱

生成的內容會像這樣:

get_name_mock.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) }
  1. 撰寫單元測試

建立 Test func 後,宣告一個 gomock controller

func TestGetName(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish() // 最後要關掉它

}

接著宣告 MockDB 的物件(以下命名為 m),將 ctrl 帶入
再根據 GetNameByIndex 方法去設定 (多個) 帶入的參數以及回傳的數值

EXPECT() 代表期望 GetNameByIndex 傳入相當於 Eq() 內的參數時

  • 回傳 Return 中指定的值
  • DoAndReturn 執行指定的行為後回傳
// _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")

gomock 會假定這個 mock 至少會被調用一次,若無,測試時會報錯「missing call(s)」
欲避免此情況,可以在設定後方加上 .AnyTimes()
相關指令可以參考 Go Mock -gomock- 簡明教程

設定完 mock 後,要把 MockDB 物件帶入要測試的那個 func 裡
因為它有 implement interface 中的 Method (GetNameByIndex),所以能順利取代原本真正要去存取DB的 GetNameByIndex (觀念參考)

Convey(testCase.testName, t, func() {
	name := GetName(m, testCase.arg) // call 要測試的那個 function
	So(name, ShouldEqual, testCase.want)
})

goconvey package

  1. 開測

完整的測試程式碼像這樣:

get_name_test.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) }) } }

執行結果:

$ 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

官方文件流程參考

產生測試報告

go test 方法一 (xml)

  1. 產生覆蓋率文件
$ go test -v ./... -coverprofile=coverage.out

coverage.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
  1. 轉換為sonar可解讀呈現的檔案格式

需安裝 gocov gocov-xml 兩個工具

$ gocov convert cover.out 
$ gocov-xml > report.xml

report.xml

點擊查看完整XML檔案

<?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>
								
							</lines>
						</method>
					</methods>
					<lines>
						
					</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>
								
							</lines>
						</method>
					</methods>
					<lines>
						
					</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>
								
								
								
							</lines>
						</method>
					</methods>
					<lines>
						
						
						
					</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>
								
							</lines>
						</method>
						<method name="GetNameByIndex" signature="" line-rate="1" branch-rate="0" complexity="0" line-count="0" line-hits="0">
							<lines>
								
								
								
								
							</lines>
						</method>
					</methods>
					<lines>
						
						
						
						
						
					</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>
								
								
							</lines>
						</method>
					</methods>
					<lines>
						
						
					</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>
								
								
							</lines>
						</method>
					</methods>
					<lines>
						
						
					</lines>
				</class>
			</classes>
		</package>
	</packages>
	<sources>
		<source>/Users/esther_lin/Desktop/Go traning/goUnitTest</source>
	</sources>
</coverage>
  

go test 方法二 (json)

$ go test "./..." -coverprofile="coverage.out" -covermode=count -json > report.json

report.json

點擊查看完整 json檔案

{"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}

goconvey

適合本地查看測試狀態,需使用該套件寫斷言

  1. 安裝
$ go install github.com/smartystreets/goconvey
  1. 下此指令後,會自動以瀏覽器開啟介面(http://localhost:8080
$ goconvey
  • 成功的
  • 失敗的
  1. 從terminal中可以看到,只要程式有異動就會持續刷新測試結果
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

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

package db

import "os"

func init() {
	if os.Getenv("DB_HOST") == "" {
		panic("缺少環境參數")
	}
    // DB連線邏輯
    // ...
}

幫 GetExample 寫了一個測試案例,內容也很單純

internal/odds_test.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 不起來

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) 測試驅動開發(入門篇)
30天快速上手TDD系列 第 2 篇
為什麼程式需要單元測試? - 概念篇
[C#][Unit Test] 04. Mock (仿製資料)
go test命令(Go语言测试命令)完全攻略
A Quick Way to Generate Go Tests in Visual Studio Code
Golang Unit Test(二)
Golang Test - 單元測試、Mock與http handler 測試
Go Mock -gomock- 簡明教程
How to write stronger unit tests with a custom go-mock matcher
Testing with GoMock: A Tutorial
跟煎魚學Go - 1.4 使用 Gomock 进行单元测试
SonarQube and code coverage
Go单测从零到溜系列5—goconvey的使用