发布于 2026-01-06 4 阅读
0

使用随机数据编写数据库 CRUD 操作的 Go 单元测试

使用随机数据编写数据库 CRUD 操作的 Go 单元测试

大家好,欢迎回来!

在上一节课中,我们学习了如何生成 Go 语言的 CRUD 代码来与数据库交互。今天我们将学习如何为这些 CRUD 操作编写单元测试。

以下是:

测试创建帐户

我们先从CreateAccount()函数开始。我将在文件夹account_test.go内创建一个新文件db/sqlc

在 Golang 中,我们约定将测试文件放在与代码相同的文件夹中,并且测试文件的名称应该以 .suffix 后缀结尾_test

此测试文件的包名将是db,与我们的 CRUD 代码所在的包相同。现在让我们定义函数TestCreateAccount()



func TestCreateAccount(t *testing.T) {
    ...
}


Enter fullscreen mode Exit fullscreen mode

Go 语言中的每个单元测试函数都必须以Test大写字母 T 开头,并接收一个testing.T对象作为输入。我们将使用此T对象来管理测试状态。

CreateAccount()函数被定义为Queries对象的一个​​方法,它需要数据库连接才能与数据库通信。因此,为了编写测试,我们必须Queries先设置连接并创建对象。正确的做法是在main_test.go文件中进行这些操作。

我将把一个testQueries对象定义为全局变量,因为我们将在所有的单元测试中大量使用它。



var testQueries *Queries


Enter fullscreen mode Exit fullscreen mode

Queries对象定义在db.go由生成的文件中sqlc。它包含一个DBTX,该对象可以是数据库连接或事务:



type Queries struct {
    db DBTX
}


Enter fullscreen mode Exit fullscreen mode

在本例中,我们将建立一个数据库连接并使用它来创建Queries对象。

我将声明一个名为 `_` 的特殊函数TestMain(),它接受一个testing.M对象作为输入。



func TestMain(m *testing.M) {
    ...
}


Enter fullscreen mode Exit fullscreen mode

按照惯例,该TestMain()函数是特定 golang 包内所有单元测试的主要入口点,在本例中,该包为db

请记住,Golang 中的单元测试是针对每个包单独运行的,因此如果您的项目中有多个包,则可以有多个具有不同入口点的main_test.go文件。TestMain()

好的,现在要创建一个新的数据库连接,我们使用sql.Open()一个函数,并传入数据库驱动程序和数据库源字符串。目前,我先将它们声明为常量。以后我们会学习如何从环境变量中加载它们。

数据库驱动程序应该是postgres……而数据库源,我们可以从上一讲中编写的迁移命令中复制。



const (
    dbDriver = "postgres"
    dbSource = "postgresql://root:secret@localhost:5432/simple_bank?sslmode=disable"
)

var testQueries *Queries

func TestMain(m *testing.M) {
    conn, err := sql.Open(dbDriver, dbSource)
    if err != nil {
        log.Fatal("cannot connect to db:", err)
    }

    testQueries = New(conn)

    os.Exit(m.Run())
}


Enter fullscreen mode Exit fullscreen mode

sql.Open()函数返回一个连接对象和一个错误信息。如果错误信息不是空值nil,则写入一条致命日志,说明无法连接到数据库。

否则,我们将使用该连接创建新testQueries对象。该New()函数定义在db.gosqlc 为我们生成的文件中。

现在testQueries一切就绪,我们只需调用函数m.Run()开始运行单元测试。该函数将返回一个退出代码,告诉我们测试是成功还是失败。然后,我们应该通过os.Exit()命令将结果报告给测试运行器。

好的,我们来试试运行一下!

导入失败

我们收到一个错误:cannot connect to db: unknown driver "postgres"

这是因为该database/sql软件包仅提供了一个围绕 SQL 数据库的通用接口。它需要与数据库驱动程序配合使用,才能与特定的数据库引擎通信。

我们正在使用postgres,所以我将使用lib/pq驱动程序。让我们打开它的GitHub 页面,并复制 go get 命令。在终端中运行它来安装该软件包:



go get github.com/lib/pq


Enter fullscreen mode Exit fullscreen mode

现在如果我们打开这个go.mod文件,可以看到lib/pq已经添加了内容。

间接进口

这里显示“间接”,是因为我们还没有在代码中导入和使用它。所以让我们回到文件main_test.go,导入 lib/pq 驱动程序:



import "github.com/lib/pq"


Enter fullscreen mode Exit fullscreen mode

这是一个非常特殊的导入,因为我们实际上并没有lib/pq在代码中直接调用任何函数。底层代码database/sql会处理这些。

所以如果我们像这样直接导入,Go 格式化程序会在保存文件时自动将其删除。要告诉 Go 格式化程序保留它,我们必须使用空标识符,即在导入包名前添加一个下划线:



import (
    "database/sql"
    "log"
    "os"
    "testing"

    _ "github.com/lib/pq"
)


Enter fullscreen mode Exit fullscreen mode

现在如果我们再次运行TestMain(),就不会再出现错误了。

导入成功

如果我们打开终端并运行命令go mod tidy来清理依赖项,现在可以看到文件lib/pq中的requirego.mod不再是间接的,因为我们已经在代码中导入了它。

无间接

好了,现在设置完成了,我们可以开始为CreateAccount()函数编写第一个单元测试了。

首先我们声明一个新的参数:CreateAccountParams。假设所有者姓名是tom,账户余额是100,货币是USD

然后我们调用该函数testQueries.CreateAccount(),传入后台上下文和参数。这个对象就是我们之前testQueries在文件中声明的对象。main_test.go



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

    account, err := testQueries.CreateAccount(context.Background(), arg)

    ...
}


Enter fullscreen mode Exit fullscreen mode

CreateAccount()函数返回一个账户对象或一个错误作为结果。

为了检查测试结果,我建议使用testify 包。它比使用标准的 if else 语句更简洁。让我们在终端运行以下 go get 命令来安装该包:



go get github.com/stretchr/testify


Enter fullscreen mode Exit fullscreen mode

好了,现在要使用这个包,我们需要先导入它。Testify 包含几个子包,但我只会使用其中一个,也就是这个require包。



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


Enter fullscreen mode Exit fullscreen mode

通过此导入,我们现在可以传入require.NoError(),对象testing.T和函数返回的错误信息CreateAccount()



func TestCreateAccount(t *testing.T) {
    ...

    account, err := testQueries.CreateAccount(context.Background(), arg)

    require.NoError(t, err)
    require.NotEmpty(t, account)
}


Enter fullscreen mode Exit fullscreen mode

基本上,该命令会检查错误是否必须存在nil,如果不是,则会自动使测试失败。

接下来,我们使用函数要求返回的帐户不能是空对象require.NotEmpty()

之后,我们需要检查账户所有者、余额和货币是否与输入参数相符。

因此我们调用require.Equal(),传入t,预期的输入所有者,以及实际的account.Owner



func TestCreateAccount(t *testing.T) {
    ...

    require.Equal(t, arg.Owner, account.Owner)
    require.Equal(t, arg.Balance, account.Balance)
    require.Equal(t, arg.Currency, account.Currency)
}


Enter fullscreen mode Exit fullscreen mode

同样地,我们要求arg.Balance等于account.Balance,并且arg.Currency等于account.Currency

我们还需要检查账户 ID 是否由 Postgres 自动生成。因此,这里我们要求account.ID它不能为零。



func TestCreateAccount(t *testing.T) {
    ...

    require.NotZero(t, account.ID)
    require.NotZero(t, account.CreatedAt)
}


Enter fullscreen mode Exit fullscreen mode

最后,该created_at列还应填充当前时间戳。该NotZero()函数会断言该值不能是其类型对应的零值。

好了!单元测试完成了。点击这个按钮运行它吧。

测试创建帐户成功

这里显示“OK”,所以测试通过了。让我们simple_bank用 TablePlus 打开数据库,确认是否已插入记录。

表加帐户已创建

好了,我们有一个账户,账户值为id1。` owner,`balancecurrency`值` 都和我们在测试中设置的值相同。`created_at字段` 也已填充了当前时间戳。太好了!

我们还可以点击Run package tests运行此包中的所有单元测试。目前它只有一个测试,所以这并不重要。

代码覆盖率

但令人欣喜的是,代码覆盖率也会被报告。目前,我们的单元测试仅覆盖了 6.5% 的代码语句,这个比例非常低。

查看该account.sql.go文件,可以看到该CreateAccount()函数现在被标记为绿色,这意味着它已被单元测试覆盖。

覆盖绿色-红色

其他所有函数仍然显示为红色,这意味着它们尚未被覆盖。我们稍后会编写更多单元测试来覆盖它们。

但在此之前,我将向您展示一种更好的方法来生成测试数据,而不是像我们为创建帐户参数所做的那样手动填写它们。

生成随机数据

通过生成随机数据,我们可以节省大量时间来确定要使用的值,代码也会更加简洁易懂。

由于数据是随机的,这有助于我们避免多个单元测试之间的冲突。例如,如果数据库中存在具有唯一约束的列,这一点就尤为重要。

好的,我们创建一个新文件夹util,并在其中添加一个新文件random.go。包名util与包含它的文件夹名称相同。

首先我们需要编写一个特殊函数:init()。该函数将在首次使用该软件包时自动调用。



package util

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


Enter fullscreen mode Exit fullscreen mode

在这个函数中,我们通过调用 `setSeedValue` 来设置随机数生成器的种子值rand.Seed()。通常,种子值会被设置为当前时间。

由于rand.Seed()预期int64输入为时间,我们应该在将其传递给函数之前将其转换为 Unix nano 时间格式。

这样可以确保每次运行代码时生成的值都不同。如果不调用该函数rand.Seed(),随机数生成器将如同以 1 为种子一样运行,因此每次运行生成的值都将相同。

现在我们将编写一个函数来生成随机整数:



func RandomInt(min, max int64) int64 {
    return min + rand.Int63n(max-min+1)
}


Enter fullscreen mode Exit fullscreen mode

RandomInt()函数接受两个int64数字作为输入:minmax。它返回一个介于和之间的随机int64minmax

该函数基本上rand.Int63n(n)返回一个介于00 和 1之间的随机整数n-1。因此,rand.Int63n(max - min + 1)它将返回一个介于 00和 1之间的随机整数max - min

因此,当我们将 添加min到该表达式中时,最终结果将是介于min和之间的随机整数max

接下来,我们来编写一个生成随机字符串的函数n。为此,我们需要声明一个包含所有支持字符的字母表。为了简单起见,这里我只使用 26 个小写英文字母。



const alphabet = "abcdefghijklmnopqrstuvwxyz"

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


Enter fullscreen mode Exit fullscreen mode

在该RandomString()函数中,我们声明了一个新的字符串构建器对象sb,获取字母表中的字符总数并将其赋值给它k

然后我们将使用一个简单的 for 循环来生成n随机字符。我们用rand.Intn(k)来获取一个从0到 之间的随机位置k-1,并取字母表中该位置对应的字符,将其赋值给变量c

我们调用函数sb.WriteByte()将该字符写入c字符串生成器。最后,我们返回sb.ToString()给调用者。

函数RandomString()已经完成。现在我们可以用它来生成一个随机的所有者名称。

我们RandomOwner()为此定义一个新函数。函数内部,我们只需返回一个随机的 6 个字母的字符串。我认为这个长度足够避免重复。



func RandomOwner() string {
    return RandomString(6)
}


Enter fullscreen mode Exit fullscreen mode

同样地,我将定义另一个RandomMoney()函数来生成一个随机金额。假设它将是一个介于 00和 1之间的随机整数1000



func RandomMoney() int64 {
    return RandomInt(0, 1000)
}


Enter fullscreen mode Exit fullscreen mode

我们还需要一个函数来生成随机货币。



func RandomCurrency() string {
    currencies := []string{"EUR", "USD", "CAD"}
    n := len(currencies)
    return currencies[rand.Intn(n)]
}


Enter fullscreen mode Exit fullscreen mode

RandomCurrency()函数将返回列表中的一种货币。这里我只使用了三种货币:EURUSDCAD。您可以根据需要添加更多值。

类似于我们之前从字母表中生成随机字符的方法,这里我们计算货币列表的长度并将其赋值给n

然后我们使用rand.Intn(n)函数生成介于0和之间的随机索引n-1,并返回列表中该索引处的货币。

好了,现在回到account_test.go文件。在文件中CreateAccountParams,我们可以将特定所有者名称替换为util.RandomOwner(),余额替换为util.RandomMoney(),并将值替换USDutil.RandomCurrency()



func createRandomAccount(t *testing.T) Account {
    arg := CreateAccountParams{
        Owner:    util.RandomOwner(),
        Balance:  util.RandomMoney(),
        Currency: util.RandomCurrency(),
    }

    ...
}


Enter fullscreen mode Exit fullscreen mode

就这样!

现在,如果我们重新运行单元测试并刷新 TablePlus,我们可以看到一条id = 3具有随机值的新记录。

随机账户

前两条记录是固定值,因为我们在使用随机函数之前已经运行了两次测试。

成功了!

现在我要在 Makefile 中添加一个新的测试命令,以便我们可以在终端中轻松运行单元测试。

这个命令很简单。我们只需调用它go test,使用-v选项打印详细日志,并-cover使用选项测量代码覆盖率即可。



test:
    go test -v -cover ./...


Enter fullscreen mode Exit fullscreen mode

由于我们的项目将包含多个包,我们使用此./...参数在所有包中运行单元测试。

现在,如果我们在终端运行 make test,可以看到每当测试运行或完成时,它都会打印出详细的日志。

测试

它还会报告每个包的单元测试代码覆盖率。太棒了!

让我们刷新 TablePlus 以查看新记录:

不同账户

它与之前的记录值完全不同。所以随机数生成器运行良好。

接下来,我将向您展示如何为其余的 CRUD 操作编写单元测试:Delete,,,GetListUpdate

测试获取帐户

我们先来看GetAccount()函数。

你知道,要测试所有其他 CRUD 操作,我们总是需要先创建一个帐户。

请注意,在编写单元测试时,我们应该确保它们彼此独立。

为什么?因为如果有上百个相互依赖的测试,维护起来会非常困难。相信我,你最不希望看到的就是一个测试的简单改动影响到其他测试的结果。

因此,每个测试都应该创建自己的账户记录。为了避免代码重复,我们编写一个单独的函数来创建随机账户。请将我们编写的代码粘贴到该TestCreateAccount()函数中:



func createRandomAccount(t *testing.T) Account {
    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)

    return account
}


Enter fullscreen mode Exit fullscreen mode

然后,对于 ` testing.T` 对象TestCreateAccount(),我们只需要createRandomAccount()像这样调用并传入它:



func TestCreateAccount(t *testing.T) {
    createRandomAccount(t)
}


Enter fullscreen mode Exit fullscreen mode

请注意,该createRandomAccount()函数没有Test前缀,因此不会作为单元测试运行。相反,它应该返回创建的Account记录,以便其他单元测试能够获得足够的数据来执行它们自己的操作。

现在有了这个函数,我们就可以编写该GetAccount()函数的测试了。

首先,我们调用createRandomAccount()并保存创建的记录到account1。然后,我们调用testQueries.GetAccount()带有后台上下文和 ID 的函数account1。结果是account2返回一个结果或一个错误。



func TestGetAccount(t *testing.T) {
    account1 := createRandomAccount(t)
    account2, err := testQueries.GetAccount(context.Background(), account1.ID)

    require.NoError(t, err)
    require.NotEmpty(t, account2)

    ...
}


Enter fullscreen mode Exit fullscreen mode

我们检查错误是否应该nil使用该require.NoError()函数。然后,我们要求account2它不能为空。

的所有数据字段都account2应该与 的数据字段相同account1。我们使用require.Equal()函数来比较它们。首先是 ID,然后是账户所有者、余额和货币。



func TestGetAccount(t *testing.T) {
    ...

    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, account2.CreatedAt, time.Second)

    ...
}


Enter fullscreen mode Exit fullscreen mode

对于类似这样的时间戳字段created_at,除了 `timestamp` 之外require.Equal(),你还可以使用 ` require.WithinDuration()timestamp` 来检查两个时间戳之间的差值是否不超过某个时间差(delta)。例如,在本例中,我选择时间差为 1 秒。

好了!GetAccount()操作单元测试完成了。让我们运行一下:

获取帐户

通过了!

测试更新帐户

现在我们来为这个UpdateAccount()函数编写测试。第一步是创建一个新的account1.

然后我们声明参数,这是一个UpdateAccountParams对象,其中ID是创建的帐户的 ID,balance是随机金额。



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

    arg := UpdateAccountParams{
        ID:      account1.ID,
        Balance: util.RandomMoney(),
    }

    ...
}


Enter fullscreen mode Exit fullscreen mode

现在我们调用testQueries.UpdateAccount(),传入后台上下文和更新参数。

那么我们要求不能返回任何错误。更新后的account2对象不应为空。



func TestUpdateAccount(t *testing.T) {
    ...

    account2, err := testQueries.UpdateAccount(context.Background(), arg)

    require.NoError(t, err)
    require.NotEmpty(t, account2)
}


Enter fullscreen mode Exit fullscreen mode

我们将每个字段与 进行比较account2account1几乎所有字段都应该相同,除了余额,余额应该更改为arg.Balance



func TestUpdateAccount(t *testing.T) {
    ...

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


Enter fullscreen mode Exit fullscreen mode

好的,我们来运行这个测试。

更新帐户

通过了!

测试删除帐户

可以TestDeleteAccount()很容易地以类似的方式实现。

首先,我们创建一个新的对象account1。然后,我们调用该函数testQueries.DeleteAccount(),并传入后台上下文以及已创建对象的 ID account1。我们要求不返回任何错误。



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

    ...
}


Enter fullscreen mode Exit fullscreen mode

然后,为了确保账户确实已被删除,我们调用testQueries.GetAccount()数据库查找该账户。在这种情况下,调用应该返回错误。所以我们require.Error()在这里使用。



func TestDeleteAccount(t *testing.T) {
    ...

    account2, err := testQueries.GetAccount(context.Background(), account1.ID)
    require.Error(t, err)
    require.EqualError(t, err, sql.ErrNoRows.Error())
    require.Empty(t, account2)
}



Enter fullscreen mode Exit fullscreen mode

更准确地说,我们使用require.EqualError()函数来检查错误是否正确sql.ErrNoRows。最后检查 account2 对象是否为空。

现在我们来运行测试。

删除帐户

通过了!太棒了!

测试列表帐户

我们要测试的最后一个操作是ListAccount()……它与其他函数略有不同,因为它会选择多条记录。

为了测试,我们需要创建多个账户。这里我只用一个简单的 for 循环创建了 10 个随机账户。



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

    arg := ListAccountsParams{
        Limit:  5,
        Offset: 5,
    }

    ...
}


Enter fullscreen mode Exit fullscreen mode

然后我们声明列表帐户参数。假设 limit 为5,offset 为5,这意味着跳过前 个5记录,并返回下一个5

当我们运行测试时,数据库中至少会有 10 个帐户,因此使用这些参数,我们预计会得到 5 条记录。

现在我们testQueries.ListAccounts()传入背景信息和参数进行调用。



func TestListAccounts(t *testing.T) {
    ...

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

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


Enter fullscreen mode Exit fullscreen mode

我们不允许出现任何错误,并且返回的账户切片的长度应为5

我们还遍历账户列表,并要求每个账户都不为空。

就是这样!我们来运行这个测试。

列出账户

测试通过了!现在让我们运行这个包中的所有单元测试。

包测试

全部通过。

查看该account.sql.go文件,我们可以看到所有账户 CRUD 功能都已涵盖。

涵盖所有

但为什么这个套餐的总覆盖范围只有这么多33.8%

Entry这是因为我们还没有为表的 CRUD 操作编写任何测试Transfer。这留给你们自己练习。

希望这篇文章对您有所帮助。感谢阅读,我们下节课再见。


如果您喜欢这篇文章,请订阅我们的 YouTube 频道在 Twitter 上关注我们,以便将来获取更多教程。


如果你想加入我在Voodoo的优秀团队,请点击此处查看我们的招聘信息。可远程办公,也可在巴黎/阿姆斯特丹/伦敦/柏林/巴塞罗那现场办公,公司提供签证担保。

文章来源:https://dev.to/techschoolguru/write-go-unit-tests-for-db-crud-with-random-data-53no