Eagle

代码最佳实践 - 组件篇

代码最佳实践 - 组件篇

配置组件

日志组件

MySQL

gorm model定义

  • gorm的model的字段的定义,尽量用 golang 原生的基本类型

gorm方法换行

  • gorm每个方法调用后换行,看着比较清晰
user := query.User
users, err := user.WithContext(ctx).
	Select(user.ID, user.Username, user.Age).
	Where(user.ID.Eq(uid)).
	Find()

SQL列表查询优先使用in关键字

用gorm编写query的查询条件,当某个查询条件是列表时,应当优先采用in关键字,而不是拼接多个or关键字

// good case
// IN
db.Where("user_id in ?", []int64{123, 456}).Find(&users)
// SELECT * FROM users WHERE user_id IN (123, 456);

dal/dao 层模糊查询写法

  • GORM 的查询并没有专门的模糊查询方法,需要自己传入的时候拼接%,形如 %xx%
  • 使用 "%" + str + "%" 而不是 fmt.Spinrtf("%%%s%%", str)
// good case
db.Where("username LIKE ?", "%" + str + "%").Find(&users)

// bad case
db.Where("username LIKE ?", fmt.Sprintf("%%%s%%", str)).Find(&users)

gorm 读写分离

  • 在RDS的使用中, gorm v2 支持读写分离,写入 DB 后立刻读取,大概率会出现读失败(同步延迟)。在读写分离场景下,对一致性敏感的场景,可以显示指定读的节点为主节点(master)
  • GORM V2 提供了多数据库及读写分离等的支持,复杂配置请参考 https://gorm.io/docs/dbresolver.html

gorm 事务

  • 常见的事务设计在进入事务临界区后,只有commit或rollback才会释放事务锁。
  • 在代码中如果对异常场景遗漏了rollback,可能会导致事务锁无法释放。gorm v2 推荐使用Transaction代替Begin/Rollback,通过 Begin, Commit, Rollback 来管理事务极易因为过早 return 等问题导致事务未正常处理。
  • 在代码逻辑中未能正确commit,也会导致事务锁无法释放。
  • 在数据库中,事务锁直到关闭数据库连接或连接超时时候才会把对应事务回滚, 而大量的无法释放的事务锁会导致数据库链接雪崩。

rollback必须要覆盖所有异常场景

// good case 1 (推荐)
// 推荐使用Transaction方式来管理事务,由于手动调用 Commit 和 Rollback
q := query.Use(db)

q.Transaction(func(tx *query.Query) error {
  if _, err := tx.User.WithContext(ctx).Where(tx.User.ID.Eq(100)).Delete(); err != nil {
    return err
  }
  if _, err := tx.Article.WithContext(ctx).Create(&model.User{Name:"modi"}); err != nil {
    return err
  }
  return nil
})

// good case 2
q := query.Use(db)

func doSomething(ctx context.Context, users ...*model.User) (err error) {
    tx := q.Begin()
    defer func() {
        if r := recover(); r != nil {
            _ = tx.Rollback()
        }
        if err != nil {
            _ = tx.Rollback()
        }
    }()

    err = tx.User.WithContext(ctx).Create(users...)
    if err != nil {
        return
    }

    // ...

    return tx.Commit()
}

// bad case
q := query.Use(db)

func doSomething(ctx context.Context, users ...*model.User) (err error) {
    tx := q.Begin()
    defer func() {
        if r := recover(); r != nil {
            _ = tx.Rollback()
        }
        // 没有下面的 Rollback,其他地方也没有对非nil的err进行处理
        //if err != nil {
        //    _ = tx.Rollback()
        // }
    }()

    err = tx.User.WithContext(ctx).Create(users...)
    if err != nil {
        return
    }

    // ...

    return tx.Commit()
}

参看官方文档: https://gorm.io/gen/transaction.html
https://gorm.io/docs/transactions.html

安全优雅的执行复杂 SQL

开发过程中大部分简单的查询我们可以使用 ORM 的基础方法组合 (例如 WhereOrderGroup 组合等), 但是一些复杂的操作还是直接使用定制的复杂 SQL 更加方便。

手写可能带来的问题:

  1. 大量零值逻辑判断、条件 check检查散落在各处,不整洁,可维护性差
  2. 最重要的,SQL拼接很容易导致安全风险,导致 SQL 注入等安全漏洞

复杂的逻辑判断

场景:在管理后台进行查询操作时候,可能需要对参数做一些复杂的逻辑判断,除了非零的判断外,还可能需要判断多种状态。
由于参数不确定安全性上很难做安全预编译,如果直接字符串拼接到SQL就可能引起SQL注入。

// 定义查询条件结构体
type SearchCondition struct {
   UserID         int64
   Username       string
   Phone          string
   Email          string
   Level          int
   Status         int
   CreatedAtGte   time.Time
   CreatedAtLte   time.Time
   OrderBy        string
}

// {{}} 里面是用go模板的语法编写的,然后就可以用go的语法进行编码了,比如:strings.ToLower 或 cond.CreatedAtGte.IsZero
type Search interface {
    //select * from @@table
    //    {{where}}
    //        {{if cond.UserID > 0}} id = @cond.UserID {{end}}
    //        {{if cond.Username != ""}} and username like concat('%',@cond.Username,'%') {{end}}
    //        {{if cond.Phone != ""}} and phone=@cond.Phone {{end}}
    //        {{if cond.Email != ""}} and email=@cond.Email {{end}}
    //        {{if cond.Level > 0 && cond.Level < 10}} and level = @cond.Level {{end}}
    //        {{if cond.Status > 0}} id = @cond.Status {{end}}
    //        {{if cond.CreatedAtGte.IsZero()}} and created_at >=@cond.CreatedAtGte {{end}}
    //        {{if cond.CreatedAtLte.IsZero()}} and created_at <=@cond.CreatedAtLte {{end}}
    //    {{end}}    
    //    {{if cond.OrderBy == "created_at" }}
    //        order by @@cond.OrderBy
    //    {{else}}
    //        order by id desc
    //    {{end}}
   SearchByConditions(cond SearchCondition) ([]*gen.T, error)
}

// 生成代码
// cmd/gen/generate.go
...
func main() {
    g := gen.NewGenerator(gen.Config{
        OutPath: "../internal/dal/db/dao",
    })
    g.UseDB(db)
    
    // 将 search 自定义的方法附加到 User 上
    g.ApplyInterface(func(Search) {}, model.User{})
    
    g.Execute()
}      

// 生成的Go代码参考如下:

// select * from @@table
//
//	{{where}}
//	    {{if cond.UserID > 0}} id = @cond.UserID {{end}}
//	    {{if cond.Username != ""}} and username like concat('%',@cond.Username,'%') {{end}}
//	    {{if cond.Phone != ""}} and phone=@cond.Phone {{end}}
//	    {{if cond.Email != ""}} and email=@cond.Email {{end}}
//	    {{if cond.Level > 0 && cond.Level < 10}} and level = @cond.Level {{end}}
//	    {{if cond.Status > 0}} id = @cond.Status {{end}}
//	    {{if cond.CreatedAtGte.IsZero()}} and created_at >=@cond.CreatedAtGte {{end}}
//	    {{if cond.CreatedAtLte.IsZero()}} and created_at <=@cond.CreatedAtLte {{end}}
//	{{end}}
//	{{if cond.OrderBy == "created_at" }}
//	    order by @@cond.OrderBy
//	{{else}}
//	    order by id desc
//	{{end}}
func (u userDo) SearchByConditions(cond method.SearchCondition) (result []*model.User, err error) {
	var params []interface{}

	var generateSQL strings.Builder
	generateSQL.WriteString("select * from users ")
	var whereSQL0 strings.Builder
	if cond.UserID > 0 {
		params = append(params, cond.UserID)
		whereSQL0.WriteString("id = ? ")
	}
	if cond.Username != "" {
		params = append(params, cond.Username)
		whereSQL0.WriteString("and username like concat('%',?,'%') ")
	}
	if cond.Phone != "" {
		params = append(params, cond.Phone)
		whereSQL0.WriteString("and phone=? ")
	}
	if cond.Email != "" {
		params = append(params, cond.Email)
		whereSQL0.WriteString("and email=? ")
	}
	if cond.Level > 0 && cond.Level < 10 {
		params = append(params, cond.Level)
		whereSQL0.WriteString("and level = ? ")
	}
	if cond.Status > 0 {
		params = append(params, cond.Status)
		whereSQL0.WriteString("id = ? ")
	}
	if cond.CreatedAtGte.IsZero() {
		params = append(params, cond.CreatedAtGte)
		whereSQL0.WriteString("and created_at >=? ")
	}
	if cond.CreatedAtLte.IsZero() {
		params = append(params, cond.CreatedAtLte)
		whereSQL0.WriteString("and created_at <=? ")
	}
	helper.JoinWhereBuilder(&generateSQL, whereSQL0)
	if cond.OrderBy == "created_at" {
		generateSQL.WriteString("order by " + u.Quote(cond.OrderBy) + " ")
	} else {
		generateSQL.WriteString("order by id desc ")
	}

	var executeSQL *gorm.DB
	executeSQL = u.UnderlyingDB().Raw(generateSQL.String(), params...).Find(&result) // ignore_security_alert
	err = executeSQL.Error

	return
}


// 查询时候只需要该调用函数即可:

// 从请求中获取值
var reqCondition = SearchCondition{
  UserID: c.GetInt("user_id"),
  Username: c.GetString("username"),
  Phone: c.GetString("phone"),
  Email: c.GetString("email"),
  CreatedAtGte: c.GetTime("created_at_gte"),
  OrderBy: c.GetString("created_at")
  ...
}

// 查询数据
result, err := query.User.SearchByConditions(reqCondition)
if err!= nil{
  //...
}

更多示例可以参看下面写法

// internal/dal/db/method/method.go
// common method
type Method interface {
	//where(id=@id)
	FindByID(id int) (*gen.T, error)

	//sql(select * from users)
	FindAll() ([]gen.M, error)

	//sql(select * from users limit 1)
	FindOne() gen.M

	/*
		select * from @@table
		  {{where}}
				  id>0
		       {{if cond}}id=@id {{end}}
		       {{if key!="" && value != ""}} or @@key=@value{{end}}
		   {{end}}
	*/
	FindByIDOrCustom(cond bool, id int, key, value string) ([]*gen.T, error)
	// where clause

	// update @@table
	//	{{set}}
	//		update_time=now(),
	//		{{if name != ""}}
	//			name=@name
	//		{{end}}
	//	{{end}}
	//	{{where}}
	//		id=@id
	//	{{end}}
	UpdateName(name string, id int) error
	// Set clause if clause blend

}

// only for users
type UserMethod interface {
	//where(id=@id)
	FindByID(id int) (gen.T, error)

	//select * from users where age>18
	FindAdult() ([]gen.T, error)

	//Where("name=@name and age=@age")
	FindByNameAndAge(name string, age int) (*gen.T, error)
	//sql(select id,name,age from users where age>18)
	FindBySimpleName() ([]*gen.T, error)

	//sql(select id,name,age from @@table where age>18
	//{{if cond1}}and id=@id {{end}}
	//{{if name == ""}}and @@col=@name{{end}})
	FindByIDOrName(cond1 bool, id int, col, name string) (*gen.T, error)

	//sql(select address from users limit 1)
	FindAddress() (*gen.T, error)
	//simple query

	// select * from users where id=@id
	FindUserToMap(id int) (gen.M, error)
	// return to map[string]interface{}

	//select * from @@table
	//	{{where}}
	//		{{if role=="user"}}
	//			id=@id
	//		{{else if role=="admin"}}
	//			role="user" or rule="normal-admin"
	//		{{else}}
	//			role="user" or role="normal-admin" or role="admin"
	//		{{end}}
	//	{{end}}
	FindByRole(role string, id int)

	//update users
	//	{{set}}
	//		update_time=now(),
	//		{{if name != ""}}
	//			name=@name
	//		{{end}}
	//	{{end}}
	// where id=@id
	UpdateUserName(name string, id int) error

	//sql(insert into @@table (name,age) values (@name,@age) )
	InsertValue(age int, name string) error
	// execute only return error
}

更多写法可以参考官网:https://gorm.io/gen/

多个 Struct 复用SQL逻辑

场景:有多个结构相同或者类似的数据表,如果需要通过相同的过滤逻辑,并且绑定到不同的结构体。可以通过 GEN 实现只写一遍查询逻辑。

type CommonSearch interface{    
    // 使用自定义字段
    // FindByCustomKey select by custom key
    //
    // select * from @@table 
    // {{where}}
    //     @@key = @value
    // {{end}}
    FindByCustomKey(key,value string)([]*gen.T,error)
    
    //....
}

// 根据特定状态字段查询
type StatusSearch interface {
    // FindByStatus select by status
    //
    // select * from @@table 
    // {{where}}
    //     status = @value
    // {{end}}
    FindByStatus(value string)([]*gen.T, error)
}

// 根据创建时间范围查询
type TimeRangeSearch interface {
    // FindByTimeRange select by time range
    //
    // select * from @@table 
    // {{where}}
    //     created_at >= @startTime AND created_at <= @endTime
    // {{end}}
    FindByTimeRange(startTime, endTime time.Time)([]gen.T, error)
}

// 根据特定编号查询
type IDSearch interface {
    // FindByID select by ID
    //
    // select * from @@table 
    // {{where}}
    //     id = @value
    // {{end}}
    FindByID(value int)([]gen.T, error)
}

// 以上只是举例,也可以将其合并为一个,或者将多个不同的的条件应用到不同的对象结构体

// 代码生成
func main() {
    g := gen.NewGenerator(gen.Config{
        OutPath: "../internal/db/dal/dao", 
    })
    g.UseDB(db)
    
    // 可以对多个表生成同一个查询接口,GEN会对所有表生成的结构体绑定查询方法
    g.ApplyInterface(func(CommonSearch){}, model.User{}, model.Post{})
    g.ApplyInterface(func(StatusSearch){}, model.Order{}, model.Task{})
    g.ApplyInterface(func(TimeRangeSearch){}, model.Event{}, model.Log{})
    g.ApplyInterface(func(IDSearch){}, model.Product{}, model.Category{})
    
    g.Execute()
}


// 代码中进行查询,查询传入的参数不需要单独做安全处理
// GEN会自动帮字段名和数据都做安全处理,确保没有SQL注入漏洞

users, err := query.User.FindByCustomKey("username","eagle")
posts, err := query.Post.FindByCustomKey("username","eagle-bot")

orders, err := query.Order.FindByStatus("pending")
tasks, err := query.Task.FindByStatus("in_progress")

events, err := query.Event.FindByTimeRange(time.Now().Add(-24*time.Hour), time.Now())
logs, err := query.Log.FindByTimeRange(time.Date(2024, 11, 1, 0, 0, 0, 0, time.UTC), time.Date(2024, 11, 8, 0, 0, 0, 0, time.UTC))

products, err := query.Product.FindByID(123)
categories, err := query.Category.FindByID(456)

Cache

热key失效引起穿透

  • 缓存失效时,回源数据要考虑下游保护(例如使用singleflight+多级缓存等),防御穿透的解决方法。
    • 多级缓存
    • 过期时间随机浮动
    • 使用redis分布式锁

https://pkg.go.dev/golang.org/x/sync/singleflight

// bad case
// 缓存穿透
func GetProduct(porductId int64) string{
    key := GenerateKey(porductId)
    item := ReadCache(key)
    if item == "" {
        // 缓存失效 查询DB或者rpc调用
        item = GetProductByID(porductId)
    }
    WriteCache(key, item)
    return item
}

// 方法中在读取缓存失效后,会触发数据回源,然后重新写缓存。这是符合我们通常认知的处理思路,但是在实际业务中,
// 尤其是key如果是热key,会在失效时,大量的请求穿透到下游的服务或者DB,有可能直接打垮下游服务。
// 如果调用RPC中逻辑复杂,甚至请求多处依赖的话,如果没有回源保护,可能导致雪崩。

// good case

分布式锁

  • 使用Redis SetNX 获取分布式,使用 random.Int() 做为value,避免因为使用defer unlock 错误释放别人的锁
// ------ good case ------
// 处理函数
func process() {
    val, err := Lock(ctx, uid)
    if err != nil {
        return fmt.Errorf("...")
    }
    defer UnLock(ctx, userId, val)
}

// 加锁
func Lock(ctx context.Context, uid int64) (int64, error) {
    key := fmt.Sprintf("lock:%d", uid)
    randVal = random.Int()
    lock, err := GetClient().SetNX(key, randVal, time.Second*3)
    if err != nil {
        return 0, err
    }
    if !lock {
        return 0, errors.New("failed to lock")
    }
    return randVal, nil
}

// 释放锁
func UnLock(ctx context.Context, uid, randVal int64) {
    key := fmt.Sprintf("lock:%d", uid)
    return GetClient().DelIfEqual(key, randVal)
}

// ------ bad case ------
// 未获得锁的情况下也会执行defer语法,会导致错误释放别人的锁,引发事故
// 处理函数
func process() {
    getLock := Lock(ctx, uid)
    defer UnLock(ctx, uid)
    if !getLock {
       return fmt.Errorf("...")
    }
}

// 加锁
func Lock(ctx context.Context, uid int64) {
    key := fmt.Sprintf("lock:%d", uid)
    return GetClient().SetNX(key, 1, time.Second*5)
}

// 释放锁
func UnLock(ctx context.Context, uid int64) {
    key := fmt.Sprintf("lock:%d", uid)
    return GetClient().Del(key)
}

单元测试

单测基本原则

单元测试基本原则 “FIRST”:

  • FAST(快速):单元测试应以最小单元快速运行验证

每个单元测试逻辑不应该太复杂,粒度要小 单个单元测试最好能在毫秒级完成,要考虑能否在几分钟内跑完所有单元测试。

  • ISOLATED(隔离):每个单元测试之间应独立,无互相依赖

一个单元测试结果不能依赖另外一个单元测试,比如查询类型的单元测试不应依赖上一个插入的单元测试成功 单独跑某一个单元测试不应影响结果。

  • REPEATABLE(可重复):单元测试在任意时间、任意人员执行结果需要一致

不能因为数据库等外部原因导致单元测试失败,这要求我们模拟(mock)所有外部依赖 确保写的单元测试不受执行环境、外部依赖(RPC/DB 等)影响结果。

  • SELF-VALIDATING(可自我验证):只打印日志的单元测试无任何意义

必须有断言(assert),使用工具排除无效单元测试 确保如果被测代码逻辑有问题,单元测试能 FAIL 拦截。

  • TIMELY(及时):应尽早写单元测试

提测后再补单元测试会导致单元测试无法起到应有的质量保障作用 在联调 / 提测前要确保代码经过单元测试验证

推荐组合:testing + mockey + goconvey

测试框架

测试基础框架

  • testing,Go 语言官方自带的测试包,按照约定方式编写测试代码后,使用 go test 命令即可执行测试。参考:https://pkg.go.dev/testing

mock 库

  • gomock,官方开发维护的测试框架,实现了较为完整的基于 interface 的 mock 功能,能够与 golang 内置的 testing 包良好集成,也能用于其他测试环境中。包含 gomock 包和 mockgen 工具两部分,- - gomock 包完成对桩对象生命周期的管理,mockgen 工具用来生成 interface 对应的 mock 类源文件。参考:https://github.com/golang/mock
  • gomonkey:golang 的一款打桩框架,目标是让用户在单元测试中以低成本完成打桩,从而将精力聚焦于业务功能的开发。参考:https://github.com/agiledragon/gomonkey
  • mockey:实现思想来自 java 的 mockito,是快速 mock 函数、方便单测的工具库,推荐结合 goconvey 使用。参考:https://github.com/bytedance/mockey

断言库

  • goconvey,Github 上有 8000 星。开源库,提供丰富的断言功能和断言函数,并支持很多 Web 界面特性,采用函数式编程风格。参考:https://github.com/smartystreets/goconvey
  • testify:Github 上有 21000 星。包含断言、suite、mock(不推荐使用 testify 的 mock),面向对象编程风格。参考:https://github.com/stretchr/testify

一个简单的demo

import (
	. "github.com/bytedance/mockey"
    "github.com/smartystreets/goconvey/convey"
)

func Fun(a string) string {
   fmt.Println(a)
   return a
}

func TestXXX(t *testing.T) {
    PatchConvey("test return", t, func() {
       Mock(Fun).Return("c").Build()
       r := Fun("a")
       convey.So(r, convey.ShouldEqual, "c")
    })
}

多case情况

// case
// 基于 mockey + convey 的单测示例
// mockey 可以使得 Mock 在 PatchConvey 内部生效,外部自动释放。
mockey.PatchConvey("test case", t, func() {
    mockey.PatchConvey("测试 return 1", func() {
        // mock 仅对区域生效
        mockey.Mock(Fun).Return("1").Build()
	convey.So(Fun(""), ShouldEqual, "1")
    })

    mockey.PatchConvey("测试 return 2", func() {
        // 覆盖 mock,仅对区域有效
        mockey.Mock(Fun).Return("2").Build()
        convey.So(Fun(""), ShouldEqual, "2")
    })

    mockey.PatchConvey("测试 return 3", func() {
        // 覆盖 mock,仅对区域有效
        mockey.Mock(Fun).Return("3").Build()
        convey.So(Fun(""), ShouldEqual, "3")
    })
    
})
// 这里没有 mock 生效,均已被自动 Un Patch
convey.So(Fun(""), ShouldEqual, "")

测试代码组织

测试代码常见组织模式有 Arrange-Act-AssertGiven-When-Then 两种,在 Go 语言工程实践中推荐 Arrange-Act-Assert(AAA) 作为测试代码组织模式,它将单元测试分为三个阶段:

  • Arrange(准备):设置测试的前提条件,包括创建对象、设置依赖关系、定义输入、MOCK 等。
  • Act(操作):执行要测试的行为,通常是调用一个函数或一个方法,并获取其返回值。
  • Assert(断言):验证测试的结果,通常是检查返回值或对象的状态,以确保它们符合预期。
func TestParseGender(t *testing.T){
    mockey.PatchConvey("18 位身份证号女性", t, func(){
        // Arrange: 输入和预期的输出
        cardNo :="120100201802013691"
        expectGender := GenderFemale
        // Act: 调用被测函数
        resultGender := ParseGender(cardNo)
        // Assert: 检查结果是否符合预期
        convey.So(resultGender, convey.ShouldEqual, expectGender)
    })
    mockey.PatchConvey("18 位身份证号男性", t, func() {
	// ...
    })
}

Arrange-Act-Assert

// case 1
// 计算两个数字之和的函数测试
package main

import "testing"

func addNumbers(a, b int) int {
    return a + b
}

func TestAddNumbers(t *testing.T) {
    // Arrange
    a := 3
    b := 5
    expected := 8
    // Act
    result := addNumbers(a, b)
    // Assert
    if result!= expected {
       t.Errorf("addNumbers(%d, %d) = %d; expected %d", a, b, result, expected)
    }
}

// case 2
// 判断字符串是否包含特定子串的函数测试
package main

import "testing"

func containsSubstring(str, substr string) bool {
    return len(str) > len(substr) && str[len(str)-len(substr):] == substr
}

func TestContainsSubstring(t *testing.T) {
    // Arrange
    str := "Hello, world!"
    substr := "world"
    expected := true
    // Act
    result := containsSubstring(str, substr)
    // Assert
    if result!= expected {
       t.Errorf("containsSubstring(%s, %s) = %t; expected %t", str, substr, result, expected)
    }
}

Given-When-Then

// case 1
// 用户登录功能测试
package main

import "testing"

func login(username, password string) bool {
    // 假设这里有实际的登录逻辑,检查用户名和密码是否匹配数据库中的用户
    if username == "testuser" && password == "testpassword" {
       return true
    }
    return false
}

func TestLogin(t *testing.T) {
    // Given
    username := "testuser"
    password := "testpassword"
    // When
    result := login(username, password)
    // Then
    if!result {
       t.Errorf("login(%s, %s) should return true", username, password)
    }
}

// case 2
// 文件读取功能测试
package main

import "testing"

func readFile(filename string) string {
    // 假设这里有实际的文件读取逻辑
    return "Hello, this is a test file."
}

func TestReadFile(t *testing.T) {
    // Given
    filename := "test.txt"
    expectedContent := "Hello, this is a test file."
    // When
    result := readFile(filename)
    // Then
    if result!= expectedContent {
       t.Errorf("readFile(%s) = %s; expected %s", filename, result, expectedContent)
    }
}

无效单测

如果单测没有断言,则完全失去了写测试代码的意义,是无效的单测(写单测的目标不是覆盖率达标),需要避免

// 无效单测示例:只打印日志,无断言,单测不会 FAIL
func TestDoSomeThing(t *testing.T) {
    PatchConvey("invalid test", t, func(){
       result := doSomeThing()
       logs.Info("doSomeThing result= %+v", result) //缺少 assert
    })
}

表驱动(Table-Driven)

  • 只需准备多组数据,无需重复构造流程,代码少冗余少,写得快,结构清晰可读性强,更易扩展和可维护
package main

import (
    "testing"

    "github.com/bytedance/mockey"
    "github.com/smartystreets/goconvey/convey"
)

/* 被测单元 */
func Add(x, y int) int {
    return x + y
}

/* 单元测试 */
func TestAddWithDataTable(t *testing.T) {
    // Arrange:准备输入和预期的输出
    type inputs struct {
       a int
       b int
    }
    tests := []struct {
       name     string
       args     inputs
       expected int
    }{
       {"0+0", inputs{0, 0}, 0},
       {"正数和", inputs{1, 2}, 3},
       {"正负和", inputs{-1, 1}, 0},
       {"负数和", inputs{-1, -1}, -2},
    }
    // 循环遍历,测试各种情况
    for _, tt := range tests {
       t.Run(tt.name, func(t *testing.T) {
          mockey.PatchConvey(tt.name, t, func() {
             // Act:调用被测函数
             result := Add(tt.args.a, tt.args.b)

             // Assert:检查结果是否符合预期
             convey.So(result, convey.ShouldEqual, tt.expected)
          })
       })
    }
}
  1. tests定义测试数据,其中 name 定义了每个测试场景的名称。
  2. 循环中,t.Run()执行每一个子测试,每个子测试使用 name 区分,若某个 case 报错,可快速定位和排查。
  3. 该例子中,断言使用 goconvey 库中的 convey.So() 方法替代了 t.Errorf()
  4. 特别地,convey.So() 必须位于 convey.PatchConvey() 中。

注意:Table-Driven 并非所有情况都适用。

  1. 适用范围:针对同一函数或者方法,有众多相似的测试用例需要测试,尤其是输入和输出可以被清晰定义时。比如,对于数学函数、解析函数、格式化函数等的测试。
  2. 不适用范围:当函数行为复杂、数据结构复杂时,每个测试用例可能需要不同的预设条件和断言,或独立的前置设置或后置清理。