Go单元测试如何阻塞等待select处理通道操作(无需改业务代码)
问题描述
我正在尝试为下述简化示例编写单元测试,覆盖容器的元素添加、移除两类测试场景。当前测试遇到问题:select语句尚未接收并处理完发送到通道的值时,测试断言就已经执行并触发失败。
是否存在无需修改非测试代码(例如不引入WaitGroup、不新增响应通道)即可完成这类场景测试的方案?
待测试的代码与初始测试用例如下:
package main import "testing" type Container struct { items map[*Item]bool add chan *Item remove chan *Item } type Item struct { name string } func (c *Container) run() { for { select { case item := <-c.add: c.items[item] = true case item := <-c.remove: delete(c.items, item) } } } func TestAddItem(t *testing.T) { container := &Container{ items: make(map[*Item]bool), add: make(chan *Item), remove: make(chan *Item), } go container.run() if len(container.items) > 0 { t.Errorf("Container shouldn't contain anything yet") } container.add <- &Item{name: "Thing"} if len(container.items) != 1 { t.Errorf("Container should have 1 item, actually has %d.", len(container.items)) } } func TestRemoveItem(t *testing.T) { container := &Container{ items: make(map[*Item]bool), add: make(chan *Item), remove: make(chan *Item), } go container.run() item := &Item{name: "Thing"} container.add <- item if len(container.items) != 1 { t.Errorf("Container should have 1 item, actually has %d.", len(container.items)) } container.remove <- item if len(container.items) != 0 { t.Errorf("Container should have 0 items, actually has %d.", len(container.items)) } }
解决方案
不需要修改任何非测试代码,使用带超时的状态轮询即可稳定覆盖测试场景,这是无侵入测试异步逻辑的通用方案:
- 原测试偶发失败的核心原因:无缓冲通道的发送操作返回,仅代表值已经被接收方goroutine从通道拷贝完成,不代表接收方已经执行完后续的map写入/删除逻辑,主测试goroutine和运行
run方法的goroutine存在时序竞争,断言会提前触发。 - 轮询逻辑完全收敛在测试代码内:发送通道操作后,反复检查容器状态是否符合预期,在超时时间内等到预期状态则判定逻辑正常,超时未达到则判定测试失败,不会对生产代码造成任何侵入。
首先在测试文件内添加一个仅测试使用的辅助轮询函数:
import "time" // waitForCondition 轮询检查条件是否达成,超时则标记测试失败 func waitForCondition(t *testing.T, check func() bool, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { if check() { return } time.Sleep(10 * time.Millisecond) } t.Fatalf("等待预期状态超时,超时阈值:%v", timeout) }
之后将原测试中直接断言长度的逻辑,替换为等待条件达成即可:
func TestAddItem(t *testing.T) { container := &Container{ items: make(map[*Item]bool), add: make(chan *Item), remove: make(chan *Item), } go container.run() if len(container.items) > 0 { t.Errorf("Container shouldn't contain anything yet") } container.add <- &Item{name: "Thing"} // 等待元素添加完成再断言 waitForCondition(t, func() bool { return len(container.items) == 1 }, time.Second) } func TestRemoveItem(t *testing.T) { container := &Container{ items: make(map[*Item]bool), add: make(chan *Item), remove: make(chan *Item), } go container.run() item := &Item{name: "Thing"} container.add <- item // 等待添加完成 waitForCondition(t, func() bool { return len(container.items) == 1 }, time.Second) container.remove <- item // 等待删除完成 waitForCondition(t, func() bool { return len(container.items) == 0 }, time.Second) }
注意:原生产代码存在多goroutine并发读写map的问题,使用
go test -race运行测试会报数据竞争,这是业务代码本身的逻辑缺陷,和测试方案无关。不建议使用
runtime.Gosched()替代轮询,该方法仅会让出当前goroutine的执行权,无法保证运行run的goroutine能完成对应逻辑,在多核环境下依然会偶发测试失败。
内容的提问来源于stack exchange,提问作者Anonymous
相关产品推荐
相关产品推荐

