iT邦幫忙

2023 iThome 鐵人賽

DAY 23
0
自我挑戰組

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

[Day 25] Mock DB for testing HTTP API in Go and achieve 100% coverage Part 4

  • 分享至 

  • xImage
  •  

Achieve 100% coverage

  • 宣告測試案例

    • 使用匿名類別來存放測試數據。
    • 每個測試案例都應該有一個唯一的名稱。
  • 結構定義

    • 每個測試案例包含名稱、賬戶ID、建立模擬數據的函式(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回應狀態碼為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回應狀態碼為http.StatusNotFound
    • 模擬資料庫回應sql.ErrNoRows表示賬戶不存在。
    • 因為NotFound,所以也不需要requireBodyMatchAccount來驗證Body
    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:      "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回應狀態碼為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回應狀態碼為http.StatusBadRequest
    • 賬戶ID設定為0,模擬一個無效的賬戶ID請求。
    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)
                },
            },
        }
    
        ...
    }
    
  • 整體測試覆蓋率

    • 進行整套測試確保所有情境都被涵蓋,並達到100%的代碼覆蓋率。
  • 減少多餘的日誌輸出

    • Gin框架預設為Debug模式,會產生許多冗餘的日誌。
    • main_test.go中設置Gin為gin.TestMode以減少不必要的日誌。
    api/main_test.go
    func TestMain(m *testing.M) {
        gin.SetMode(gin.TestMode)
        os.Exit(m.Run())
    }
    

Q & A:

  1. 其中 checkResponse func(t *testing.T, recoder httptest.ResponseRecorder) 裡面的**httptest.ResponseRecorder 指就是respone bodystatus code嗎 ?
    1. 是的,httptest.ResponseRecorder 是一個特殊的 http.ResponseWriter。它的主要目的是在單元測試中記錄 HTTP 回應,以便稍後檢查和驗證。當你使用它來"記錄" HTTP 回應時,它會保存以下內容:
      1. 狀態碼 (StatusCode):例如 200 (OK)、404 (NotFound) 等。
      2. 回應頭 (HeaderMap):這是一個映射,保存了回應的所有 HTTP 頭。
      3. 回應主體 (Body):一個保存了 HTTP 回應主體的緩衝區。
    2. 所以在你的測試中,recorder.Body 是一個包含 HTTP 回應主體的 *bytes.Buffer,而 recorder.Code 則是 HTTP 的狀態碼。
  2. t. Run的作用是什麼? 為何需要帶入tc.name ?
    1. 功能
      1. t.Run 是 Go 的 testing 套件中的一個方法,用於執行子測試。
    2. 使用場景
      1. 主要用於組織和執行表格驅動測試 (table-driven tests)。
    3. 表格驅動測試
      1. 這是一種常見的測試模式,允許你使用一個資料表(如結構體切片)來定義多個測試案例。
      2. 通過迭代這些案例,可以依次執行每個測試。
    4. 子測試名稱
      1. t.Run 的第一個參數是子測試的名稱,這裡使用的是 tc.name
      2. 子測試的名稱在測試報告中必須是唯一的。
      3. 這樣做的目的是在測試結果中能夠快速識別和定位特定的子測試。
    5. 優點
      1. 使用 t.Run 和表格驅動測試可以使測試代碼更有組織、更易於閱讀。
      2. 方便你輕鬆地添加、修改或刪除測試案例。

Write unit test for Create Account API

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)
		})
	}
}
  • Code Flow:
    1. 初始化:
      • 定義一個名為 TestCreateAccountAPI 的函數,它接受一個測試物件 t 作為參數。
      • 利用 randomAccount 函數創建一個隨機賬戶,用來為賬戶欄位生成隨機值。
    2. 定義測試案例:
      • 創建一個測試案例結構的切片,每個結構包含:
        • name: 字串,表示測試案例的名稱。
        • body: 一個 map,表示要在 HTTP 請求中發送的請求主體。
        • buildStubs: 一個函數,用來為 mock store 建立 stubs(模擬預期)。
        • checkResponse: 一個函數,用來檢查從 HTTP 請求收到的回應。
    3. 遍歷測試案例:
      • 使用迴圈遍歷每個測試案例。
    4. 執行每個測試案例:
      • 在迴圈內,對於每個測試案例:
        • 使用 t.Run 創建一個新的子測試,其中 tc.name 用作子測試的名稱。
        • 創建一個新的 mock 控制器並初始化 mock store。
        • 調用當前測試案例的 buildStubs 函數來設置模擬預期。
        • 使用 mock store 創建一個新的伺服器。
        • 創建一個 HTTP 記錄器來記錄 HTTP 回應。
        • 將請求主體編碼為 JSON 並創建一個 HTTP 請求。
        • 伺服器處理 HTTP 請求,並記錄回應。
        • 調用當前測試案例的 checkResponse 函數來檢查回應。
  • Test Cases:
    1. OK 案例:
      • 名稱: OK
      • 主體: 包含隨機賬戶的有效擁有者和貨幣欄位。
      • Stub: 預期 CreateAccount 方法被調用一次,帶有特定的參數,並返回一個有效的賬戶和無錯誤。
      • 檢查回應: 檢查 HTTP 狀態碼為 200 (OK) 並且回應主體與賬戶詳細信息相匹配。
    2. 內部錯誤案例:
      • 名稱: InternalError
      • 主體: 包含隨機賬戶的有效擁有者和貨幣欄位。
      • Stub: 預期 CreateAccount 方法被調用一次,帶有特定的參數,並返回內部服務器錯誤(sql.ErrConnDone)。
      • 檢查回應: 檢查 HTTP 狀態碼為 500(內部服務器錯誤)。
    3. 無效貨幣案例:
      • 名稱: InvalidCurrency
      • 主體: 包含隨機賬戶的有效擁有者欄位,但貨幣欄位無效。
      • Stub: 不預期調用 CreateAccount 方法,因為貨幣是無效的。
      • 檢查回應: 檢查 HTTP 狀態碼為 400(請求錯誤)。

Write unit test for List Account API

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)
		})
	}
}
  • Code Flow:
    1. 函數初始化:
      • 定義一個 TestListAccountAPI 測試函數,並創建一個包含5個隨機賬戶的列表。
    2. 定義查詢結構和測試案例:
      • 定義一個名為 Query 的結構,其中包含 pageIDpageSize 作為查詢參數。
      • 定義一系列測試案例,每個案例包含名稱、查詢條件、建立模擬存儲期望的函數和檢查回應的函數。
    3. 遍歷測試案例:
      • 透過循環遍歷每一個測試案例。
    4. 執行每個測試案例:
      • 在循環中,針對每一個測試案例:
        • 使用 t.Run 創建一個新的子測試,並使用 tc.name 作為子測試的名稱。
        • 創建一個新的 mock 控制器和 mock store。
        • 調用當前測試案例的 buildStubs 函數來設定 mock store 的期望行為。
        • 創建一個新的伺服器和 HTTP 記錄器。
        • 根據測試案例的查詢條件創建一個新的 HTTP 請求。
        • 使伺服器處理 HTTP 請求並記錄回應。
        • 調用 checkResponse 函數來檢查 HTTP 回應。
  • Test Cases:
    1. OK 案例:
      • 名稱: OK
      • 查詢: 第1頁,每頁有5個賬戶。
      • Stub: 設定期望 ListAccounts 方法將被呼叫一次,並返回5個賬戶和無錯誤。
      • 檢查回應: 確認 HTTP 狀態碼是 200(OK),並且回應的賬戶列表與預期相符。
    2. 內部錯誤案例:
      • 名稱: InternalError
      • 查詢: 第1頁,每頁有5個賬戶。
      • Stub: 設定期望 ListAccounts 方法將被呼叫一次,但返回內部服務器錯誤(sql.ErrConnDone)。
      • 檢查回應: 確認 HTTP 狀態碼是 500(內部服務器錯誤)。
    3. 無效頁碼案例:
      • 名稱: InvalidPageID
      • 查詢: 第-1頁(無效),每頁有5個賬戶。
      • Stub: 由於頁碼是無效的,所以不期望 ListAccounts 方法被調用。
      • 檢查回應: 確認 HTTP 狀態碼是 400(請求錯誤)。
    4. 無效頁面大小案例:
      • 名稱: InvalidPageSize
      • 查詢: 第1頁,每頁有100000個賬戶(無效的大小)。
      • Stub: 由於頁面大小是無效的,所以不期望 ListAccounts 方法被調用。
      • 檢查回應: 確認 HTTP 狀態碼是 400(請求錯誤)。

Issues:


上一篇
[Day 24] Mock DB for testing HTTP API in Go and achieve 100% coverage Part 3
下一篇
[Day 26] Implement transfer money API with a custom params validator in Go
系列文
Techschool Goalng Backend Master Class 的學習記錄31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言