测试WebSocket服务器中Hub后台线程的客户端注册功能
这个问题戳中了Go并发测试里的一个常见坑——用time.Sleep做同步真的是治标不治本!这种方式完全不可靠,我来给你拆解下问题,再分享几个更合理的测试方案。
首先说为什么time.Sleep不行:
- 不稳定:在性能一般的机器上,1秒可能还没等hub处理完所有注册请求,测试就提前断言导致失败;在性能好的机器上,又平白浪费时间拖慢测试套件。
- 不精准:你根本没法保证sleep的时长刚好匹配hub处理请求的实际耗时,本质就是靠“猜”来同步,属于典型的脆弱测试。
接下来是更靠谱的解决方案,分场景来看:
最优解:利用无缓冲通道的阻塞特性
如果你定义registerConn的时候用的是无缓冲通道(也就是make(chan client),没指定缓冲区大小),那恭喜你,不用改任何生产代码就能完美解决问题!
因为无缓冲通道的发送操作会阻塞直到接收方(也就是hub的run goroutine)接收到数据。换句话说,当你的测试代码执行完thub.registerConn <- cl,这个client已经被hub处理并添加到clients map里了。测试代码可以直接去掉sleep,立刻做断言:
func TestRegisterClientWSConnections(t *testing.T){ // 先启动hub的后台goroutine go thub.run() for _, cl := range testClients { thub.registerConn <- cl // 这里会阻塞到hub处理完该注册 } // 直接验证注册结果 if len(thub.clients) != len(testClients) { t.Errorf("期望注册%d个客户端,实际注册了%d个", len(testClients), len(thub.clients)) } for _, cl := range testClients { if !thub.clients[cl] { t.Errorf("客户端%v未被成功注册", cl) } } }
这是最简洁、最可靠的方式,完全利用Go通道的原生特性来保证同步。
次优解:用同步原语做明确同步(适用于有缓冲通道场景)
如果你的registerConn是有缓冲通道,或者需要更明确的同步逻辑,可以用sync.WaitGroup。这里只需要对hub的代码做极小的侵入式修改(甚至可以用测试标签隔离,不影响生产代码):
先给hub加一个可选的WaitGroup字段:
type hub struct { clients map[client]bool registerConn chan client testWG *sync.WaitGroup // 仅测试用,生产环境设为nil // 其他字段... } func (h *hub) run() { for { select{ case client := <- h.registerConn: h.clients[client] = true // 如果是测试场景,通知WaitGroup任务完成 if h.testWG != nil { h.testWG.Done() } } } }
然后测试代码里就可以用WaitGroup来等待所有注册完成:
func TestRegisterClientWSConnections(t *testing.T){ thub.testWG = &sync.WaitGroup{} go thub.run() // 注册前先添加任务数 thub.testWG.Add(len(testClients)) for _, cl := range testClients { thub.registerConn <- cl } thub.testWG.Wait() // 阻塞到所有注册都被处理 // 执行断言验证 if len(thub.clients) != len(testClients) { t.Errorf("期望注册%d个客户端,实际注册了%d个", len(testClients), len(thub.clients)) } }
这种方式虽然需要修改生产代码,但改动极小,而且同步逻辑非常明确,测试结果稳定。
零侵入解:用哨兵值+监控通道(不修改生产代码)
如果不想碰生产代码,还可以用“哨兵客户端”的思路:发送完所有测试客户端后,发送一个特殊的哨兵值,然后在测试里监控hub的clients map,直到哨兵出现,就说明前面的所有客户端都已经处理完成了:
// 定义一个特殊的哨兵客户端(要保证和测试客户端不重复) var sentinelClient = client{} func TestRegisterClientWSConnections(t *testing.T){ done := make(chan struct{}) // 启动一个监控goroutine,等待哨兵出现 go func() { for { if _, exists := thub.clients[sentinelClient]; exists { delete(thub.clients, sentinelClient) // 清理哨兵,不影响断言 close(done) break } time.Sleep(10 * time.Millisecond) // 短轮询,比1秒sleep高效太多 } }() go thub.run() // 发送测试客户端 for _, cl := range testClients { thub.registerConn <- cl } // 发送哨兵 thub.registerConn <- sentinelClient <-done // 等待哨兵被处理,说明所有测试客户端已注册 // 验证结果 if len(thub.clients) != len(testClients) { t.Errorf("期望注册%d个客户端,实际注册了%d个", len(testClients), len(thub.clients)) } }
这种方式完全不修改生产代码,只是用了短轮询替代长sleep,可靠性和效率都比原方案高很多。
总结一下:绝对不要依赖time.Sleep来做并发测试的同步,优先用无缓冲通道的阻塞特性,不行就用WaitGroup或哨兵值的方案,这些都能让你的测试稳定又高效。
内容的提问来源于stack exchange,提问作者Dan

