使用随机数据编写数据库 CRUD 操作的 Go 单元测试
大家好,欢迎回来!
在上一节课中,我们学习了如何生成 Go 语言的 CRUD 代码来与数据库交互。今天我们将学习如何为这些 CRUD 操作编写单元测试。
以下是:
- YouTube 上的完整剧集播放列表链接
- 以及它的GitHub 仓库
测试创建帐户
我们先从CreateAccount()函数开始。我将在文件夹account_test.go内创建一个新文件db/sqlc。
在 Golang 中,我们约定将测试文件放在与代码相同的文件夹中,并且测试文件的名称应该以 .suffix 后缀结尾_test。
此测试文件的包名将是db,与我们的 CRUD 代码所在的包相同。现在让我们定义函数TestCreateAccount()。
func TestCreateAccount(t *testing.T) {
...
}
Go 语言中的每个单元测试函数都必须以Test大写字母 T 开头,并接收一个testing.T对象作为输入。我们将使用此T对象来管理测试状态。
该CreateAccount()函数被定义为Queries对象的一个方法,它需要数据库连接才能与数据库通信。因此,为了编写测试,我们必须Queries先设置连接并创建对象。正确的做法是在main_test.go文件中进行这些操作。
我将把一个testQueries对象定义为全局变量,因为我们将在所有的单元测试中大量使用它。
var testQueries *Queries
该Queries对象定义在db.go由生成的文件中sqlc。它包含一个DBTX,该对象可以是数据库连接或事务:
type Queries struct {
db DBTX
}
在本例中,我们将建立一个数据库连接并使用它来创建Queries对象。
我将声明一个名为 `_` 的特殊函数TestMain(),它接受一个testing.M对象作为输入。
func TestMain(m *testing.M) {
...
}
按照惯例,该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())
}
该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
现在如果我们打开这个go.mod文件,可以看到lib/pq已经添加了内容。
这里显示“间接”,是因为我们还没有在代码中导入和使用它。所以让我们回到文件main_test.go,导入 lib/pq 驱动程序:
import "github.com/lib/pq"
这是一个非常特殊的导入,因为我们实际上并没有lib/pq在代码中直接调用任何函数。底层代码database/sql会处理这些。
所以如果我们像这样直接导入,Go 格式化程序会在保存文件时自动将其删除。要告诉 Go 格式化程序保留它,我们必须使用空标识符,即在导入包名前添加一个下划线:
import (
"database/sql"
"log"
"os"
"testing"
_ "github.com/lib/pq"
)
现在如果我们再次运行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)
...
}
该CreateAccount()函数返回一个账户对象或一个错误作为结果。
为了检查测试结果,我建议使用testify 包。它比使用标准的 if else 语句更简洁。让我们在终端运行以下 go get 命令来安装该包:
go get github.com/stretchr/testify
好了,现在要使用这个包,我们需要先导入它。Testify 包含几个子包,但我只会使用其中一个,也就是这个require包。
import "github.com/stretchr/testify/require"
通过此导入,我们现在可以传入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)
}
基本上,该命令会检查错误是否必须存在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)
}
同样地,我们要求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)
}
最后,该created_at列还应填充当前时间戳。该NotZero()函数会断言该值不能是其类型对应的零值。
好了!单元测试完成了。点击这个按钮运行它吧。
这里显示“OK”,所以测试通过了。让我们simple_bank用 TablePlus 打开数据库,确认是否已插入记录。
好了,我们有一个账户,账户值为id1。` owner,`balance和currency`值` 都和我们在测试中设置的值相同。`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())
}
在这个函数中,我们通过调用 `setSeedValue` 来设置随机数生成器的种子值rand.Seed()。通常,种子值会被设置为当前时间。
由于rand.Seed()预期int64输入为时间,我们应该在将其传递给函数之前将其转换为 Unix nano 时间格式。
这样可以确保每次运行代码时生成的值都不同。如果不调用该函数rand.Seed(),随机数生成器将如同以 1 为种子一样运行,因此每次运行生成的值都将相同。
现在我们将编写一个函数来生成随机整数:
func RandomInt(min, max int64) int64 {
return min + rand.Int63n(max-min+1)
}
此RandomInt()函数接受两个int64数字作为输入:min和max。它返回一个介于和之间的随机int64数。minmax
该函数基本上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()
}
在该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)
}
同样地,我将定义另一个RandomMoney()函数来生成一个随机金额。假设它将是一个介于 00和 1之间的随机整数1000。
func RandomMoney() int64 {
return RandomInt(0, 1000)
}
我们还需要一个函数来生成随机货币。
func RandomCurrency() string {
currencies := []string{"EUR", "USD", "CAD"}
n := len(currencies)
return currencies[rand.Intn(n)]
}
此RandomCurrency()函数将返回列表中的一种货币。这里我只使用了三种货币:EUR,USD和CAD。您可以根据需要添加更多值。
类似于我们之前从字母表中生成随机字符的方法,这里我们计算货币列表的长度并将其赋值给n。
然后我们使用rand.Intn(n)函数生成介于0和之间的随机索引n-1,并返回列表中该索引处的货币。
好了,现在回到account_test.go文件。在文件中CreateAccountParams,我们可以将特定所有者名称替换为util.RandomOwner(),余额替换为util.RandomMoney(),并将值替换USD为util.RandomCurrency()。
func createRandomAccount(t *testing.T) Account {
arg := CreateAccountParams{
Owner: util.RandomOwner(),
Balance: util.RandomMoney(),
Currency: util.RandomCurrency(),
}
...
}
就这样!
现在,如果我们重新运行单元测试并刷新 TablePlus,我们可以看到一条id = 3具有随机值的新记录。
前两条记录是固定值,因为我们在使用随机函数之前已经运行了两次测试。
成功了!
现在我要在 Makefile 中添加一个新的测试命令,以便我们可以在终端中轻松运行单元测试。
这个命令很简单。我们只需调用它go test,使用-v选项打印详细日志,并-cover使用选项测量代码覆盖率即可。
test:
go test -v -cover ./...
由于我们的项目将包含多个包,我们使用此./...参数在所有包中运行单元测试。
现在,如果我们在终端运行 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
}
然后,对于 ` testing.T` 对象TestCreateAccount(),我们只需要createRandomAccount()像这样调用并传入它:
func TestCreateAccount(t *testing.T) {
createRandomAccount(t)
}
请注意,该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)
...
}
我们检查错误是否应该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)
...
}
对于类似这样的时间戳字段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(),
}
...
}
现在我们调用testQueries.UpdateAccount(),传入后台上下文和更新参数。
那么我们要求不能返回任何错误。更新后的account2对象不应为空。
func TestUpdateAccount(t *testing.T) {
...
account2, err := testQueries.UpdateAccount(context.Background(), arg)
require.NoError(t, err)
require.NotEmpty(t, account2)
}
我们将每个字段与 进行比较account2。account1几乎所有字段都应该相同,除了余额,余额应该更改为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)
}
好的,我们来运行这个测试。
通过了!
测试删除帐户
可以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)
...
}
然后,为了确保账户确实已被删除,我们调用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)
}
更准确地说,我们使用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,
}
...
}
然后我们声明列表帐户参数。假设 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)
}
}
我们不允许出现任何错误,并且返回的账户切片的长度应为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
















