宣告測試案例
結構定義
buildStubs)和檢查API回應的函式(checkResponse)。func TestGetAccountAPI(t *testing.T) {
account := randomAccount()
testCases := []struct {
name string
accountID int64
buildStubs func(store *mockdb.MockStore)
checkResponse func(t *testing.T, recoder *httptest.ResponseRecorder)
}{
// TODO: add test data
}
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()
url := fmt.Sprintf("/accounts/%d", tc.accountID)
request, err := http.NewRequest(http.MethodGet, url, nil)
require.NoError(t, err)
server.router.ServeHTTP(recorder, request)
tc.checkResponse(t, recorder)
})
}
}
成功的情境
http.StatusOK。func TestGetAccountAPI(t *testing.T) {
account := randomAccount()
testCases := []struct {
name string
accountID int64
buildStubs func(store *mockdb.MockStore)
checkResponse func(t *testing.T, recoder *httptest.ResponseRecorder)
}{
{
name: "OK",
accountID: account.ID,
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
GetAccount(gomock.Any(), gomock.Eq(account.ID)).
Times(1).
Return(account, nil)
},
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusOK, recorder.Code)
requireBodyMatchAccount(t, recorder.Body, account)
},
},
}
...
}
賬戶未找到的情境
http.StatusNotFound。sql.ErrNoRows表示賬戶不存在。NotFound,所以也不需要requireBodyMatchAccount來驗證Bodyfunc TestGetAccountAPI(t *testing.T) {
account := randomAccount()
testCases := []struct {
name string
accountID int64
buildStubs func(store *mockdb.MockStore)
checkResponse func(t *testing.T, recoder *httptest.ResponseRecorder)
}{
...
{
name: "NotFound",
accountID: account.ID,
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
GetAccount(gomock.Any(), gomock.Eq(account.ID)).
Times(1).
Return(db.Account{}, sql.ErrNoRows)
},
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusNotFound, recorder.Code)
},
},
}
...
}
伺服器內部錯誤情境
http.StatusInternalServerError。sql.ErrConnDone,表示一個已結束的連接錯誤。func TestGetAccountAPI(t *testing.T) {
account := randomAccount()
testCases := []struct {
name string
accountID int64
buildStubs func(store *mockdb.MockStore)
checkResponse func(t *testing.T, recoder *httptest.ResponseRecorder)
}{
...
{
name: "InternalError",
accountID: account.ID,
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
GetAccount(gomock.Any(), gomock.Eq(account.ID)).
Times(1).
Return(db.Account{}, sql.ErrConnDone)
},
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusInternalServerError, recorder.Code)
},
},
}
...
}
無效請求情境
http.StatusBadRequest。func TestGetAccountAPI(t *testing.T) {
account := randomAccount()
testCases := []struct {
name string
accountID int64
buildStubs func(store *mockdb.MockStore)
checkResponse func(t *testing.T, recoder *httptest.ResponseRecorder)
}{
...
{
name: "InvalidID",
accountID: 0,
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
GetAccount(gomock.Any(), gomock.Any()).
Times(0)
},
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusBadRequest, recorder.Code)
},
},
}
...
}
整體測試覆蓋率
減少多餘的日誌輸出
main_test.go中設置Gin為gin.TestMode以減少不必要的日誌。api/main_test.go
func TestMain(m *testing.M) {
gin.SetMode(gin.TestMode)
os.Exit(m.Run())
}
checkResponse func(t *testing.T, recoder httptest.ResponseRecorder) 裡面的**httptest.ResponseRecorder 指就是respone body 和status code嗎 ?
httptest.ResponseRecorder 是一個特殊的 http.ResponseWriter。它的主要目的是在單元測試中記錄 HTTP 回應,以便稍後檢查和驗證。當你使用它來"記錄" HTTP 回應時,它會保存以下內容:
StatusCode):例如 200 (OK)、404 (NotFound) 等。HeaderMap):這是一個映射,保存了回應的所有 HTTP 頭。Body):一個保存了 HTTP 回應主體的緩衝區。recorder.Body 是一個包含 HTTP 回應主體的 *bytes.Buffer,而 recorder.Code 則是 HTTP 的狀態碼。t. Run的作用是什麼? 為何需要帶入tc.name ?
t.Run 是 Go 的 testing 套件中的一個方法,用於執行子測試。t.Run 的第一個參數是子測試的名稱,這裡使用的是 tc.name。t.Run 和表格驅動測試可以使測試代碼更有組織、更易於閱讀。func TestCreateAccountAPI(t *testing.T) {
account := randomAccount()
testCases := []struct {
name string
body gin.H
buildStubs func(store *mockdb.MockStore)
checkResponse func(t *testing.T, recoder *httptest.ResponseRecorder)
}{
{
name: "OK",
body: gin.H{
"owner": account.Owner,
"currency": account.Currency,
},
buildStubs: func(store *mockdb.MockStore) {
arg := db.CreateAccountParams{
Owner: account.Owner,
Balance: 0,
Currency: account.Currency,
}
store.EXPECT().
CreateAccount(gomock.Any(), gomock.Eq(arg)).
Times(1).
Return(account, nil)
},
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusOK, recorder.Code)
requireBodyMatchAccount(t, recorder.Body, account)
},
},
{
name: "InternalError",
body: gin.H{
"owner": account.Owner,
"currency": account.Currency,
},
buildStubs: func(store *mockdb.MockStore) {
arg := db.CreateAccountParams{
Owner: account.Owner,
Balance: 0,
Currency: account.Currency,
}
store.EXPECT().
CreateAccount(gomock.Any(), gomock.Eq(arg)).
Times(1).
Return(db.Account{}, sql.ErrConnDone)
},
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusInternalServerError, recorder.Code)
},
},
{
name: "InvalidCurrency",
body: gin.H{
"owner": account.Owner,
"currency": "invalid",
},
buildStubs: func(store *mockdb.MockStore) {
arg := db.CreateAccountParams{
Owner: account.Owner,
Balance: 0,
Currency: "invalid",
}
store.EXPECT().
CreateAccount(gomock.Any(), gomock.Eq(arg)).
Times(0)
},
checkResponse: func(t *testing.T, 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()
// Marshal body data to JSON
data, err := json.Marshal(tc.body)
require.NoError(t, err)
url := "/accounts"
request, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
require.NoError(t, err)
server.router.ServeHTTP(recorder, request)
tc.checkResponse(t, recorder)
})
}
}
TestCreateAccountAPI 的函數,它接受一個測試物件 t 作為參數。randomAccount 函數創建一個隨機賬戶,用來為賬戶欄位生成隨機值。name: 字串,表示測試案例的名稱。body: 一個 map,表示要在 HTTP 請求中發送的請求主體。buildStubs: 一個函數,用來為 mock store 建立 stubs(模擬預期)。checkResponse: 一個函數,用來檢查從 HTTP 請求收到的回應。t.Run 創建一個新的子測試,其中 tc.name 用作子測試的名稱。buildStubs 函數來設置模擬預期。checkResponse 函數來檢查回應。CreateAccount 方法被調用一次,帶有特定的參數,並返回一個有效的賬戶和無錯誤。CreateAccount 方法被調用一次,帶有特定的參數,並返回內部服務器錯誤(sql.ErrConnDone)。CreateAccount 方法,因為貨幣是無效的。func TestListAccountAPI(t *testing.T) {
n := 5
accounts := make([]db.Account, n)
for i := range accounts {
accounts[i] = randomAccount()
}
type Query struct {
pageID int32
pageSize int32
}
testCases := []struct {
name string
query Query
buildStubs func(store *mockdb.MockStore)
checkResponse func(t *testing.T, recoder *httptest.ResponseRecorder)
}{
{
name: "OK",
query: Query{
pageID: 1,
pageSize: int32(n),
},
buildStubs: func(store *mockdb.MockStore) {
arg := db.ListAccountsParams{
Limit: int32(n),
Offset: 0,
}
store.EXPECT().
ListAccounts(gomock.Any(), gomock.Eq(arg)).
Times(1).
Return(accounts, nil)
},
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusOK, recorder.Code)
requireBodyMatchAccounts(t, recorder.Body, accounts)
},
},
{
name: "InternalError",
query: Query{
pageID: 1,
pageSize: int32(n),
},
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
ListAccounts(gomock.Any(), gomock.Any()).
Times(1).
Return([]db.Account{}, sql.ErrConnDone)
},
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusInternalServerError, recorder.Code)
},
},
{
name: "InvalidPageID",
query: Query{
pageID: -1,
pageSize: int32(n),
},
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
ListAccounts(gomock.Any(), gomock.Any()).
Times(0)
},
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusBadRequest, recorder.Code)
},
},
{
name: "InvalidPageSize",
query: Query{
pageID: 1,
pageSize: 100000,
},
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
ListAccounts(gomock.Any(), gomock.Any()).
Times(0)
},
checkResponse: func(t *testing.T, 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()
url := fmt.Sprintf("/accounts?page_id=%d&page_size=%d", tc.query.pageID, tc.query.pageSize)
request, err := http.NewRequest(http.MethodGet, url, nil)
require.NoError(t, err)
server.router.ServeHTTP(recorder, request)
tc.checkResponse(t, recorder)
})
}
}
TestListAccountAPI 測試函數,並創建一個包含5個隨機賬戶的列表。Query 的結構,其中包含 pageID 和 pageSize 作為查詢參數。t.Run 創建一個新的子測試,並使用 tc.name 作為子測試的名稱。buildStubs 函數來設定 mock store 的期望行為。checkResponse 函數來檢查 HTTP 回應。ListAccounts 方法將被呼叫一次,並返回5個賬戶和無錯誤。ListAccounts 方法將被呼叫一次,但返回內部服務器錯誤(sql.ErrConnDone)。ListAccounts 方法被調用。ListAccounts 方法被調用。prog.go:12:2: missing go.sum entry for module providing package github.com/golang/mock/mockgen/model; to add:
go mod download github.com/golang/mock
SA1019: "io/ioutil" has been deprecated since Go 1.19: As of Go 1.16, the same functionality is now provided by package [io] or package [os], and those implementations should be preferred in new code. See the specific function documentation for details. (staticcheck)
func requireBodyMatchAccount(t *testing.T, body *bytes.Buffer, account db.Account) {
data, err := io.ReadAll(body) // modify ioutil to io
require.NoError(t, err)
...
}