Go单元测试笔记

Go单元测试与集成测试

单元测试概述

    Go语言中的单元测试是对程序中最小的功能单元进行正确性验证的一种自动化测试方式。通过引用Go语言中内置的testing包就能编写单元测试了。在Go单元测试中要有以下命名规范:

  1. 1. 测试文件命名规范:单元测试文件一般命名为xxx_test.go,并且与要测试的源代码放在同一个文件夹下。

  2. 2. 测试函数命名规范测试函数命名必须以 Test 开头,后面接上要测试的函数名称。

    新建测试文件后就能开始编写测试代码了,写单元测试对每个函数进行测试,并且与预期的结果进行对比。若与预期结果不符,那么表示测试失败。Testing包提供了一些断言函数用于在测试失败时提供反馈(如t.Error(),t.Fatal(),assert.NoError()等方法)。单元测试中还有性能测试、基准测试、测试覆盖率等选择,用于评估代码的性能、稳定性和覆盖率(评估测试是否全面)。单元测试中有一种模式叫Table-Driven,意思是在测试中处理多个输入和预期输出的情况;核心思想是把易变的数据从稳定的处理数据流程里分离,不直接混杂在if-else/switch-case的多个分支。

在这篇笔记中我们使用Uber公司开源的Mock框架,该框架是基于Go语言中的反射机制实现的,能根据接口自动生成Mock对象,极大的加速了Mock对象的创建过程、提高开发效率。通过下面这条指令来安装:
go install go.uber.org/mock/mockgen@latest
安装完成后通过指定参数生成mock代码,通过 -source来指定接口,-package来指定包名,-destination来指定生成mock代码的路径。下面这条指令是将service层的user.go指定为生成mock代码的接口,将包名设置为svcmocks,并指定生成位置与user.go同一层的mocks文件夹下。mockgen完成后要记得执行go mod tidy整理项目中包的依赖关系。
mockgen -source=./internal/service/user.go -package=svcmocks -destination=./internal/service/mocks/user.mock.go

---- 

go mod tidy

编写单元测试的思路与实现

单元测试简单例子

简单的单元测试就测一个加和减的函数。按照测试文件命名规范,测试文件命名为math_test.go,将测试函数命名为TestAddTestSubtract。通过设置输入值和预期值并进行比较,若二者一致表示单元测试通过。
  1. 1. 需要测试的代码

 // math.go
 func Add(a, b intint {
     return a + b
 }
 
 func Subtract(a, b intint {
     return a - b
 }
  1. 2. 新建测试文件和测试函数下面单元测试使用的是Table Driven的写法,首先是创建数据表格,如输入值inputA inputB,预期值want,然后填充数据,如{1,1,2},{2,3,5}对应上面的表格,最后是执行全部测试用例,对比实际值和预期值。

 // math_test.go
 func TestAdd(t *testing.T) {
     testCases := []struct {
         // 输入值
         intputA int
         intputB int
 
         // 预期值
         want int
     }{
         {112},
         {235},
         {347},
     }
     
   // 执行测试
     for _, tc := range testCases {
         t.Run("Add"func(t *testing.T) {
             if got := Add(tc.intputA, tc.intputB); got != tc.want {
                 t.Errorf("got %d ,want %d", got, tc.want)
             }
         })
     }
 
 }
 
 func TestSubtract(t *testing.T) {
     testCases := []struct {
         intputA int
         intputB int
         want    int
     }{
         {110},
         {321},
     }
 
     for _, tc := range testCases {
         t.Run("Subtract"func(t *testing.T) {
             if got := Subtract(tc.intputA, tc.intputB); got != tc.want {
                 t.Errorf("got %d ,want %d", got, tc.want)
             }
         })
     }
 }

Go单元测试笔记

编写登录/注册业务的单元测试

我们通过用户登录/注册的流程中从Handler -> service -> repository -> DAO层都先测试一遍,模拟需要构建的对象进行该模块的单元测试。首先从Handler开始。

mock Handler

Handler处于后端接收处理http请求的位置,在该层后数据向service->repository->DAO层传递并将执行结果返回给Handler 。对于Handler来说就是处理http请求,通过service返回数据再返回给客户端。单元测试是只针对该最小单元进行正确校验的方式,不能将整个项目跑起来测试运行,所以我们需要模拟发送http请求,模拟service层返回的数据并与预期值进行比较,通过添加断言判断该模块是否正确。
1. 项目中UserHandler的结构体中有UserServiceCodeService,一会需要模拟这两个对象。
type UserHandler struct {
    svc     service.UserService
    codeSvc service.CodeService
}
2. 在BasicProject玩具项目登录的单元测试中,输入侧用户是通过邮箱和密码登录的,需要模拟http请求,Mock UserServiceCodeService来创建UserHandler,在预期侧可以是返回的状态码。所以数据表格会写成这样子:
name     string
// 输入
mock     func(ctrl *gomock.Controller) (service.UserService, service.CodeService)
email    string
password string
reqBuilder func(t *testing.T) *http.Request

// 预期输出
wantCode int
wantBody string
  1. 3. 使用指令生成mock代码。
    mockgen -source=./internal/service/code.go -package=svcmocks -destination=./internal/service/mocks/code.mock.go
    mockgen -source=./internal/service/user.go -package=svcmocks -destination=./internal/service/mocks/user.mock.go
    go mod tidy
  1. 4. 定义mock内容,我们测试登录成功的例子,在mock函数中创建userServcecodeService并返回,同时预期模拟的userService调用Login函数,第一个参数gomock.Any()表示不关心具体值,只关心方法的调用,剩余参数传入邮箱和密码,返回一个空的domain.User结构体和nil错误。
  2. 5. 定义http请求。使用http.NewRequest来创建请求,设置好请求方式POST,请求url路径/v2/users/login,请求参数email":"1@qq.com","password":"123"}和请求头"Content-Type", "application/json"
  3. 6. 定义预期值wantCode;登录成功预期是http.SatatusOK。如此一来我们可以将数据填充成这样:
testCases := []struct{
        name string
      xxxx
      xxxx
}{
  {
    name: "登录成功",
            mock: func(ctrl *gomock.Controller) (service.UserService, service.CodeService) {
                userSvc := svcmocks.NewMockUserService(ctrl)
                userSvc.EXPECT().
                    Login(gomock.Any(), "1@qq.com""123").
                    Return(domain.User{}, nil)
                codeSvc := svcmocks.NewMockCodeService(ctrl)
                return userSvc, codeSvc
            },
            reqBuilder: func(t *testing.T) *http.Request {
                req, err := http.NewRequest(http.MethodPost, "/v2/users/login", bytes.NewReader(
                    []byte(`{"email":"1@qq.com","password":"123"}`)))
                req.Header.Set("Content-Type""application/json")
                assert.NoError(t, err)
                return req
            },
            wantCode: http.StatusOK,
  }
}
  1. 7. 执行单元测试。创建一个测试用例的循环,首先创建一个gomock控制器管理模拟对象,使用该控制器创建userServicecodeService,再使用这两个模拟对象创建一个userHandler。此时还需要新建一个Gin框架,并将userHandler注册到路由当中;一起准备妥当后创建http请求和httptest.NewRecorder。Recorder是一个响应期,用于捕获http处理程序返回的响应,可以记录程序是否正确设置了响应状态码、响应头和响应体。最后执行该请求,设置断言判断该测试是否通过。
for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            // 新建一个控制器,用于管理模拟对象
            ctrl := gomock.NewController(t)
            // 在函数结束时确保资源被正确释放
            defer ctrl.Finish()

            userSvc, codeSvc := tc.mock(ctrl)       // 用控制器模拟userSvc和codeSvc
            hdl := NewUserHandler(userSvc, codeSvc) // 创建一个userHandler

            // 注册路由
            serve := gin.Default()
            hdl.RegisterRoutes(serve)

            // 准备Req和记录的recorder
            req := tc.reqBuilder(t)
            recorder := httptest.NewRecorder()

            //执行
            serve.ServeHTTP(recorder, req)

            //断言
            assert.Equal(t, tc.wantCode, recorder.Code)
        })
    }

Go单元测试笔记

mock service

1. 首先我们来看一眼service层的Login函数是什么样的。函数首先会调用repositoryFindByEmail查询用户是否存在,随后拿着从数据库找到的加密后的用户密码进行对比,若密码正确则用户登录成功,返回domain.User
// service 

type UserService interface {
        Login(ctx context.Context, email string, password string) (domain.User, error)
}

type userService struct {
        repo repository.UserRepository
}

// Login函数
func (svc *userService) Login(ctx context.Context, email string, password string) (u domain.User, err error) {
    u, err = svc.repo.FindByEmail(ctx, email)
    // 用户不存在
    if err == repository.ErrUserNotFound {
      return domain.User{}, ErrInvalidUserOrPassword
    }
    // 对比密码
    if result := bcrypt.ComparePwd(u.Password, password); result != true {
      // 密码错误
      //err = errors.New("passwordError")
      err = ErrInvalidUserOrPassword
      return domain.User{}, err
    }
        return u, nil
}
2. 能看出来输入侧需要Mockrepository.UserRepository,从Login函数看需要提供Context上下文对象,邮箱和密码,预期返回一个domain.Usererror。于是数据表格会写成这样子:
name string
mock func(ctrl *gomock.Controller) repository.UserRepository

// 预期输入
ctx      context.Context
email    sql.NullString
password string

//实际值
wantUser domain.User
wantErr  error
  1. 3. 使用mockgen生成mock代码
    mockgen -source=./internal/repository/user.go -package=repomocks -destination=./internal/repository/mocks/user.mock.go
    mockgen -source=./internal/repository/code.go -package=repomocks -destination=./internal/repository/mocks/code.mock.go
    go mod tidy
  1. 4. 定义mock内容,我们测试登录成功的例子,因此我们期望repository返回一个正确的domain.User对象,包括邮箱、正确经过加密的密码,手机号码。这些数据可以从之前数据库中拿到,我之前注册了邮箱是1@qq.com,密码是123的用户(加密前),因此Mock时要从数据库中拿到那一长串加密后的密码,与输入值密码进行比对。如果要测试密码错误的例子,将输入侧的password写错就可以了。一通操作下来测试用例可以写成这样子。
{
            name: "登录成功",
            mock: func(ctrl *gomock.Controller) repository.UserRepository {
                // 使用repomocks包创建一个模拟的UserRepository
                repo := repomocks.NewMockUserRepository(ctrl)
                // 设置模拟UserRepository的FindByEmail方法的预期行为,期望FindByEmail方法被调用时,传入的邮箱为 "1@qq.com"
                // 返回值是一个具有特定字段值的 domain.User对象
                repo.EXPECT().
                    FindByEmail(gomock.Any(), "1@qq.com").
                    Return(domain.User{
                        Email:    "1@qq.com",
                        Password: "$2a$10$GFSbeRa5RVX912wj0SGZHuGjeJuDEB3eOlXDv8GZ/yAaP4rKY9Roq",
                        Phone:    "123",
                    }, nil)
                return repo
            },
            email:    sql.NullString{String: "1@qq.com", Valid: true},
            password: "123",

            wantUser: domain.User{
                Email:    "1@qq.com",
                Password: "$2a$10$GFSbeRa5RVX912wj0SGZHuGjeJuDEB3eOlXDv8GZ/yAaP4rKY9Roq",
                Phone:    "123",
            },
        },
        {
            name: "密码或用户名错误",
            mock: func(ctrl *gomock.Controller) repository.UserRepository {
                repo := repomocks.NewMockUserRepository(ctrl)
                repo.EXPECT().
                    FindByEmail(gomock.Any(), "1@qq.com").
                    Return(
                        domain.User{
                            Email:    "1@qq.com",
                            Password: "$2a$10$GFSbeRa5RVX912wj0SGZHuGjeJuDEB3eOlXDv8GZ/yAaP4rKY9Roq",
                            Phone:    "123",
                        }, nil)
                return repo
            },
            email:    sql.NullString{String: "1@qq.com", Valid: true},
            password: "123123123",
            wantErr:  ErrInvalidUserOrPassword,
        },
  1. 5. 执行测试。新建gomock控制器管理模拟对象,模拟repository然后创建一个UserService。执行测试时调用Login函数,做domian.Usererror的断言。
for _, tc := range testCases {
    t.Run(tc.name, func(t *testing.T) {
       ctrl := gomock.NewController(t)
       defer ctrl.Finish()

       // 模拟UserRepository 来创建 UserSerivce
       repo := tc.mock(ctrl)
       svc := NewUserService(repo)

       // 调用Login函数
       user, err := svc.Login(tc.ctx, tc.email.String, tc.password)
       assert.Equal(t, tc.wantUser, user)
       assert.Equal(t, tc.wantErr, err)
    })
}

Go单元测试笔记

mock repository

repository层测试FindById函数。因为同时使用了MysqlRedis,所以CacheUserRepository里有UserDAOUserCacheFindById要做的事情,就是通过id查找用户时先到redis看看有没有,没有的话就去Mysql找找,找到了这个用户就设置到Redis缓存中。这里就测试查询成功,但未命中缓存的结果。那么我们预期就是UserCache没有查询到用户,UserDAO查询到了该用户,然后UserCache将查询回来的值成功写到缓存中。
// repository
var (
    ErrDuplicateUser = dao.ErrDuplicateEmail
    ErrUserNotFound  = dao.ErrRecordNotFound
)

type UserRepository interface {
    FindById(ctx context.Context, id string) (domain.User, error)
}

type CacheUserRepository struct {
    dao   dao.UserDAO
    cache cache.UserCache
}

func (repo *CacheUserRepository) FindById(ctx context.Context, id string) (domain.User, error) {
    // 首先找一下redis有没有
    du, err := repo.cache.Get(ctx, id)
    if err == nil {
        return du, nil
    }
    u, err := repo.dao.FindById(ctx, id)
    if err != nil {
        return domain.User{}, err
    }
    du = repo.toDomain(u)

    if err = repo.cache.Set(ctx, du); err != nil {
        log.Println("set cache error", err)
    }
    return du, nil
}
  1. 1. 定义数据表格。现在轻车熟路就知道我们遇到Mock一个带有UserDAOUserCache的控制器,输入侧需要Context上下文,用户id,查找成功后我们预期返回一个domain.User并没有错误。所以可以这样写:
name string
mock func(ctrl *gomock.Controller) (cache.UserCache, dao.UserDAO)

ctx context.Context
id  string

wantUser domain.User
wantErr  error
  1. 2. 执行mockgen生成代码
    mockgen -source=./internal/repository/cache/user.go -package=cachemocks -destination=./internal/repository/cache/mocks/user.mock.go
    mockgen -source=./internal/repository/cache/code.go -package=cachemocks -destination=./internal/repository/cache/mocks/code.mock.go
    mockgen -source=./internal/repository/dao/user.go -package=daomocks -destination=./internal/repository/dao/mocks/user.mock.go
    go mod tidy
  1. 3. 定义Mock数据。如前面所说的那样,先创建Mock的UserDAOUserCacheUserCacheGET方法无法再缓存中获取该用户,随后设置预期UserDAO通过FindById查到该用户并返回,最后预期UserCache成功设置缓存设置。因为要测试成功获取查找用户的例子,所以wantUser和预期FindById返回的用户数据是要一致的。可以写成下面这样:
{
            name: "查询成功 但未命中缓存",
            mock: func(ctrl *gomock.Controller) (cache.UserCache, dao.UserDAO) {
                id := "123456"
                d := daomocks.NewMockUserDAO(ctrl)
                c := cachemocks.NewMockUserCache(ctrl)
                // 未命中缓存
                c.EXPECT().Get(gomock.Any(), id).
                    Return(domain.User{}, cache.ErrKeyNotExist)
                // 查询成功
                d.EXPECT().FindById(gomock.Any(), id).
                    Return(dao.User{
                        ID:       id,
                        Email:    sql.NullString{String: "1@qq.com", Valid: true},
                        Password: "123",
                        Phone:    sql.NullString{String: "123", Valid: true},
                        Birthday: 0,
                        Nickname: "",
                        Aboutme:  "夏天夏天悄悄过去留下小秘密",
                    }, nil)

                // 设置缓存成功
                c.EXPECT().Set(gomock.Any(), domain.User{
                    ID:       id,
                    Email:    "1@qq.com",
                    Password: "123",
                    Phone:    "123",
                    Aboutme:  "夏天夏天悄悄过去留下小秘密",
                }).Return(nil)
                return c, d
            },
            id:  "123456",
            ctx: context.Background(),
            wantUser: domain.User{
                ID:       "123456",
                Email:    "1@qq.com",
                Password: "123",
                Phone:    "123",
                Aboutme:  "夏天夏天悄悄过去留下小秘密",
            },
            wantErr: nil,
        },
  1. 4. 执行测试。跟其他部分都一样,对预期和实际值断言。
ctrl := gomock.NewController(t)
defer ctrl.Finish()

uc, ud := tc.mock(ctrl)
svc := NewCacheUserRepository(ud, uc)
user, err := svc.FindById(tc.ctx, tc.id)
assert.Equal(t, tc.wantErr, err)
assert.Equal(t, tc.wantUser, user)

Go单元测试笔记

mock dao

在最后写一下数据库写入的测试,测试Insert函数。在这里我们需要用到DATA-DOG的一个开源的Mock数据库的库。Datadog是一家总部位于美国纽约的SaaS公司,成立于2010年。它提供云监控服务,包括基础架构监视、应用程序性能监视、日志管理、网络性能监视等。Datadog的平台能够集成和自动化客户的整个技术堆栈的监控,以提供实时的观察能力
go get github.com/DATA-DOG/go-sqlmock
// DAO

type GORMUserDAO struct {
    db *gorm.DB
}

func (dao *GORMUserDAO) Insert(ctx context.Context, u User) (err error) {
    err = dao.db.WithContext(ctx).Create(&u).Error
    if me, ok := err.(*mysql.MySQLError); ok {
        const duplicateErr uint16 = 1062
        if me.Number == duplicateErr {
            // 用户冲突,邮箱冲突
            return ErrDuplicateEmail
        }
    }
    return err
}
  1. 1. 定义数据表格,输入侧需要提供context上下文,User,Mocksql.DB
name string
mock func(t *testing.T) *sql.DB
user User
ctx  context.Context

wantErr error
  1. 2. 定义Mock内容。在该测试中我们需要创建爱你数据库连接模拟对象,并创建模拟操作数据库的结果。
{
  name: "写入成功",
  mock: func(t *testing.T) *sql.DB {
  db, mock, err := sqlmock.New() //创建数据库连接模拟对象
  assert.NoError(t, err) // 断言是否有错误
  mockRes := sqlmock.NewResult(1231// 创建模拟的数据库操作结果
  // 这边要求传入的是 sql 的正则表达式
  mock.ExpectExec("INSERT INTO .*").
  WillReturnResult(mockRes) // 设置模拟的返回结果
  return db
  },
  ctx:  context.Background(),
  user: User{Aboutme: "夏天夏天悄悄过去"},
},
  1. 3. 执行测试。在这里有几点需要注意:
  2. 需要设置跳过初始化版本,尽量减少依赖;
  3. 需要设置禁用自动Ping,GORM 在初始化后会自动执行一次数据库 Ping 操作,以检查数据库的可用性。在单元测试中,我们通常会使用模拟的数据库或内存数据库,而不是真正的 MySQL 数据库,避免不必要的数据库操作。
  4. 要设置跳过默认事务。默认情况下,GORM 的写操作(创建、更新、删除)会在事务中执行,以确保数据一致性。在单元测试中,我们可能只关心 DAO 层的逻辑,而不需要默认的事务支持。
sqlDB := tc.mock(t)
db, err := gorm.Open(mysql.New(mysql.Config{
Conn:                      sqlDB,
SkipInitializeWithVersion: true,
}), &gorm.Config{
DisableAutomaticPing:   true,
SkipDefaultTransaction: true,
})
assert.NoError(t, err)
dao := NewUserDAO(db)
err = dao.Insert(tc.ctx, tc.user)
assert.Equal(t, tc.wantErr, err)

Go单元测试笔记

写在最后

本人是新手小白,如果这篇笔记中有任何错误或不准确之处,真诚地希望各位读者能够给予批评和指正。谢谢!练习的代码放在这里–↓
https://github.com/FengZeHe/LearnGolang/tree/main/project/BasicProjectV2


原文始发于微信公众号(ProgrammerHe):Go单元测试笔记

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/273244.html

(0)
明月予我的头像明月予我bm

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!