掘金 后端 ( ) • 2024-04-30 14:30

为什么要做测试

做好单元测试,可以让代码设计更加优雅,从而提高了代码的可理解行、复用性和可维护性,并且在引入改动的时候不需要整个程序重新测试,只要保证修改的地方输入输出保持不变,即可快速验证程序是否有问题。

并且在每一次发生BUG的时候,我们可以将BUG的输入作为case这样我们就不会再因为同样的问题犯错,而且每次只要跑一次测试就可以发现新的修改是否有引入以前相似的问题,这对软件质量是非常大的提升。

将方法作为入参,方便 mock

在 Kubernetes 优雅退出的执行方法中,通过将handler入参声明成方法,而不是直接调用,可以只测试 flushList 的逻辑,而不需要关注 handler 的对错。

不过这里也可以通过使用 gomonkey 的反射来直接 mock 掉方法的返回值同样可以实现。

并且如果需要测试竞争调整,可以通过开启协程进行测试。

type gracefulTerminationManager struct {
 rsList graceTerminateRSList
}

func newGracefulTerminationManager() *gracefulTerminationManager {
 return &gracefulTerminationManager{
  rsList: graceTerminateRSList{
   list: make(map[string]*item),
  },
 }
}

type item struct {
 VirtualServer string
 RealServer    string
}

type graceTerminateRSList struct {
 lock sync.Mutex
 list map[string]*item
}

func (g *graceTerminateRSList) flushList(handler func(rsToDelete *item) (bool, error)) bool {
 g.lock.Lock()
 defer g.lock.Unlock()
 success := true
 for _, rs := range g.list {
  if ok, err := handler(rs); !ok || err != nil {
   success = false
  }
 }
 return success
}

func (g *graceTerminateRSList) add(rs *item) {
 g.lock.Lock()
 defer g.lock.Unlock()
 g.list[rs.RealServer] = rs
}

func (g *graceTerminateRSList) len() int {
 g.lock.Lock()
 defer g.lock.Unlock()
 return len(g.list)
}

这里要对竞争条件下的 flushListadd 进行测试。

func Test_raceGraceTerminateRSList_flushList(t *testing.T) {
 manager := newGracefulTerminationManager()
 go func() {
  for i := 0; i < 100; i++ {
   manager.rsList.add(&item{
    VirtualServer: "virtualServer",
    RealServer:    fmt.Sprint(i),
   })
  }
 }()

 // 等待加入到一定元素再继续往下执行
 for manager.rsList.len() < 20 {
 }

 // 传入了处理方法进行 mock
 success := manager.rsList.flushList(func(rsToDelete *item) (bool, error) {
  return true, nil
 })

 assert.True(t, success)
}

通过 https://github.com/agiledragon/gomonkey 给程序进行 mock ,可以屏蔽掉外部调用对所要测试的方法的影响

如果需要 给私有方法打桩,则可以使用高版本的 gomonkey ,这样可以让我们更加专注在我们需要测试的方法上。

如果想在测试文件里面做一部分集成测试的功能,这时候会遇到一个比较头疼的问题,就是需要先初始化比较多的资源,比如 数据库、缓存等,这个时候我们在每个组件的模块下面可以增加初始化这些资源的方法。eg:

func InitTestSuite(opts ...TestSuiteConfigOpt) {
 config := &TestSuiteConfig{}
 for _, opt := range opts {
  opt(config)
 }
 dsn := config.GetDSN()
 err := NewOrmClient(&Config{
  Config: &gorm.Config{
   //Logger: logger.Default.LogMode(logger.Info),
  },
  SourceConfig: &SourceDBConfig{},
  Dial:         postgres.Open(dsn),
 })
}

然后在需要使用的测试文件下面,通过 TestMain 方法进行初始化。

这样还有一个好处,就可以提前发现一些模块解耦的是否干净,比如我初始化一个测试套件的时候,发现我需要初始化很多内容,这个时候就需要看看代码的模块设计是否正确和有必要。

并发问题如何测试

并发的程序如何编写测试?

分布式下,最常见的就是有大量竞争条件,很多case只有非常小的概率出现,但是出现却是会造成事故的巨大问题,所以我们需要尽可能多的模拟并发竞争的场景,等待所有都操作完之后判断结果,但是偶尔一次执行测试是可以成功的,我们需要保证多次执行之后结果仍然是一致的,那么就需要多次执行代码,可参考代码如下

var (
 counter int
)

func increment() {
 counter++
}

func TestIncrement(t *testing.T) {
 count := 100
 var wg sync.WaitGroup
 for i := 0; i < count; i++ {
  wg.Add(1)
  go func() {
   increment()
   wg.Done()
  }()
 }
 assert.Equal(t, count, counter)
}

通过起多个协程去操作方法,发现结果不符合我们的预期,这个时候就需要对代码进行Review和修改。

TDD(测试驱动开发)

每次写完测试后,只写最小能运行通过的代码,以编写状态机代码为例,首先定义好我们的方法,这里为了方便阅读,直接对方法进行简单实现

func GetOrder(orderId string) Order {
 return Order{}
}

func UpdateOrder(originalOrder, order Order) error {
 return nil
}

func UpdateOrderStateByEvent(ctx context.Context, orderId string, event Event) (err error) {
 order := GetOrder(orderId)
 stateMap, ok := orderEventStateMap[event]
 if !ok {
  return errors.New("event not exists")
 }

 if !stateMap.currentStateSet.Contains(order.OrderState) {
  return errors.New("current OrderState error")
 }

 updateOrder := Order{
  OrderId:    order.OrderId,
  OrderState: order.OrderState,
 }

 err = UpdateOrder(order, updateOrder)
 if err != nil {
  return err
 }
 return nil
}

然后对 UpdateOrderStateByEvent 进行测试,我们要明确单测是单独测试这个方法,其他的方法都可以通过 gomonkey 进行mock,保证单测的可重复运行。

func TestOrderStateByEvent(t *testing.T) {
 type args struct {
  ctx     context.Context
  orderId string
  event   Event
 }
 tests := []struct {
  name      string
  args      args
  wantErr   error
  initStubs func() (reset func())
 }{
  {
   name: "",
   args: args{
    ctx:     context.Background(),
    orderId: "orderId1",
    event:   onHoldEvent,
   },
   wantErr: nil,
   initStubs: func() (reset func()) {
    patches := gomonkey.ApplyFunc(GetOrder, func(orderId string) Order {
     return Order{
      OrderId:    orderId,
      OrderState: delivering,
     }
    })
    return func() {
     patches.Reset()
    }
   },
  },
 }
 for _, tt := range tests {
  t.Run(tt.name, func(t *testing.T) {
   // 1. 把需要mock 的方法mock掉
   reset := tt.initStubs()
   defer reset()
   // 2. 调用需要测试的方法
   err := UpdateOrderStateByEvent(tt.args.ctx, tt.args.orderId, tt.args.event)
   assert.Nil(t, err)
  })
 }
}

测试驱动开发在我大学就接触到的概念,这里我们只用Go举例,但是具体的思想在任何编码的场景下都可以用到,在《代码整洁之道:程序员的职业素养》这本书里面也提到,这个概念从90年代就已经被提出,最早使用也是在其他语言,作者通过编写测试,然后再**编写能让测试运行的最小代码,**不断地交替进行,最终程序出来的时候,也是处于可测试的状态。

将测试程序放在最前面去启动,这样可以避免我们在写完实际代码后,不想去大规模的变动,导致函数过长,下次修改的时候再次测试会非常麻烦。如果我们平时做业务开发,预先将业务逻辑进行拆解,然后再通过胶水代码将各个单元组件组合起来使用,这样出现的 BUG 相较于我们一气呵成写成代码后再去进行测试出现的 BUG 会少很多。

这里可能会有人提出质疑,觉得写代码进入心流状态后,抽出精力来写单元测试非常消耗时间,我原来也是这么认为的,但是重新回去审视我心流状态下写出来的东西,要二次进行修改的时候苦不堪言,在引入变动的时候,要么就是重新测试需要耗费非常多的时间,要么就是改动的地方非常多,发现第一次代码设计十分不合理,要新增代码十分麻烦

经常打断自己的心流状态或许相对来说会难受,但是也能让我们更多的去反思代码设计存在的不合理的地方。

这时候有人会觉得编写测试占用的时间太多,那我们就善用工具提升我们编写测试的效率。首先可以用IDE 生成我们测试的框架代码

image.png

image.png

生成后我们可以看到我们只需要写测试的样例即可。因为有 copilot 的出现,test case 中重复的工作不需要手工去做,现在只要我们通过编写一个 case ,然后写好测试方法的逻辑,AI 可以帮助我们快速生成很多边缘测试的例子,甚至比我们自己想的还要更周全,而且只要方法命名的好,生成的样例可用率非常高。这样在 AI 生成的测试样例有大量不符合我们的要求的时候,我们也可以反过来思考一下是否是我们的方法名有问题,从而不断的优化我们的代码。

最后

我们不需要第一次就写出优雅的代码,但是我们要有一个写出更好代码的想法,不断的去反思,用工具不断的去提升我们自己,我们产出的内容也会更加优秀。