在上一节中,学习了如何生成自动Golang CRUD
代码,本节将学习如何为这些CRUD
操作编写单元测试。
1. 测试 CreateAccount
从account.sql.go
里面的CreateAccount
开始,在项目的db/sqlc
目录下新建一个文件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
将运行结果告诉测试运行器。
点击,run test
运行一下,看到报错了,这是因为database/sql
包只提供了访问数据库的通用接口,它需要和数据库驱动结合使用,才能与指定的数据库进行连接。
在项目终端里,安装一下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
运行它
可以看到ok
测试通过了,打开navicat
连到数据库看一下accounts
表,可以看到数据也插入进来了。
也可以点击,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
所有的单元测试都通过了,让我们再打开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操作数据库事务的方法