Viết Go Unit test cho db CRUD với random data

EminelEminel
8 min read

Khi phát triển các ứng dụng Go tương tác với cơ sở dữ liệu, việc đảm bảo tính chính xác của các thao tác CRUD (Create, Read, Update, Delete) là vô cùng quan trọng. Unit test với dữ liệu ngẫu nhiên giúp kiểm tra tính ổn định của hệ thống dưới nhiều điều kiện đầu vào khác nhau, phát hiện các lỗi tiềm ẩn và đảm bảo code hoạt động đúng như mong đợi trong mọi tình huống.

Bài viết này sẽ hướng dẫn cách viết các unit test toàn diện cho các thao tác CRUD trong Go, sử dụng dữ liệu ngẫu nhiên để mô phỏng các tình huống thực tế. Chúng ta sẽ tìm hiểu cách thiết lập môi trường test, tạo dữ liệu mẫu ngẫu nhiên và viết các test case hiệu quả để đảm bảo chức năng của ứng dụng.

Trong bài giảng trước, chúng ta đã tìm hiểu cách tạo code Golang Crud để giao tiếp với cơ sở dữ liệu. Hôm nay chúng ta sẽ học cách viết unit test cho các hoạt động CRUD đó.

Setup main test

Mọi hàm unit test trong Go phải bắt đầu với tiền tố Test (chữ T viết hoa) và nhận một đối tượng testing.T làm tham số. Đối tượng T này được sử dụng để quản lý trạng thái của test.

Tôi sẽ khai báo một hàm đặc biệt có tên TestMain(), nhận một đối tượng testing.M làm tham số.

Theo convention, TestMain() là entry point chính của tất cả unit test trong một package Go cụ thể, trong trường hợp này là package db.

Lưu ý rằng unit test trong Golang được chạy riêng biệt cho từng package. Vì vậy, nếu dự án có nhiều package, bạn có thể có nhiều file main_test.go, mỗi file chứa một TestMain() riêng biệt.

Dưới đây là file main_test.go

package db

import (
    "context"
    "log"
    "os"
    "testing"

    "github.com/jackc/pgx/v5/pgxpool"
)

const (
    dbSource = "postgresql://postgres:Abc@12345678@localhost:54322/simple_bank?sslmode=disable"
)

var testQueries *Queries

func TestMain(m *testing.M) {
    connPool, err := pgxpool.New(context.Background(), dbSource)
    if err != nil {
        log.Fatal("cannot connect to db:", err)
    }
    defer connPool.Close()

    testQueries = New(connPool)

    os.Exit(m.Run())
}
  • Import: Import các package cần thiết (pgxpool để kết nối DB, testing để chạy test).

  • Khai báo biến toàn cục: testQueries để dùng chung trong các test case.

  • Hàm TestMain:

    • Tải config từ file cấu hình.

    • Tạo connection pool để kết nối database.

    • Gán testStore để sử dụng trong test.

    • Chạy tất cả các test case với m.Run().

Chạy lệnh sau để kiểm tra:

go test -v ./db/sqlc

Kết quả sẽ trông như thế này:

Test Create Account

Hãy bắt đầu với hàm CreateAccount(). Tôi sẽ tạo một tệp mới có tên account_test.go bên trong thư mục db/sqlc.

Trong Golang, có một quy ước là đặt tên file test trong cùng thư mục với mã nguồn, và tên của file test nên kết thúc bằng hậu tố _test.

Tên package của file test này sẽ là db, giống với package chứa mã CRUD của chúng ta. Bây giờ, hãy định nghĩa hàm TestCreateAccount().

package db

import (
    "context"
    "testing"
    "time"

    "github.com/stretchr/testify/require"
)

func createRandomAccount(t *testing.T) Account {
    arg := CreateAccountParams{
        Owner:    "test_user",
        Balance:  100,
        Currency: "USD",
    }

    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)
}

func TestGetAccount(t *testing.T) {
    // Create account first
    account1 := createRandomAccount(t)

    // Get the created account
    account2, err := testQueries.GetAccount(context.Background(), account1.ID)
    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, account1.Balance, account2.Balance)
    require.Equal(t, account1.Currency, account2.Currency)
    require.WithinDuration(t, account1.CreatedAt.Time, account2.CreatedAt.Time, time.Second)
}

func TestUpdateAccount(t *testing.T) {
    account1 := createRandomAccount(t)

    arg := UpdateAccountParams{
        ID:      account1.ID,
        Balance: 200,
    }

    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.Time, account2.CreatedAt.Time, 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)
}

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

    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)
    }
}

File test này bao gồm các test case cho các chức năng CRUD cơ bản của Account:

  • createRandomAccount: Helper function để tạo account ngẫu nhiên cho mục đích test

  • TestCreateAccount: Test việc tạo account mới

  • TestGetAccount: Test việc lấy thông tin một account

  • TestUpdateAccount: Test việc cập nhật thông tin account

  • TestDeleteAccount: Test việc xóa account

  • TestListAccounts: Test việc lấy danh sách accounts với phân trang

Mỗi test case đều tuân theo cấu trúc:

  • Arrange: Chuẩn bị dữ liệu test

  • Act: Thực hiện hành động cần test

  • Assert: Kiểm tra kết quả với các assertions

Các test sử dụng thư viện testify/require để viết các assertions rõ ràng và dễ đọc. Mỗi test case đều kiểm tra đầy đủ các trường hợp thành công và thất bại cần thiết.

Chạy lệnh go test -v ./db/sqlc để chạy test

Mở database để kiểm tra dữ liệu được tạo

Có một số bản ghi đã được tạo ra, tuy nhiên có một vấn đề là dữ liệu đang bị hard code nên đang không đúng với logic lắm. Chúng ta tiến hành sửa lại file test để tạo random data.

Generate random data

Bằng cách tạo dữ liệu ngẫu nhiên, ta tiết kiệm được rất nhiều thời gian trong việc chọn giá trị phù hợp, đồng thời code sẽ gọn hơn và dễ hiểu hơn.

Hơn nữa, vì dữ liệu là ngẫu nhiên, ta cũng tránh được xung đột giữa các unit test, đặc biệt quan trọng khi có cột bị ràng buộc unique trong database.

Giờ tạo thư mục util, thêm file random.gocurrency.go vào đó. Tên package là util, trùng với tên thư mục. Hai file này mục đích để tạo dữ liệu random.

Đây là file currency.go

package util

// Constants for all supported currencies
const (
    USD = "USD"
    EUR = "EUR"
    CAD = "CAD"
)

// IsSupportedCurrency checks if the currency is supported
func IsSupportedCurrency(currency string) bool {
    switch currency {
    case USD, EUR, CAD:
        return true
    }
    return false
}
  • Định nghĩa danh sách các loại tiền tệ được hỗ trợ (USD, EUR, CAD) dưới dạng hằng số.

  • Hàm IsSupportedCurrency(currency string) bool kiểm tra xem một loại tiền tệ có nằm trong danh sách hỗ trợ hay không, trả về true nếu có, ngược lại trả về false.

Đây là file random.go

package util

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

const alphabet = "abcdefghijklmnopqrstuvwxyz"

// 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
    k := len(alphabet)

    for i := 0; i < n; i++ {
        c := alphabet[rand.Intn(k)]
        sb.WriteByte(c)
    }

    return sb.String()
}

// RandomOwner generates a random owner name
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)
    return currencies[rand.Intn(n)]
}

// RandomEmail generates a random email
func RandomEmail() string {
    return fmt.Sprintf("%s@email.com", RandomString(6))
}
  • Khai báo package util và import các thư viện cần thiết (fmt, math/rand, strings).

  • Định nghĩa bảng chữ cái (alphabet) để tạo chuỗi ngẫu nhiên.

  • RandomInt(min, max int64) int64: Tạo số nguyên ngẫu nhiên trong khoảng [min, max].

  • RandomString(n int) string: Tạo chuỗi ngẫu nhiên có độ dài n từ bảng chữ cái.

  • RandomOwner() string: Tạo tên chủ sở hữu ngẫu nhiên (chuỗi 6 ký tự).

  • RandomMoney() int64: Tạo số tiền ngẫu nhiên từ 0 đến 1000.

  • RandomCurrency() string: Chọn ngẫu nhiên một loại tiền tệ từ danh sách USD, EUR, CAD.

  • RandomEmail() string: Tạo email ngẫu nhiên theo định dạng xxxxxx@email.com.

Okay, tiếp theo sửa lại file account_test.go sử dụng data random

Chạy lệnh go test -v -cover ./db/sqlc để chạy lại test

  • go test: Chạy các bài test trong Go.

  • -v (verbose): Hiển thị chi tiết kết quả từng test case.

  • -cover: Bật chế độ đo độ bao phủ (coverage) của các bài test.

  • ./db/sqlc: Chỉ định thư mục chứa mã nguồn cần test (trong trường hợp này là thư mục db/sqlc

Kết quả như sau

=== RUN   TestCreateAccount
--- PASS: TestCreateAccount (0.03s)
=== RUN   TestGetAccount
--- PASS: TestGetAccount (0.02s)
=== RUN   TestUpdateAccount
--- PASS: TestUpdateAccount (0.04s)
=== RUN   TestDeleteAccount
--- PASS: TestDeleteAccount (0.06s)
=== RUN   TestListAccounts
--- PASS: TestListAccounts (0.22s)
PASS
coverage: 35.2% of statements
ok      github.com/eminel9311/simplebank/db/sqlc        0.379s  coverage: 35.2% of statements

Coverage: 35.2% of statements nghĩa là chỉ 35.2% số dòng lệnh trong mã nguồn được thực thi khi chạy các bài test. Còn các Unit test cho các file khác như entry.sql.go transfer.sql.go … Các bạn xem tại Git của mình nhé.

Cuối cùng chúng ta kiểm tra lại database xem dữ liệu được tạo như thế nào nhé

Viết unit test cho các thao tác CRUD trong Go với dữ liệu ngẫu nhiên giúp đảm bảo tính đúng đắn của code, đồng thời giảm thiểu rủi ro xung đột giữa các test case. Việc sử dụng dữ liệu ngẫu nhiên còn giúp kiểm tra ứng dụng trong nhiều tình huống khác nhau, phát hiện lỗi tiềm ẩn hiệu quả hơn.

Bằng cách áp dụng phương pháp này, bạn có thể xây dựng các bài test linh hoạt, dễ bảo trì và phản ánh sát thực tế hơn. Hãy tiếp tục tối ưu test coverage để nâng cao chất lượng hệ thống!

Xin chào và hẹn gặp lại các bạn trong các bài viết lần sau. Link tài liệu tham khảo: https://dev.to/techschoolguru/write-go-unit-tests-for-db-crud-with-random-data-53no

0
Subscribe to my newsletter

Read articles from Eminel directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Eminel
Eminel

Hello, my name is Eminel a software engineer specializing in scalable software architecture, microservices, and AI-powered platforms. Passionate about mentoring, performance optimization, and building solutions that drive business success.