0


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"funcTestCreateAccount(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

funcTestMain(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")funcTestMain(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")funcTestCreateAccount(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")funcinit(){
    rand.Seed(time.Now().UnixNano())}

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

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

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

var alphabet ="abcdefghijklmopqrstuvwxyz"funcRandomString(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字母字符串,后面,我们会改进

随机生成中文的姓名
funcRandomOwner()string{returnRandomString(6)}

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

funcRandomMoney()int64{returnRandomInt(0,1000)}

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

"RMB", "USD", "EUR", "CAD"
funcRandomCurrency()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"funcinit(){
    rand.Seed(time.Now().UnixNano())}/**
* 生成随机整数
 */funcRandomInt(min, max int64)int64{return min + rand.Int63n(max-min+1)}/**
* 生成随机字符串
 */funcRandomString(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()}/**
* 随机生成账户所有者
 */funcRandomOwner()string{returnRandomString(6)}/**
* 随机生成金额
 */funcRandomMoney()int64{returnRandomInt(0,1000)}/**
* 随机生成币种
 */funcRandomCurrency()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:
    dockerexec -it postgres14 createdb --username=root --owner=root simple_bank

dropdb:
    dockerexec -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

来到项目终端,运行:

maketest

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

注意:多次运行

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")funccreateRandomAccount(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
}funcTestCreateAccount(t *testing.T){createRandomAccount(t)}funcTestGetAccount(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)

然后,定义参数,如下:

funcTestUpdateAccount(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

也可以类似的实现:

funcTestDeleteAccount(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

, 因为这是个列表,所以,我们多创建几个账户。

funcTestListAccounts(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")funccreateRandomAccount(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
}funcTestCreateAccount(t *testing.T){createRandomAccount(t)}funcTestGetAccount(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)}funcTestUpdateAccount(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)}funcTestDeleteAccount(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)}funcTestListAccounts(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{"佳","彤","自","怡","颖","宸","雅","微","羽","馨","思","纾","欣","元","凡","晴","玥","宁","佳","蕾","桑","妍","萱","宛","欣","灵","烟","文","柏","艺","以","如","雪","璐","言","婷","青","安","昕","淑","雅","颖","云","艺","忻","梓","江","丽","梦","雪","沁","思","羽","羽","雅","访","烟","萱","忆","慧","娅","茹","嘉","幻","辰","妍","雨","蕊","欣","芸","亦"}funcRandomChineseFirstname(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()}/**
* 生成随机的中文姓名
 */funcRandomChineseOwner()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操作数据库事务的方法

标签: golang go 单元测试

本文转载自: https://blog.csdn.net/8665048/article/details/124042722
版权归原作者 宝码 所有, 如有侵权,请联系我们删除。

“Golang后端学习笔记 — 5. 使用Golang为数据库CRUD写单元测试”的评论:

还没有评论