iT邦幫忙

2023 iThome 鐵人賽

0
自我挑戰組

Techschool Goalng Backend Master Class 的學習記錄系列 第 30

[Day 30] How to write stronger unit tests with a custom go-mock matcher

  • 分享至 

  • xImage
  •  

Object

我們將學習如何撰寫自定義的 gomock matcher,以增強我們Golang單元測試的強度。

Weak Unit Test for User Creation API

我們已經學習了如何使用bcrypt安全地儲存用戶的密碼,並且也實現了為我們簡單的銀行應用創建新用戶的API。

單元測試的問題

按照前面所學,如果你嘗試自己為createUser的API寫單元測試,你可能會發現它有點複雜,主要是因為輸入的密碼參數在儲存到數據庫之前會被Hashing

api/user_test.go

func randomUser(t *testing.T) (user db.User, password string) {
	password = util.RandomString(6)
	hashedPassword, err := util.HashPassword(password)
	require.NoError(t, err)

	user = db.User{
		Username:       util.RandomOwner(),
		HashedPassword: hashedPassword,
		FullName:       util.RandomOwner(),
		Email:          util.RandomEmail(),
	}

	// naked return
	return
}

func requireBodyMatchUser(t *testing.T, body *bytes.Buffer, user db.User) {
	data, err := io.ReadAll(body)
	require.NoError(t, err)

	var gotUser db.User
	err = json.Unmarshal(data, &gotUser)
	require.NoError(t, err)
	require.Equal(t, user.Username, gotUser.Username)
	require.Equal(t, user.FullName, gotUser.FullName)
	require.Equal(t, user.Email, gotUser.Email)
	require.Empty(t, gotUser.HashedPassword)
}

func TestCreateUserAPI(t *testing.T) {
	user, password := randomUser(t)

	testCases := []struct {
		name          string
		body          gin.H
		buildStubs    func(store *mockdb.MockStore)
		checkResponse func(recorder *httptest.ResponseRecorder)
	}{
		{
			name: "OK",
			body: gin.H{
				"username":  user.Username,
				"password":  password,
				"full_name": user.FullName,
				"email":     user.Email,
			},
			buildStubs: func(store *mockdb.MockStore) {
				// arg := db.CreateUserParams{
				// 	Username:       user.Username,
				// 	HashedPassword: user.HashedPassword,
				// 	FullName:       user.FullName,
				// 	Email:          user.Email,
				// }
				store.EXPECT().
					CreateUser(gomock.Any(), gomock.Any()).
					Times(1).
					Return(user, nil)
			},
			checkResponse: func(recorder *httptest.ResponseRecorder) {
				require.Equal(t, http.StatusOK, recorder.Code)
				requireBodyMatchUser(t, recorder.Body, user)
			},
		},
		{
			name: "InternalError",
			body: gin.H{
				"username":  user.Username,
				"password":  password,
				"full_name": user.FullName,
				"email":     user.Email,
			},
			buildStubs: func(store *mockdb.MockStore) {
				store.EXPECT().
					CreateUser(gomock.Any(), gomock.Any()).
					Times(1).
					Return(db.User{}, sql.ErrConnDone)
			},
			checkResponse: func(recorder *httptest.ResponseRecorder) {
				require.Equal(t, http.StatusInternalServerError, recorder.Code)
			},
		},
		{
			name: "DuplicateUsername",
			body: gin.H{
				"username":  user.Username,
				"password":  password,
				"full_name": user.FullName,
				"email":     user.Email,
			},
			buildStubs: func(store *mockdb.MockStore) {
				store.EXPECT().
					CreateUser(gomock.Any(), gomock.Any()).
					Times(1).
					Return(db.User{}, &pq.Error{Code: pq.ErrorCode("23505")})
			},
			checkResponse: func(recorder *httptest.ResponseRecorder) {
				require.Equal(t, http.StatusForbidden, recorder.Code)
			},
		},
		{
			name: "InvalidUsername",
			body: gin.H{
				"username":  "invalid-user#name",
				"password":  password,
				"full_name": user.FullName,
				"email":     user.Email,
			},
			buildStubs: func(store *mockdb.MockStore) {
				store.EXPECT().
					CreateUser(gomock.Any(), gomock.Any()).
					Times(0)
			},
			checkResponse: func(recorder *httptest.ResponseRecorder) {
				require.Equal(t, http.StatusBadRequest, recorder.Code)
			},
		},
		{
			name: "InvalidEmail",
			body: gin.H{
				"username":  user.Username,
				"password":  password,
				"full_name": user.FullName,
				"email":     "invalid-Email",
			},
			buildStubs: func(store *mockdb.MockStore) {
				store.EXPECT().
					CreateUser(gomock.Any(), gomock.Any()).
					Times(0)
			},
			checkResponse: func(recorder *httptest.ResponseRecorder) {
				require.Equal(t, http.StatusBadRequest, recorder.Code)
			},
		},
		{
			name: "TooShortPassword",
			body: gin.H{
				"username":  user.Username,
				"password":  "12345",
				"full_name": user.FullName,
				"email":     user.Email,
			},
			buildStubs: func(store *mockdb.MockStore) {
				store.EXPECT().
					CreateUser(gomock.Any(), gomock.Any()).
					Times(0)
			},
			checkResponse: func(recorder *httptest.ResponseRecorder) {
				require.Equal(t, http.StatusBadRequest, recorder.Code)
			},
		},
	}

	for i := range testCases {
		tc := testCases[i]

		t.Run(tc.name, func(t *testing.T) {
			ctrl := gomock.NewController(t)
			defer ctrl.Finish()

			store := mockdb.NewMockStore(ctrl)
			tc.buildStubs(store)

			server := NewServer(store)
			recorder := httptest.NewRecorder()

			data, err := json.Marshal(tc.body)
			require.NoError(t, err)

			url := "/users"
			request, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
			require.NoError(t, err)

			server.router.ServeHTTP(recorder, request)
			tc.checkResponse(recorder)
		})
	}
}

這個測試涵蓋了以下幾種情況:

  • The successful case
  • Internal server error case
  • Duplicate username case
  • Invalid username, email, or password case

The weak unit test

接來下我們將關注在”The successful case",這個測試使用了gomock.Any()作為匹配器,這其實會削弱測試的能力,尤其是在驗證CreateUser函數的參數時。

func TestCreateUserAPI(t *testing.T) {
    user, password := randomUser(t)

    testCases := []struct {
        name          string
        body          gin.H
        buildStubs    func(store *mockdb.MockStore)
        checkResponse func(recoder *httptest.ResponseRecorder)
    }{
        {
            name: "OK",
            body: gin.H{
                "username":  user.Username,
                "password":  password,
                "full_name": user.FullName,
                "email":     user.Email,
            },
            buildStubs: func(store *mockdb.MockStore) {
                store.EXPECT().
                    CreateUser(gomock.Any(), gomock.Any()).
                    Times(1).
                    Return(user, nil)
            },
            checkResponse: func(recorder *httptest.ResponseRecorder) {
                require.Equal(t, http.StatusOK, recorder.Code)
                requireBodyMatchUser(t, recorder.Body, user)
            },
        },
        ...
    }

    ...
}

Example: Empty CreateUserParams

api/user.go

func (server *Server) createUser(ctx *gin.Context) {
    ...

    arg := db.CreateUserParams{}

    user, err := server.store.CreateUser(ctx, arg)
    if err != nil {
        if pqErr, ok := err.(*pq.Error); ok {
            switch pqErr.Code.Name() {
            case "unique_violation":
                ctx.JSON(http.StatusForbidden, errorResponse(err))
                return
            }
        }
        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }

    ...
}
  • 這邊將CreateUserParams 設置為Empty,我們預期在Unit Test會出現錯誤StatusInternalServerError

  • 然後卻能通過測試,這是非常糟糕的,因為handler的實作是完全錯誤的,但是測試卻無法偵測到!

    https://ithelp.ithome.com.tw/upload/images/20231017/20121746ddeM77J7ts.png

Example: Ignoring User Input for Password

func (server *Server) createUser(ctx *gin.Context) {
    ...

    hashedPassword, err := util.HashPassword("xyz")
    if err != nil {
        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }

    arg := db.CreateUserParams{
        Username:       req.Username,
        HashedPassword: hashedPassword,
        FullName:       req.FullName,
        Email:          req.Email,
    }

    user, err := server.store.CreateUser(ctx, arg)
    if err != nil {
        if pqErr, ok := err.(*pq.Error); ok {
            switch pqErr.Code.Name() {
            case "unique_violation":
                ctx.JSON(http.StatusForbidden, errorResponse(err))
                return
            }
        }
        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }

    ...
}
  • 這邊將hashedPassword 設置”xyz” ,來模擬user input過短的密碼,我們預期在Unit Test會出現錯誤,但實際還是通過了測試:

    https://ithelp.ithome.com.tw/upload/images/20231017/20121746ejuPNlaZmn.png

使用gomock.Eq解決單元測試問題

初始設定和哈希密碼

  • 在單元測試的開頭,生成一個隨機的用戶和密碼。
  • 使用**util.HashPassword**函數對生成的密碼進行哈希。
  • 確保沒有錯誤,使用**require.NoError()**來驗證。
goCopy code
user, password := randomUser(t)
hashedPassword, err := util.HashPassword(password)
require.NoError(t, err)

使用gomock.Eq() Matcher

  • buildStubs函數中,建立一個新的arg變量,其類型為db.CreateUserParams
  • 使用gomock.Eq(arg)來替換gomock.Any(),以便精確地匹配參數。
api/user_test.go
func TestCreateUserAPI(t *testing.T) {
    user, password := randomUser(t)

    hashedPassword, err := util.HashPassword(password)
    require.NoError(t, err)

    testCases := []struct {
        name          string
        body          gin.H
        buildStubs    func(store *mockdb.MockStore)
        checkResponse func(recoder *httptest.ResponseRecorder)
    }{
        {
            name: "OK",
            body: gin.H{
                "username":  user.Username,
                "password":  password,
                "full_name": user.FullName,
                "email":     user.Email,
            },
            buildStubs: func(store *mockdb.MockStore) {
                arg := db.CreateUserParams{
                    Username: user.Username,
                    HashedPassword: hashedPassword,
                    FullName: user.FullName,
                    Email: user.Email,
                }
                store.EXPECT().
                    CreateUser(gomock.Any(), gomock.Eq(arg)).
                    Times(1).
                    Return(user, nil)
            },
            checkResponse: func(recorder *httptest.ResponseRecorder) {
                require.Equal(t, http.StatusOK, recorder.Code)
                requireBodyMatchUser(t, recorder.Body, user)
            },
        },
        ...
    }

    ...
}

測試Empty CreateUserParams

api/user.go
func (server *Server) createUser(ctx *gin.Context) {
    ...

    arg := db.CreateUserParams{}

    user, err := server.store.CreateUser(ctx, arg)
    if err != nil {
        if pqErr, ok := err.(*pq.Error); ok {
            switch pqErr.Code.Name() {
            case "unique_violation":
                ctx.JSON(http.StatusForbidden, errorResponse(err))
                return
            }
        }
        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }

    ...
}
  • 如果輸入參數為empty,由於使用了更強大的Eq()匹配器,測試應該會失敗。
  • 確實,測試失敗了,日誌告訴我們它是由於缺少調用而失敗的。

https://ithelp.ithome.com.tw/upload/images/20231017/20121746DSYI7RNFVs.png

bcrypt的Random Salt

api/user.go
func (server *Server) createUser(ctx *gin.Context) {
    ...

    arg := db.CreateUserParams{
        Username:       req.Username,
        HashedPassword: hashedPassword,
        FullName:       req.FullName,
        Email:          req.Email,
    }

    user, err := server.store.CreateUser(ctx, arg)
    if err != nil {
        if pqErr, ok := err.(*pq.Error); ok {
            switch pqErr.Code.Name() {
            case "unique_violation":
                ctx.JSON(http.StatusForbidden, errorResponse(err))
                return
            }
        }
        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }

    ...
}
  • 然而,即使createUser處理程序正確地接收所有輸入參數,測試仍然會失敗。

    https://ithelp.ithome.com.tw/upload/images/20231017/20121746PorEqBQrMo.png

  • 原因是bcrypt使用隨機鹽,所以即使密碼相同,每次哈希也會不同。

  • 因此,我們不能僅使用內置的gomock.Eq()匹配器來比較參數。

自定義匹配器的需要

  • 為了正確地處理這個問題,我們需要實現一個自定義的匹配器。
  • 雖然這聽起來有點麻煩,但實際上很容易實現,對於你在實際項目中遇到的特殊情況可能會很有用。

這樣,你就可以更準確地測試是否正確地處理了所有輸入參數和哈希密碼。

Implement a custom gomock matcher

Matcher 接口和現有實現

  • gomock 提供的 Matcher 接口有兩個方法:Matches()String()

    func Eq(x interface{}) Matcher { return eqMatcher{x} }
    
    type Matcher interface {
        // Matches returns whether x is a match.
        Matches(x interface{}) bool
    
        // String describes what the matcher matches.
        String() string
    }
    
    • Matches() 判斷輸入 x 是否匹配。
    • String() 用於日誌,描述匹配器的功能。
  • anyMatcher

    type anyMatcher struct{}
    
    func (anyMatcher) Matches(interface{}) bool {
    	return true
    }
    
    func (anyMatcher) String() string {
    	return "is anything"
    }
    
  • eqMatcher

    • It uses reflect.DeepEqual to compare the actual input argument with the expected one.
    type eqMatcher struct {
    	x interface{}
    }
    
    func (e eqMatcher) Matches(x interface{}) bool {
    	return reflect.DeepEqual(e.x, x)
    }
    
    func (e eqMatcher) String() string {
    	return fmt.Sprintf("is equal to %v", e.x)
    }
    

自定義匹配器結構

  • api/user_test.go創建一個名為 eqCreateUserParamsMatcher 的結構。
  • 結構中有兩個字段:arg(類型為 db.CreateUserParams)和 password
api/user_test.go

type eqCreateUserParamsMatcher struct {
    arg      db.CreateUserParams
    password string
}

實現Matches()方法

  • Matches() 方法中,將 x 轉換為 db.CreateUserParams 對象。
    • arg, ok := x.(db.CreateUserParams) 是一種稱為「型別斷言」(Type Assertion)的 Go 語言特性。
    • 它嘗試將 x 轉換成 db.CreateUserParams 型別。
    • 如果轉換成功,ok 會被設為 true,並且 arg 會存儲轉換後的值。
    • 如果轉換失敗,ok 會被設為 false,並且 arg 會是 db.CreateUserParams 型別的零值。
    • 這種做法通常用於處理不確定輸入類型的情況,在這個例子中,Matches 函數的參數 x 是**interface{}**類型,這意味著它可以是任何型別。透過型別斷言,我們可以安全地將它轉換為我們需要的特定型別(在這個例子中為 db.CreateUserParams),然後進行後續的操作。
  • 使用 util.CheckPassword() 函數來檢查哈希密碼是否與預期的密碼匹配。
  • eqCreateUserParamsMatcher 結構體中,e.arg.HashedPassword = arg.HashedPassword 這行程式碼做的事情是將從 Matches 函數的參數 x 轉型得到的 db.CreateUserParams 型別的 argHashedPassword 欄位值設定給 e.arg.HashedPassword
  • 簡單來說,它是在同步或更新 e.arg(也就是 eqCreateUserParamsMatcher 結構體中的 arg 欄位)的 HashedPassword 值,使其與從函數參數得到的 argHashedPassword 值相同。
  • 這麼做的目的通常是為了後續的比較或驗證。在這個特定的例子中,它是為了讓 reflect.DeepEqual(e.arg, arg) 這個比較能夠正確地執行。因為在這之前已經使用 util.CheckPassword(e.password, arg.HashedPassword) 驗證了密碼,所以現在需要確保兩個 db.CreateUserParams 物件在其他所有欄位(包括 HashedPassword)上也是相同的。
func (e eqCreateUserParamsMatcher) Matches(x interface{}) bool {
    arg, ok := x.(db.CreateUserParams)
    if !ok {
        return false
    }

    err := util.CheckPassword(e.password, arg.HashedPassword)
    if err != nil {
        return false
    }

    e.arg.HashedPassword = arg.HashedPassword
    return reflect.DeepEqual(e.arg, arg)
}

實現String()方法

  • 更新 String() 方法以包含預期的參數和明文密碼值。
func (e eqCreateUserParamsMatcher) String() string {
    return fmt.Sprintf("matches arg %v and password %v", e.arg, e.password)
}

Factory Function : EqCreateUserParams

func EqCreateUserParams(arg db.CreateUserParams, password string) gomock.Matcher {
    return eqCreateUserParamsMatcher{arg, password}
}

  • 簡化 eqCreateUserParamsMatcher 結構體的創建過程,並且返回一個實現了 gomock.Matcher 介面的物件。這樣做有幾個好處:

    1. 封裝詳細信息:使用這個Factory Function可以隱藏 eqCreateUserParamsMatcher 的內部結構,使得外部代碼不需要直接與它互動,只需要通過這個函式即可。
    2. 簡化使用:這個工廠函式使得創建一個新的 Matcher 變得更簡單和直觀。你只需要提供必要的參數(在這個案例中是 argpassword),然後函式會為你做剩下的事。
    3. 提高可讀性和維護性:使用有意義的函式名(如 EqCreateUserParams)會使代碼更容易理解和維護。
    4. 彈性和擴展性:如果未來 eqCreateUserParamsMatcher 的內部實現改變了,你只需要更新 EqCreateUserParams 函式內部的實現,而不需要改變使用這個 Matcher 的所有地方。
  • 創建一個名為 EqCreateUserParams() 的函數,它接受一個 db.CreateUserParams 對象和一個明文密碼字符串。

  • 函數返回一個 Matcher 接口實例。

  • 如果沒有 EqCreateUserParams 這個工廠函式,你仍然可以手動創建 eqCreateUserParamsMatcher 結構的實例。具體來說,你會需要做類似下面這樣的操作:

    matcher := eqCreateUserParamsMatcher{
        arg: db.CreateUserParams{
            Username: "exampleUsername",
            // 其他字段
        },
        password: "examplePassword",
    }
    
    

    然後在 gomockEXPECT() 語句中使用這個 matcher

    store.EXPECT().
        CreateUser(gomock.Any(), matcher).
        Times(1).
        Return(user, nil)
    
    

    這樣做雖然可行,但會使代碼變得更冗長和難以維護。使用工廠函式可以將這些細節封裝起來,使得代碼更簡潔、易讀和易於維護。

在單元測試中使用自定義匹配器

  • 在單元測試 TestCreateUserAPI 中,使用 EqCreateUserParams() 替換 gomock.Eq()
func TestCreateUserAPI(t *testing.T) {
    user, password := randomUser(t)

    testCases := []struct {
        name          string
        body          gin.H
        buildStubs    func(store *mockdb.MockStore)
        checkResponse func(recoder *httptest.ResponseRecorder)
    }{
        {
            name: "OK",
            body: gin.H{
                "username":  user.Username,
                "password":  password,
                "full_name": user.FullName,
                "email":     user.Email,
            },
            buildStubs: func(store *mockdb.MockStore) {
                arg := db.CreateUserParams{
                    Username: user.Username,
                    FullName: user.FullName,
                    Email:    user.Email,
                }
                store.EXPECT().
                    CreateUser(gomock.Any(), EqCreateUserParams(arg, password)).
                    Times(1).
                    Return(user, nil)
            },
            checkResponse: func(recorder *httptest.ResponseRecorder) {
                require.Equal(t, http.StatusOK, recorder.Code)
                requireBodyMatchUser(t, recorder.Body, user)
            },
        },
        ...
    }

    ...
}

測試結果

  • Empty CreateUserParams測試通過,證明自定義匹配器的實現是正確的。

    func (server *Server) createUser(ctx *gin.Context) {
        ...
    
        arg := db.CreateUserParams{}
    
        user, err := server.store.CreateUser(ctx, arg)
        if err != nil {
            if pqErr, ok := err.(*pq.Error); ok {
                switch pqErr.Code.Name() {
                case "unique_violation":
                    ctx.JSON(http.StatusForbidden, errorResponse(err))
                    return
                }
            }
            ctx.JSON(http.StatusInternalServerError, errorResponse(err))
            return
        }
    
        ...
    }
    

    https://ithelp.ithome.com.tw/upload/images/20231017/20121746FuXarjnH3I.png

  • 當輸入參數不匹配預期值時,測試會失敗,從而使單元測試更強大。

    func (server *Server) createUser(ctx *gin.Context) {
        ...
    
        hashedPassword, err := util.HashPassword("xyz")
        if err != nil {
            ctx.JSON(http.StatusInternalServerError, errorResponse(err))
            return
        }
    
        ...
    }
    

    https://ithelp.ithome.com.tw/upload/images/20231017/20121746LtP0T1Kkaf.png


上一篇
[Day 29] How to securely store passwords?
下一篇
[Day 31] Why PASETO is better than JWT for token-based authentication?
系列文
Techschool Goalng Backend Master Class 的學習記錄31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言