iT邦幫忙

2023 iThome 鐵人賽

DAY 6
0
自我挑戰組

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

[Day 06] Write Unit Testing for Database (postgresSQL) CRUD

  • 分享至 

  • xImage
  •  

Go testing Rule

  • Golang 寫測試程式時,只需在程式名稱後面加上 _test並與程式放在同一個folder :
    例如程式名稱叫 account.go 只要再加上一隻 account_test.go

    https://ithelp.ithome.com.tw/upload/images/20230921/201217466URu0GjtaR.png

  • Package的名稱需要與被測試的程式碼相同

account.sql.go

package db

import (
	"context"
)

const addAccountBalance = `-- name: AddAccountBalance :one
UPDATE accounts
SET balance = balance + $1
WHERE id = $2
RETURNING id, owner, balance, currency, created_at
`

...
account_test.go

import (
	"context"
	"database/sql"
	"testing"
	"time"

	"github.com/stretchr/testify/require"
	"github.com/techschool/simplebank/util"
)
...
  • 測試的Function Name 使用 Test 為開頭(測試時才會被呼叫),並且使用camel case
  • Function need to use testing.T object as input
func TestCreateAccount(t *testing.T) {
	createRandomAccount(t)
}

Setup the connection to PostgresSQL

測試Database CRUD需要實際操作PostgresSQL,所以需要在測試時建立連線

  • main_test.go

    • _ “ : import package to init but not use package function
    • TestMain 是一個特殊的函數,它允許你在運行測試之前和之後進行自訂的設定和清理。TestMain 函數在任何測試函數之前運行,且只運行一次。
    • 在多個test.go的檔案中只能有一個**TestMain**
    • 這裡TestMain 提供了一個設定和初始化Database環境的機會
    package db
    
    import (
    	"database/sql"
    	"log"
    	"os"
    	"testing"
    
    	_ "github.com/lib/pq"
    )
    
    const (
    	// 定義資料庫的驅動名稱和資料源字符串
    	dbDriver = "postgres"
    	dbSource = "postgresql://root:secret@localhost:5432/simple_bank?sslmode=disable"
    )
    
    // testQueries 是我們在測試中將使用的 *Queries 實例
    var testQueries *Queries
    
    // TestMain 是測試的主入口點。
    // 它提供了一個設定和初始化database環境的機會。
    func TestMain(m *testing.M) {
    	// 嘗試連接到指定的資料庫
    	conn, err := sql.Open(dbDriver, dbSource)
    	if err != nil {
    		// 如果連接失敗,將錯誤記錄到日誌並退出
    		log.Fatal("cannot connect to db: ", err)
    	}
    
    	// 使用已建立的連接初始化 testQueries
    	testQueries = New(conn)
    
    	// m.Run() 會執行所有定義的測試函數。
    	// 然後,它返回一個代表測試結果的狀態碼。
    	// os.Exit() 用該狀態碼終止進程。
    	os.Exit(m.Run())
    }
    

Q & A

  1. 但執行go test時main_test.go 會使用db.go到嗎?
    1. TestMain 函數會被呼叫。
    2. 它會使用 db.goNew 函數來建立一個新的 Queries 實例,這實際上是使用 db.go 中的功能。
  2. 我以為執行go test時,只會執行帶有_test.go的檔案
    1. 當你執行 go test 時,Go 測試工具會識別並執行所有以 _test.go 結尾的文件中的測試函數。但這只是關於哪些函數會被視為測試函數並執行的規則。這不代表這些測試函數不能或不會與其他非 _test.go 的代碼文件互動。
    2. 換句話說,_test.go 文件中的測試函數可以調用、互動並測試同一套件下的其他任何代碼,無論這些代碼是否在 _test.go 文件中。
  3. 為何我沒有使用os.Exit也能正常運作呢?
    1. 使用 os.Exit(m.Run()) 的目的是返回適當的退出碼以基於測試的結果。如果任何測試失敗,m.Run() 將返回非零的狀態碼,這意味著測試套件沒有成功通過。如果所有測試都成功,則返回0。
    2. 如果您沒有使用 os.Exit,測試仍然會執行,但不會根據測試結果返回特定的退出碼。對於大多數情況,這可能沒問題,特別是如果您僅在本地運行測試並查看結果。但在 CI/CD 系統或其他需要關注測試結果狀態碼的情況下,正確的退出碼很重要。
    3. 總結:當不使用 os.Exit 時,測試仍然運行,但測試結果的退出碼可能無法正確傳遞出去。
  4. m *testing.M 是什麼呢?
    1. 提供了一個環境,讓你可以在測試開始前後執行一些初始化或清理的工作,如設置資料庫連接、啟動或關閉服務等。當你執行 m.Run() 時,Go 會開始執行所有名稱前綴為 Test 的測試函數,且每個這樣的函數都會收到一個 *testing.T 參數。

Util: random.go

  • 在執行Unit test的過程中需要寫入Test Data進入到Database table中,如果使用固定的資料很容易造成UT 之間conflict,因此建立一系列 random Method 來隨機產生字串、數字、Currency…等,且對於未來的CodeBase較好維護。
  • init 函數是一個特殊的函數,它會在包(package)被載入和初始化時自動執行,而且它不需要被明確地調用。
  • rand.Seed(time.Now().UnixNano()): 這行程式碼是在設置隨機數的種子值。在計算機程式中,真正的隨機數是很難產生的,因此大多數的隨機數都是所謂的偽隨機數,它們是基於一個種子值(seed value)進行計算的。為了每次程式運行時都能產生不同的隨機序列,我們通常使用當前時間(到納秒)作為種子值。這就是 time.Now().UnixNano() 的作用, 不這麼做的話,每次程序運行時都會產生相同的。
package util

import (
	"math/rand"
	"strings"
	"time"
)

const alphabet = "abcdefghijklmnopqrstuvwxyz"

func init() {
	rand.Seed(time.Now().UnixNano())
}

// rand.Int63n(n) returns, as an int64, a non-negative pseudo-random number -> 0~n-1.
// 0+min <= n <= max-min+1-1+min -> min <= n <=max
// RandomInt generates a random integer between min and max.
func RandomInt(min, max int64) int64 {
	return min + rand.Int63n(max-min+1)
}

// RandomString generates a random string of length n
func RandomString(n int) string {
	var sb strings.Builder
	alphabetLen := len(alphabet)

	for i := 0; i < n; i++ {
		// rand.Intn(n) returns, as an int, a non-negative pseudo-random number -> 0~n-1.
		randomIndex := rand.Intn(alphabetLen)
		// randomChar get a random character from alphabet by randomIndex.
		randomChar := alphabet[randomIndex]
		sb.WriteByte(randomChar)
	}
	return sb.String()
}

// RandomOwner generates a random owner name from alphabet of length 6
func RandomOwner() string {
	return RandomString(6)
}

// RandomMoney generates a random amount of money
func RandomMoney() int64 {
	return RandomInt(0, 1000)
}

// RandomCurrency generates a random currency code
func RandomCurrency() string {
	currencies := []string{"USD", "EUR", "CAD"}
	n := len(currencies)
	// rand.Intn(n) returns, as an int, a non-negative pseudo-random number -> 0~n-1.
	return currencies[rand.Intn(n)]
}

Unit Test for account table

  • Golang中會使用testify package來驗証執行結果是否符合預期
  • 目前Account的Test不會清除Test Data這是一個潛在的問題。

Install testify

https://github.com/stretchr/testify

go get github.com/stretchr/testify

TestCreateAccount

  • 透過testify 驗証DBfunc return的結果並進行比對

  • 未來需要優化test data 使其可以隨機產生,可以避免UT之間的conflict且codebase較好維護(不需要一個一個修改)

  • Version 1 :

    func TestCreateAccount(t *testing.T) {
    	arg := CreateAccountParams{
    		Owner:    "tom",
    		Balance:  10,
    		Currency: "USD",
    	}
    	// testQueries is declared in the main_test
    	// CreateAccount shouldn't return err and return account not empty
    	// account's data should equal arg
    	// account's ID and CreatedAt shouldn't be zero
    	account, err := testQueries.CreateAccount(context.Background(), arg)
    	require.NoError(t, err)
    	require.NotEmpty(t, account)
    
    	require.Equal(t, arg.Owner, account.Owner)
    	require.Equal(t, arg.Balance, account.Balance)
    	require.Equal(t, arg.Currency, account.Currency)
    
    	require.NotZero(t, account.ID)
    	require.NotZero(t, account.CreatedAt)
    }
    
  • Version 2 (Random test data):

    func TestCreateAccount(t *testing.T) {
    	arg := CreateAccountParams{
    		Owner:    util.RandomOwner(),
    		Balance:  util.RandomMoney(),
    		Currency: util.RandomCurrency(),
    	}
    	account, err := testQueries.CreateAccount(context.Background(), arg)
    	require.NoError(t, err)
    	require.NotEmpty(t, account)
    	require.Equal(t, arg.Owner, account.Owner)
    	require.Equal(t, arg.Balance, account.Balance)
    	require.Equal(t, arg.Currency, account.Currency)
    	require.NotZero(t, account.ID)
    	require.NotZero(t, account.CreatedAt)
    }
    
  • 執行package tests後會告知結果與目前的coverage

ok  	github.com/Kcih4518/simple-bank/db/sqlc	0.159s	coverage: 6.5% of statements
  • account.sql.go 中會出現紅色與綠色區塊,各別代表測試未涵蓋與已涵蓋

https://ithelp.ithome.com.tw/upload/images/20230921/201217467KYAAHXSWQ.png

https://ithelp.ithome.com.tw/upload/images/20230921/201217464ESXxRke7j.png

  • Account剩餘的UT(ReadUpdateDelete) 在執行前都要執行Create,目前的設計會使得dependence 很高,且未來一更動TestCreateAccount 就會影響到所有UT,所以需要將CreateAccount的功能進行解耦。

    func createRandomAccount(t *testing.T) Account {
    	arg := CreateAccountParams{
    		Owner:    util.RandomOwner(),
    		Balance:  util.RandomMoney(),
    		Currency: util.RandomCurrency(),
    	}
    	// testQueries is declared in the main_test
    	// CreateAccount shouldn't return err and return account not empty
    	// account's data should equal arg
    	// account's ID and CreatedAt shouldn't be zero
    	account, err := testQueries.CreateAccount(context.Background(), arg)
    	require.NoError(t, err)
    	require.NotEmpty(t, account)
    
    	require.Equal(t, arg.Owner, account.Owner)
    	require.Equal(t, arg.Balance, account.Balance)
    	require.Equal(t, arg.Currency, account.Currency)
    
    	require.NotZero(t, account.ID)
    	require.NotZero(t, account.CreatedAt)
    
    	return account
    }
    
    func TestCreateAccount(t *testing.T) {
    	createRandomAccount(t)
    }
    

TestGetAccount、TestUpdateAccount、TestDeleteAccount、TestListAccounts

func TestUpdateAccount(t *testing.T) {
	account1 := createRandomAccount(t)
	arg := UpdateAccountParams{
		ID:      account1.ID,
		Balance: util.RandomMoney(),
	}
	account2, err := testQueries.UpdateAccount(context.Background(), arg)

	require.NoError(t, err)
	require.NotEmpty(t, account2)
	require.Equal(t, account1.ID, account2.ID)
	require.Equal(t, account1.Owner, account2.Owner)
	require.Equal(t, arg.Balance, account2.Balance)
	require.Equal(t, account1.Currency, account2.Currency)
	require.WithinDuration(t, account1.CreatedAt, account2.CreatedAt, time.Second)
}

func TestDeleteAccount(t *testing.T) {
	account1 := createRandomAccount(t)
	err := testQueries.DeleteAccount(context.Background(), account1.ID)

	require.NoError(t, err)
	account2, err := testQueries.GetAccount(context.Background(), account1.ID)

	require.Error(t, err)
	require.Empty(t, account2)
	require.EqualError(t, err, sql.ErrNoRows.Error())
}

func TestListAccounts(t *testing.T) {
	for i := 0; i < 10; i++ {
		createRandomAccount(t)
	}

	// Limit : the maximum number of rows to return
	// Offset : the number of rows to skip before starting to return rows from the query
	arg := ListAccountsParams{
		Limit:  5,
		Offset: 5,
	}

	accounts, err := testQueries.ListAccounts(context.Background(), arg)

	require.NoError(t, err)
	require.Len(t, accounts, 5)

	for _, account := range accounts {
		require.NotEmpty(t, account)
	}
}

Unit Test for entry table

  • entry_test.go
package db

import (
	"context"
	"testing"
	"time"

	"github.com/Kcih4518/simpleBank_2023/util"
	"github.com/stretchr/testify/require"
)

func createRandomEntry(t *testing.T, account Account) Entry {
	arg := CreateEntryParams{
		AccountID: account.ID,
		Amount:    util.RandomMoney(),
	}
	entry, err := testQueries.CreateEntry(context.Background(), arg)
	require.NoError(t, err)
	require.NotEmpty(t, entry)
	require.Equal(t, arg.AccountID, entry.AccountID)
	require.Equal(t, arg.Amount, entry.Amount)
	require.NotZero(t, entry.ID)
	require.NotZero(t, entry.CreatedAt)

	return entry
}

// You can call createRandomAccount() in all package db
func TestCreateEntry(t *testing.T) {
	account := createRandomAccount(t)
	createRandomEntry(t, account)
}

func TestGetEnry(t *testing.T) {
	account := createRandomAccount(t)
	entry1 := createRandomEntry(t, account)
	entry2, err := testQueries.GetEntry(context.Background(), entry1.ID)

	require.NoError(t, err)
	require.NotEmpty(t, entry2)
	require.Equal(t, entry1.ID, entry2.ID)
	require.Equal(t, entry1.AccountID, entry2.AccountID)
	require.Equal(t, entry1.Amount, entry2.Amount)
	require.WithinDuration(t, entry1.CreatedAt, entry2.CreatedAt, time.Second)
}

func TestListEntries(t *testing.T) {
	account := createRandomAccount(t)
	for i := 0; i < 10; i++ {
		createRandomEntry(t, account)
	}

	arg := ListEntriesParams{
		AccountID: account.ID,
		Limit:     5,
		Offset:    5,
	}

	entries, err := testQueries.ListEntries(context.Background(), arg)
	require.NoError(t, err)
	require.Len(t, entries, 5)

	for _, entry := range entries {
		require.NotEmpty(t, entry)
		require.Equal(t, arg.AccountID, entry.AccountID)
	}
}

Unit Test for transfer table

  • transfer_test.go
package db

import (
	"context"
	"testing"
	"time"

	"github.com/Kcih4518/simpleBank_2023/util"
	"github.com/stretchr/testify/require"
)

func createRandomTransfer(t *testing.T, account1, account2 Account) Transfer {
	arg := CreateTransferParams{
		FromAccountID: account1.ID,
		ToAccountID:   account2.ID,
		Amount:        util.RandomMoney(),
	}
	transfer, err := testQueries.CreateTransfer(context.Background(), arg)
	require.NoError(t, err)
	require.NotEmpty(t, transfer)
	require.Equal(t, arg.FromAccountID, transfer.FromAccountID)
	require.Equal(t, arg.ToAccountID, transfer.ToAccountID)
	require.Equal(t, arg.Amount, transfer.Amount)
	require.NotZero(t, transfer.ID)
	require.NotZero(t, transfer.CreatedAt)

	return transfer
}

func TestCreateTransfer(t *testing.T) {
	account1 := createRandomAccount(t)
	account2 := createRandomAccount(t)
	createRandomTransfer(t, account1, account2)
}

func TestGetTransfer(t *testing.T) {
	account1 := createRandomAccount(t)
	account2 := createRandomAccount(t)
	transfer1 := createRandomTransfer(t, account1, account2)
	transfer2, err := testQueries.GetTransfer(context.Background(), transfer1.ID)

	require.NoError(t, err)
	require.NotEmpty(t, transfer2)
	require.Equal(t, transfer1.ID, transfer2.ID)
	require.Equal(t, transfer1.FromAccountID, transfer2.FromAccountID)
	require.Equal(t, transfer1.ToAccountID, transfer2.ToAccountID)
	require.Equal(t, transfer1.Amount, transfer2.Amount)
	require.WithinDuration(t, transfer1.CreatedAt, transfer2.CreatedAt, time.Second)
}

func TestListTransfer(t *testing.T) {
	account1 := createRandomAccount(t)
	account2 := createRandomAccount(t)
	for i := 0; i < 10; i++ {
		createRandomTransfer(t, account1, account2)
	}

	arg := ListTransfersParams{
		FromAccountID: account1.ID,
		ToAccountID:   account2.ID,
		Limit:         5,
		Offset:        5,
	}
	transfers, err := testQueries.ListTransfers(context.Background(), arg)

	require.NoError(t, err)
	require.Len(t, transfers, 5)

	for _, transfer := range transfers {
		require.NotEmpty(t, transfer)
		require.Equal(t, arg.FromAccountID, transfer.FromAccountID)
		require.Equal(t, arg.ToAccountID, transfer.ToAccountID)
	}
}

Q & A

  1. *testing.T*testing.M 究竟是什麼?彼此的關係又是什麼?

    1. 彼此的關係: *testing.M 提供了一個環境,讓你可以在測試開始前後執行一些初始化或清理的工作,如設置資料庫連接、啟動或關閉服務等。當你執行 m.Run() 時,Go 會開始執行所有名稱前綴為 Test 的測試函數,且每個這樣的函數都會收到一個 *testing.T 參數。
    2. testing.M:
      • 是用於設置和執行測試的主控制。
      • 通常在 TestMain 函數中使用,讓你可以在執行測試前後執行一些設置或清理的工作。
      • 提供了 m.Run() 方法,該方法會執行所有的測試函數。
      • 你可以通過返回值來決定最終的結束代碼(通常與 os.Exit() 一起使用)。
    3. testing.T:
    • 它是在單元測試和子測試中傳遞的參數。
    • 通常在測試函數的參數中出現,如:func TestSomething(t *testing.T) {...}
    • 提供了報告測試結果的方法,如 t.Error(), t.Errorf(), t.Fatal(), 和 t.Fatalf()
    • 可以使用 t.Run() 來執行子測試。
    • 提供了跳過測試或標記測試為失敗的功能。
  2. 所以testing.M 宣告的object都會在m.Run時 傳遞給testing.T嗎?

    1. 不完全是這樣,*testing.M*testing.T 並不直接將物件傳遞給彼此。
    2. 但因為它們都運行在同一個程序中,你可以透過全域變數或其他方法共享狀態或資源。
    3. 例如,在 main_test.go 中,testQueries 是一個全域變數。當 TestMain 初始化它時,其他的測試函數(接受 *testing.T 為參數)也可以訪問它。但這並不是 *testing.M 直接將物件傳遞給 *testing.T 的結果,而是因為它們共享了同一個執行環境。
  3. Golang 如何宣告全域變數 ?

    1. Package變數:

      • 定義在函式外部的變數是Package變數。
      • 可以被Package內的任何函式、方法訪問。
      • 如果變數名稱首字母為大寫,那麼這個變數可以被其他套件訪問。如果首字母為小寫,則只能在當前套件內訪問。
      goCopy code
      // ExportedVar 是一個可以被其他套件訪問的變數
      var ExportedVar int
      
      // privateVar 只能在當前套件內訪問
      var privateVar string
      
      
    2. 區域變數:

      • 定義在函式或區塊內的變數。
      • 只能在該函式或區塊內被訪問。
      goCopy code
      func someFunction() {
          // localVar 是一個區域變數,只能在這個函式內部訪問
          var localVar int
      }
      
      
  4. 為何testQueries 可以使用CreateAccount這個method呢?

    1. 物件型別
      • testQueries 是一個指向 Queries 結構的指標。我們從你之前提供的代碼中可以看到,testQueries 被定義為 Queries
    2. 方法定義
      • 在你給出的代碼中,CreateAccount 方法是定義在 Queries 結構上的,因此所有的 Queries 物件(包括指標)都能夠調用此方法。
    3. 變數的範疇
      • testQueries 是在 main_test.go 中宣告為全域變數,這意味著它可以被 main_test.go 中的其他測試函式訪問和使用,例如在 account_test.go 中的 TestCreateAccount 函式。
  5. 為何需要random.go來隨機生成test data呢?

    1. 增加測試覆蓋率:隨機生成的數據能夠檢測更多不同的情境和邊界條件,這有助於確保程式碼的穩健性。
    2. 避免固定的偏見:固定的測試數據可能會錯過某些特定的問題。隨機數據能夠提供更多變化,有助於找出潛在問題。
    3. 模擬真實使用情境:真實的使用者輸入數據通常都是不可預測的。使用隨機數據能夠更接近真實的使用情境。
    4. 簡化測試設計:不必為每種特定情境手動設計測試數據。

上一篇
[Day 05] Generate CRUD Golang code from SQLC
下一篇
[Day 07] A clean way to implement database transaction in Golang Part 1
系列文
Techschool Goalng Backend Master Class 的學習記錄31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言