宣告測試案例
結構定義
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)
...
}