从一条 RTMP 连接开始创建单元测试
想象我们正在检查一条刚刚建立的 RTMP 连接。它要先准备好默认状态,随后把控制消息写到网络上;如果调用者把连接对象弄丢了,它还得明确报错。我们不必真的连上服务器,就能在本地把这些行为逐项验证出来。
这就是 net_connection_test.go 的故事:用 Go 的表驱动测试,借助一根内存里的"假网线",检查连接初始化和消息写出的结果。
第一:测试从哪里进入?
文件开头声明:
go
package rtmp_test
测试使用外部测试包形式,而不是与被测包共享未导出的内部实现。随后它给 Monibuca 的 RTMP 包起了一个简短的别名:
go
rtmp "m7s.live/v5/plugin/rtmp/pkg"
因此测试调用的是 rtmp.NetConnection、rtmp.NewNetConnection 等公开 API。当前工作目录的 go.mod 把 m7s.live/v5 作为依赖,所以从项目根目录执行 go test . 时,Go 会编译这里的测试并解析该依赖。
这里有一个重要边界:工作区根目录没有 Monibuca 的 net_connection.go 实现。测试文件虽名为 net_connection_test.go,实际验证的是依赖包公开出来的行为,并非直接编译本地的一份 net_connection.go 源码。
第二:搭一条不会出门的网线
测试连接的准备工作集中在 newTestNetConnection:
go
peer, conn := net.Pipe()
nc := rtmp.NewNetConnection(conn)
nc.Context = context.Background()
net.Pipe() 返回一对彼此连接的内存端点。conn 交给 NetConnection,测试则握着 peer。于是连接发出的字节会出现在 peer 上,不需要端口、不需要服务器,也不会受到真实网络波动影响。
这根"网线"有个不太显眼的特性:读写是同步配合的。若测试主协程直接调用发送函数,发送方可能等待读取端,而读取又还没开始,双方就会互相等待。因此后面的写出测试会把发送放进 goroutine,再由主协程从 peer 读取。
辅助函数还做了两件收尾工作:
- 设置
nc.Context,让测试连接拥有明确的上下文。 - 用
t.Cleanup关闭对端并调用nc.Dispose(),确保每个子测试结束时释放资源。
t.Helper() 则让辅助函数出错时,Go 测试报告更接近实际调用它的测试位置。
第三:新连接应该带着什么默认值?
TestNewNetConnectionDefaults 不只检查一个字段,而是把要检查的项目放进表里:
go
tests := []struct {
name string
get func(*rtmp.NetConnection) int
want int
}{ ... }
表中的每一行描述一个情形:name 给子测试命名,get 负责读取目标字段,want 是预期值。当前表格分别检查读取和写入的 chunk 大小是否等于 RTMP_DEFAULT_CHUNK_SIZE。
随后,循环通过 t.Run(tt.name, ...) 把每一行变成独立子测试。这样的表驱动写法有两个好处:
- 两个检查共享相同的执行逻辑,减少重复代码。
- 某项失败时,报告会指出具体是"read chunk size"还是"write chunk size"。
这也是扩展测试的常见路径:增加一个表格用例,而不是复制整段测试流程。
第四:让消息真正走过网线
TestSendMessageWritesControlChunk 要回答的问题是:调用 SendMessage 后,连接究竟写出了哪些字节?
表格为两种 ObjectEncoding 设置准备输入消息和期望字节。每个子测试按以下顺序工作:
- 创建一条新的内存连接。
- 设置当前用例的
ObjectEncoding。 - 给对端读取设置一秒的截止时间,避免没有输出时无限等待。
- 在 goroutine 中调用
SendMessage。 - 用
io.ReadFull读出期望长度的完整数据。 - 检查发送错误,并用
bytes.Equal逐字节比较实际输出与预期输出。
测试中的发送动作如下:
go
sendDone := make(chan error, 1)
go func() {
sendDone <- nc.SendMessage(rtmp.RTMP_MSG_ACK, tt.message)
}()
带缓冲的 sendDone 用于把发送结果交回主测试协程。此处 goroutine 不只是为了并发,而是配合 net.Pipe 的同步读写特性,避免发送先开始后阻塞、导致读取永远无法启动。
期望字节数组把测试提升到了协议输出层:它不只断言"函数返回成功",还验证 chunk 的头部、消息类型和负载确实以预期形式写出。以这些用例为例,数组包含 chunk 基本头、消息头和 4 字节消息体;消息体使用特定字节值,便于发现字节顺序或编码结果变化。
这里有一个值得留意的测试命名边界。 用例名写着 "AMF0 encoding" 和 "AMF3 encoding",但用例传入的是 Uint32Message,断言的是 ACK 控制消息写出的字节;它们并没有直接验证 AMF0/AMF3 对象序列化格式。更准确地说,现有测试覆盖了两种 ObjectEncoding 设置下这条控制消息的写出结果。若目标是验证 AMF 编码本身,还需要构造 AMF 命令/对象消息,并对其编码后的消息体单独断言。
第五:用户控制消息也要留下脚印
TestSendUserControlWritesExpectedChunk 沿用相同的表驱动结构,这次覆盖 ping request 和 ping response:
eventType是输入的用户控制事件类型。want是完整预期 chunk。- 子测试启动
SendUserControl,从 pipe 对端读取数据,再逐字节比较。
两个用例的消息类型都是 RTMP_MSG_USER_CONTROL,消息体则以两个字节表示事件类型。测试直接比较线上的完整字节,因此若事件类型、消息体长度或消息类型发生意外变化,错误会在这里暴露。
这一幕也展示了良好的测试复用方式:连接创建、超时保护、异步发送和完整读取这些步骤与上一幕相同;变化的只有用例输入和预期输出。
第六:没有连接对象时,错误不能被吞掉
最后,TestSendMessageRejectsNilReceiver 把连接指针设为 nil,分别传入 ACK 和用户控制消息,再确认 SendMessage 返回了错误:
go
var nc *rtmp.NetConnection
if err := nc.SendMessage(rtmp.RTMP_MSG_ACK, tt.message); err == nil {
t.Fatal("SendMessage() error = nil, want an error for a nil receiver")
}
这是一个负向测试:它验证异常输入下的行为,而不是正常消息路径。表格让两类消息都通过同一条 nil receiver 检查逻辑,失败信息也能指出是哪种消息用例没有得到预期错误。
重点、难点与亮点回顾
重点:验证可观察行为
测试关心的是公开 API 的结果:默认字段值、写到网络端的字节,以及无效 receiver 时返回的错误。对网络协议代码而言,字节级断言通常比"没有报错"更能说明输出是否正确。
难点:同步 pipe 与 goroutine 配合
net.Pipe 不像缓冲队列那样让一端可以随意写入;读写双方需要配合。先启动发送 goroutine,再在测试协程中读取,是这些测试可以稳定完成的关键。读取截止时间则为失败情形提供了超时边界。
亮点:表驱动与子测试
每个测试把变化的数据放在表格里,把共同行为保留在循环中。t.Run 让每个用例单独显示、单独失败,既避免重复,也方便将来继续添加协议边界用例。
需要知道:日志与 -v
当前文件没有调用 t.Log 或 t.Logf,因此测试本身不会输出自定义步骤日志。运行:
powershell
go test -v .
可以看到 Go 测试框架提供的测试函数、子测试开始与通过/失败信息;若要查看每个步骤中的字节内容,则需要在相应测试中显式加入 t.Logf。
如何运行这段故事?
在项目根目录执行:
powershell
go test .
想看到每个测试和子测试的执行过程,则执行:
powershell
go test -v .
通过后,最重要的结论是:当前根目录中的表驱动测试可以编译运行,并验证 Monibuca RTMP 依赖包的这些公开行为。它们不是对本地 net_connection.go 文件的直接测试;若未来把 RTMP 实现源码纳入当前模块,测试导入和包结构也应相应调整,才能直接覆盖本地实现。