Golang后端学习笔记 — 5. 使用Golang为数据库CRUD写单元测试

在上一节中,学习了如何生成自动Golang CRUD代码,本节将学习如何为这些CRUD操作编写单元测试。

1. 测试 CreateAccount

account.sql.go里面的CreateAccount开始,在项目的db/sqlc目录下新建一个文件account_test.go
account_test.go

Golang 中有个约定,就是把测试文件和代码放在同一个文件夹内,并且测试文件的命名要以 test 后缀结尾。

这个测试文件的包名是 db, 在文件里定义个函数 TestCreateAccount

Go中的每个单元测试函数都必须以 Test 开头,并且以 testing.T 作为输入参数。

将使用这个 T 对象来管理测试状态。代码如下:

package db

import "testing"

func TestCreateAccount(t *testing.T) {

}

我们需要一个数据库连接才能与数据库交互,所以,为了编写单元测试,必须先设置连接和查询对象(Queries object), 在db/sqlc下再新建个文件 main_test.go,在这里执行相关操作。

定义个testQueries全局变量,因为在所有的单元测试中都会用到它。

var testQueries *Queries

定义函数TestMain,以testing.M类型作为参数

Golang 约定 TestMain 函数是所有单元测试的入口

package db

import "testing"

var testQueries *Queries

func TestMain(m *testing.M) {

}

在这里先创建与数据库的连接,目前先用硬编码的方式把dbDriver和dbSource作为常量,后面我们将改进它

package db

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

var testQueries *Queries

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

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

m.Run() 代表开始运行单元测试,这个函数返回一个退出码,然后os.Exit将运行结果告诉测试运行器。
m.Run返回值
点击,run test运行一下,看到报错了,这是因为database/sql包只提供了访问数据库的通用接口,它需要和数据库驱动结合使用,才能与指定的数据库进行连接。
运行Test
在项目终端里,安装一下postgres的驱动

go get github.com/lib/pq

打开项目里的go.mod文件,可以看到增加了github.com/lib/pq,后面有个indirect注释,这是因为我们还没有在代码里面导入和使用它。

回到main_test.go文件,把github.com/lib/pq导入进来,这里的代码并没有直接使用到它,直接保存的话,会被格式化掉,需要在前面加个 _ , 如下:

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

	_ "github.com/lib/pq"
)

再次,对TestMain run test,可以看到执行结果成功了!
测试成功
在项目终端执行下面的命令,清理一下依赖项,之后,可以看到go.mod文件里的indirect注释消失了,因为我们的代码里已经使用到了它。

go mod tidy

现在,正式开始为CreateAccount函数编写第一个单元测试,打开account_test.go,填充一下TestCreateAccount函数的内容。

首先,声明一个新的参数:CreateAccountParams:

arg := CreateAccountParams{
		Owner: "张三",
		Balance: 100,
		Currency: "RMB",
}

然后,调用testQueries.CreateAccount()传入一个后台上下文和arg:

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

这里的testQueries就是之前我们在main_test.go里面定义的那个全局变量。返回一个account对象或一个err

为了检测测试结果,推荐使用testify库,https://github.com/stretchr/testify,安装一下,在项目终端里执行:

go get github.com/stretchr/testify

装完后,在account_test.go里导入这个包"github.com/stretchr/testify/require",然后使用:

require.NoError(t, err)

它会检查错误是否必须为nil,如果不是,则单元测试将失败。接下来,我们要求返回的account不能是空的对象:

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)

另外,还要检查一下账户的ID是否是由postgres自动生成的,必须不是0:

require.NotZero(t, account.ID)

最后,看一下created_at也应该是当前的时间戳,不为0,完整的代码如下:

package db

import (
	"context"
	"testing"

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

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

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

	// err 必须为 nil
	require.NoError(t, err)

	// account 不能为空对象
	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)

	// 检查ID是否自动生成的,必须不为0
	require.NotZero(t, account.ID)

	require.NotZero(t, account.CreatedAt)
}

点击,run test运行它
运行Run Test
可以看到ok测试通过了,打开navicat连到数据库看一下accounts表,可以看到数据也插入进来了。
查看postgres数据库
也可以点击,Run package tests来运行这个包中的所有单元测试,目前只有1个测试,测试代码覆盖率也提示出来了。
测试代码覆盖率
打开account.sql.go,可以看到被测试通过的代码标记成了绿色背景。
测试通过的代码
红色背景的代码,表示单元测试没有被覆盖到。
未被测试覆盖的代码
之后,我们将写更多的单元测试来覆盖它们。

2. 生成测试数据

有一种更好的方法来生成测试数据,而不是像之前硬编码那样手动填写张三这样的测试数据。

通过生成随机数据,我们将节省大量的时间来确定要使用的值,代码也会更简洁易懂,并且由于数据是随机的,它将帮我们避免多个单元测试之间的冲突,比如,数据库中某个字段有唯一约束。

好的,让我们在项目根目录下创建个新目录util,在这个目录里新建random.go文件,包名就用package util

首先,需要编写一个特殊的函数init(),这个函数会在第一次使用包时自动调用。我们将通过调用rand.Seed()来设置随机生成器的种子值,参数就用当前的时间time.Now().UnixNano(),代码如下:

package util

import (
	"math/rand"
	"time"
)

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

先写个生成随机整数的函数RandomInt

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

接下来,再编写一个生成随机字符串的函数,为此,需要声明一个包含所有字符串的字母表,简单起见,只用了26个小写字母:

var alphabet = "abcdefghijklmopqrstuvwxyz"

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

这样,我们可以编写随机生成账户所有者的函数了,这里我们只是返回一个随机的6字母字符串,后面,我们会改进随机生成中文的姓名

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

同样,定义一个生成随机金额的函数,假设它是0到1000的整数

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

还需要一个生成随机币种的函数,这里我们只使用4种货币,"RMB", "USD", "EUR", "CAD"

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

完整random.go代码如下:

package util

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

var alphabet = "abcdefghijklmopqrstuvwxyz"

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

/**
* 生成随机整数
 */
func RandomInt(min, max int64) int64 {
	return min + rand.Int63n(max-min+1)
}

/**
* 生成随机字符串
 */
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()
}

/**
* 随机生成账户所有者
 */
func RandomOwner() string {
	return RandomString(6)
}

/**
* 随机生成金额
 */
func RandomMoney() int64 {
	return RandomInt(0, 1000)
}

/**
* 随机生成币种
 */
func RandomCurrency() string {
	currencies := []string{"RMB", "USD", "EUR", "CAD"}
	n := len(currencies)
	return currencies[rand.Intn(n)]
}

好了,回到我们的account_test.go文件:

  • "张三"替换为util.RandomOwner()
  • 100替换为util.RandomMoney()
  • RMB替换为util.RandomCurrency()
    如下:
	arg := CreateAccountParams{
		Owner:    util.RandomOwner(),
		Balance:  util.RandomMoney(),
		Currency: util.RandomCurrency(),
	}

再次,run test,刷新navicat,可以看到新插入的数据是随机生成的了。

现在,我们再向Makefile文件里添加一个测试命令

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

-v 表示输出日期,-cover 测量代码覆盖率,由于我们的项目将会有多个包,所以加上参数./...运行所有包下面的单元测试。目前的Makefile

postgres:
	docker run --name postgres14 -e POSTGRES_PASSWORD=123456 -e POSTGRES_USER=root -p 5432:5432 -d postgres:14-alpine

createdb:
	docker exec -it postgres14 createdb --username=root --owner=root simple_bank

dropdb:
	docker exec -it postgres14 dropdb simple_bank

migrateup:
	migrate --path db/migration --database="postgresql://root:123456@localhost:5432/simple_bank?sslmode=disable" -verbose up

migratedown:
	migrate --path db/migration --database="postgresql://root:123456@localhost:5432/simple_bank?sslmode=disable" -verbose down

sqlc:
	sqlc generate

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

.PHONY: postgres, createdb, dropdb, migrateup, migratedown, sqlc, test

来到项目终端,运行:

make test

可以看到,运行完成测试时打印出了详细的日志。
测试日志

注意:多次运行make test,回从缓存中执行,如果想不从缓存中执行,可以加上-count=1参数,如:go test -v -cover ./... -count=1

3. 编写其他的CRUD单元测试

GetAccount开始,在account_test.go文件里新增TestGetAccount函数,这里需要知道,要测试其他的CRUD操作,都必须先创建一个Account,我们需要确保它们彼此独立。为什么需要这样,因为,如果我们有几百个相互依赖的单元测试,这将变得很难维护。

最不希望的是,修改其中一个单元测试而影响到其他的一些测试结果,出于这个原因,每个单元测试都应该创建自己的Account数据,为了避免代码重复,我们编写一个单独的函数来随机创建Account

把之前的代码重构一下,如下:

package db

import (
	"context"
	"simplebank/util"
	"testing"
	"time"

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

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

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

	// err 必须为 nil
	require.NoError(t, err)

	// account 不能为空对象
	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)

	// 检查ID是否自动生成的,必须不为0
	require.NotZero(t, account.ID)

	require.NotZero(t, account.CreatedAt)

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

func TestGetAccount(t *testing.T) {
	account1 := createRandomAccount(t)
	// 查询 account, 参数为 account1 的 id,把结果给 account2
	account2, err := testQueries.GetAccount(context.Background(), account1.ID)
	// 这里应该没错误
	require.NoError(t, err)
	// account2 也必须不是空的
	require.NotEmpty(t, account2)

	// account2 的所有字段的值应该和 account1 所有字段的值相同
	require.Equal(t, account2.ID, account1.ID)
	require.Equal(t, account2.Owner, account1.Owner)
	require.Equal(t, account2.Balance, account1.Balance)
	require.Equal(t, account2.Currency, account1.Currency)
	require.WithinDuration(t, account1.CreatedAt, account2.CreatedAt, time.Second)
}

TestGetAccount 运行一下测试run test ,可以看到测试通过了!

接着,编写TestUpdateAccount(),首先,创建个随机账户

account1 := createRandomAccount(t)

然后,定义参数,如下:

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)

	// 比较 account2 和 account1, 除了 Balance,其他的字段都应该相同
	require.Equal(t, account2.ID, account1.ID)
	require.Equal(t, account2.Owner, account1.Owner)
	// 这里使用 arg.Balance 和 account2.Balance 比较
	require.Equal(t, account2.Balance, arg.Balance)
	require.Equal(t, account2.Currency, account1.Currency)
	require.WithinDuration(t, account1.CreatedAt, account2.CreatedAt, time.Second)
}

再次运行这个函数的单元测试,可以看到,也测试通过了!

TestDeleteAccount也可以类似的实现:

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)
	// 更准确的说,错误应该是 sql.ErrNoRows
	require.EqualError(t, err, sql.ErrNoRows.Error())
	// account2 也应该是空的
	require.Empty(t, account2)
}

运行这个函数的单元测试 run test,测试通过!

最后一个,测试ListAccounts, 因为这是个列表,所以,我们多创建几个账户。

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)
	// accounts 切片的长度为 5
	require.Len(t, accounts, 5)

	// 变量 accounts, 其中的每个 account 都不能为空
	for _, account := range accounts {
		require.NotEmpty(t, account)
	}
}

运行这个函数的单元测试 run test,passed!

运行 run package tests
run package tests
所有的单元测试都通过了,让我们再打开account.sql.go,里面的函数都被单元测试覆盖了,变成了绿色背景。
完成的 account_test.go :

package db

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

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

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

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

	// err 必须为 nil
	require.NoError(t, err)

	// account 不能为空对象
	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)

	// 检查ID是否自动生成的,必须不为0
	require.NotZero(t, account.ID)

	require.NotZero(t, account.CreatedAt)

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

func TestGetAccount(t *testing.T) {
	account1 := createRandomAccount(t)
	// 查询 account, 参数为 account1 的 id,把结果给 account2
	account2, err := testQueries.GetAccount(context.Background(), account1.ID)
	// 这里应该没错误
	require.NoError(t, err)
	// account2 也必须不是空的
	require.NotEmpty(t, account2)

	// account2 的所有字段的值应该和 account1 所有字段的值相同
	require.Equal(t, account2.ID, account1.ID)
	require.Equal(t, account2.Owner, account1.Owner)
	require.Equal(t, account2.Balance, account1.Balance)
	require.Equal(t, account2.Currency, account1.Currency)
	require.WithinDuration(t, account1.CreatedAt, account2.CreatedAt, time.Second)
}

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)

	// 比较 account2 和 account1, 除了 Balance,其他的字段都应该相同
	require.Equal(t, account2.ID, account1.ID)
	require.Equal(t, account2.Owner, account1.Owner)
	// 这里使用 arg.Balance 和 account2.Balance 比较
	require.Equal(t, account2.Balance, arg.Balance)
	require.Equal(t, account2.Currency, account1.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)
	// 更准确的说,错误应该是 sql.ErrNoRows
	require.EqualError(t, err, sql.ErrNoRows.Error())
	// account2 也应该是空的
	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)
	// accounts 切片的长度为 5
	require.Len(t, accounts, 5)

	// 变量 accounts, 其中的每个 account 都不能为空
	for _, account := range accounts {
		require.NotEmpty(t, account)
	}
}

4. 随机生成中文姓名的测试数据

前面,我们生成了英文的 Owner,中文环境下,有中文的测试数据不是更好,这里我们编写一下这部分代码。

打开random.go文件,增加随机生成中文姓名的函数:

var lastNames = []string{"李", "王", "张", "刘", "陈", "杨", "黄", "赵", "周", "吴", "徐", "孙", "朱", "马", "胡", "郭", "林", "何", "高", "梁", "郑", "罗", "宋", "谢", "唐", "韩", "曹", "许", "邓", "萧", "冯", "曾", "程", "蔡", "彭", "潘", "袁", "於", "董", "余", "苏", "叶", "吕", "魏", "蒋", "田", "杜", "丁", "沈", "姜", "范", "江", "傅", "钟", "卢", "汪", "戴", "崔", "任", "陆", "廖", "姚", "方", "金", "邱", "夏", "谭", "韦", "贾", "邹", "石", "熊", "孟", "秦", "阎", "薛", "侯", "雷", "白", "龙", "段", "郝", "孔", "邵", "史", "毛", "常", "万", "顾", "赖", "武", "康", "贺", "严", "尹", "钱", "施", "牛", "洪", "龚"}
var maleNames = []string{"豪", "言", "玉", "意", "泽", "彦", "轩", "景", "正", "程", "诚", "宇", "澄", "安", "青", "泽", "轩", "旭", "恒", "思", "宇", "嘉", "宏", "皓", "成", "宇", "轩", "玮", "桦", "宇", "达", "韵", "磊", "泽", "博", "昌", "信", "彤", "逸", "柏", "新", "劲", "鸿", "文", "恩", "远", "翰", "圣", "哲", "家", "林", "景", "行", "律", "本", "乐", "康", "昊", "宇", "麦", "冬", "景", "武", "茂", "才", "军", "林", "茂", "飞", "昊", "明", "明", "天", "伦", "峰", "志", "辰", "亦"}
var femaleNames = []string{"佳", "彤", "自", "怡", "颖", "宸", "雅", "微", "羽", "馨", "思", "纾", "欣", "元", "凡", "晴", "玥", "宁", "佳", "蕾", "桑", "妍", "萱", "宛", "欣", "灵", "烟", "文", "柏", "艺", "以", "如", "雪", "璐", "言", "婷", "青", "安", "昕", "淑", "雅", "颖", "云", "艺", "忻", "梓", "江", "丽", "梦", "雪", "沁", "思", "羽", "羽", "雅", "访", "烟", "萱", "忆", "慧", "娅", "茹", "嘉", "幻", "辰", "妍", "雨", "蕊", "欣", "芸", "亦"}

func RandomChineseFirstname(names []string, wordNum int64) string {
	n := len(names)
	var sb strings.Builder
	for i := 1; i < int(wordNum); i++ {
		sb.WriteString(names[rand.Intn(n)])
	}
	return sb.String()
}

/**
* 生成随机的中文姓名
 */
func RandomChineseOwner() string {
	n := len(lastNames)
	lastname := lastNames[rand.Intn(n)]

	// 随机男女
	gender := RandomInt(0, 1)
	// 随机几个字的名,2个或3个
	len := RandomInt(2, 3)

	var firstname = ""
	if gender == 0 {
		firstname = RandomChineseFirstname(femaleNames, len)
	} else {
		firstname = RandomChineseFirstname(maleNames, len)
	}

	return lastname + firstname
}

之后,再把account_test.go文件里面的util.RandomOwner(),换成util.RandomChineseOwner(),如下:

	arg := CreateAccountParams{
		Owner:    util.RandomChineseOwner(),
		Balance:  util.RandomMoney(),
		Currency: util.RandomCurrency(),
	}

在项目终端里执行 make test,完事之后,看一下数据库,测试通过没问题,并且也生成中文姓名的测试数据了。
中文姓名的测试数据
好了,本节内容学完了。下节学习Golang操作数据库事务的方法

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值