Go语言之io test源码分析

599 阅读9分钟

「这是我参与2022首次更文挑战的第15天,活动详情查看:2022首次更文挑战

io 测试

测试的安排

下面针对copy系列说的扩展是指:Writer/Reader的实现者, 同时实现了WriterTo/ReaderFrom接口, 或是实现了Writer/Reader的具体类型添加了具体的场景.

第一,针对Copy/CopyBuffer/CopyN的测试, 主要集中在Writer/Reader是否有扩展的情况. 这一部分被成为"simple test",验证copy系列是否能正常工作.

这部分还分成了几个阶段来验证:

  • 无扩展,验证所有的返回值是否符合预期
  • 带扩展,验证是否调用了扩展逻辑
    • Writer/Reader都带扩展,验证扩展优先级是否按预期执行
  • 验证读写逻辑错误的优先级
  • CopyN针对带扩展/不带扩展的情况
  • CopyN针对拷贝大小的基准测试
  • CopyN针对EOF的情况

第二,针对ReadAtLeast的测试, 主要集中在error/EOF/常规的功能测试.

第三,针对功能性结构体的测试.

具体分析

copy系列,无扩展时的测试

bytes.Buffer实现了Reader/Writer/ReaderFrom/WriterTo,很适合做测试.

type Buffer struct {
  bytes.Buffer
  ReaderFrom
  WriterTo
}

这种写法是利用语言的机制,屏蔽了bytes.Buffer的两个接口.

func main() {
  var b io.Reader = &Buffer{}
  if _, ok := b.(io.ReaderFrom); !ok {
    fmt.Println("not a ReaderFrom")
  }
}

通过类型断言可以看出,如果Buffer类型不另外实现ReadFrom()方法, 那么她就不是属于ReaderFrom接口的实现者,即使结构体中内嵌了接口类型.

上面的写法是通过内嵌接口不实现来屏蔽部分行为. 还有一种interface{},任何类型都实现了这个接口, 其底层处理手法应该是比较方法集的包含关系. 如果将空接口内嵌到Buffer,最后发现Buffer还是属于空接口的实现, 这里算是一个特例吧,毕竟实现与否看方法集的包含关系, 而内嵌接口是利用了语言的冲突机制,隐去了级别较低的访问, 从而造成了屏蔽效果.

dlv test, b io_test.TestCopy, c, 可调试第一个测试函数.

  • TestCopy
    • 测试Copy,Writer/Reader无扩展
  • TestCopyNegative
    • 测试Copy,Reader扩展成LimitedReader
      • 测试LimitedReader的最大可读字节数是负数的情况
    • 测试CopyN,也是测试最大可读字节数为负数的情况
  • TestCopyBuffer
    • 测试CopyBuffer,设定中间缓冲区,只是决定了每次读写的颗粒度
  • TestCopyBufferNil
    • 测试CopyBuffer,不指定缓冲区,会自己生产中间缓冲区
  • TestCopyReadFrom/TestCopyWriteTo
    • 测试Copy系列函数,最后是测试对扩展的处理
    • 这也算是代码复用,以后要修改只修改一处

扩展逻辑的优先级,用测试结果来说明

源码中用writeToChecker结构体来验证,具体做法是在WriteTo中加了标志位.

之前的测试已经验证了会走扩展逻辑,io包的源码指定了会优先走Writer扩展, 所以在TestCopyPriority中,只需要检查标志位是否是true.

读写错误优先级

io包源码中写错误的优先级高. TestCopyReadErrWriteErr配合两个特别设计的Writer/Reader,测试Copy()的返回值, 这种写法注重于测试异常流程.

CopyN

CopyN在Copy的基础上做了一个封装,限定了Reader读的最大字节数.

测试例子中提供了3个测试函数:

  • TestCopyN, 常规测试
  • TestCopyNReadFrom, 测试Writer对ReadFrom的扩展
  • TestCopyNWriteTo, 测试Reader对WriteTo的扩展

dlv单步之后,发现TestCopyNWriteTo没有走扩展流程,具体原因如下: 测试函数传入的Reader是bytes.Buffer, 在CopyN中将bytes.Buffer封装到LimitedReader中了,但LimitedReader 是没有实现WriterTo接口的,所以不会州扩展流程.

如果要修改,可copyBuffer中的扩展逻辑下面添加如下代码:

if l,ok := src.(*LimitedReader); ok {
  if wt,ok := src.R.(WriterTo); ok {
    return wt.WriteTo(dst)
  }
}

这里仅仅是针对CopyN的改造,但放在了公共函数copyBuffer上面, 肯定是不合适的,或许可以放在LimitedReader上,问题是: 万一LimitedReader的R没有实现WriterTo,就会影响到copyBuffer 的正常流程,综合考虑,还是应该修改公共函数copyBuffer.

CopyN的基准测试

这里有很多新东西,可以学习.

func BenchmarkCopyNSmall(b *testing.B) {
  bs := bytes.Repeat([]byte{0}, 512+1)
  rd := bytes.NewReader(bs)
  buf := new(Buffer)
  b.ResetTimer()

  for i := 0; i < b.N; i++ {
    CopyN(buf, rd, 512)
    rd.Reset(bs)
  }
}

首先是创建一个bytes.Buffer,Repeat的实现很有趣,后面也会分析到, b.ResetTimer()消除了准备阶段对测试的影响,

基准测试一定要有对比,这里对比的因素是CopyN中每次读写的颗粒度. Benchmark测试叫基准测试,也叫性能测试,后面都用性能测试来表述.

➜  io go test -bench=. -run=^$ -benchmem
goos: linux
goarch: amd64
pkg: io
BenchmarkCopyNSmall-4   2238950   8258 ns/op    2462 B/op   2 allocs/op
BenchmarkCopyNLarge-4   1300      2222590 ns/op 135716 B/op 2 allocs/op
PASS
ok  io  23.226s

可以看出,读写字节数越大性能越差,(废话?). Large读写的字节数是Small的64倍,但性能却差了三个数量级. 所以说宁可多调CopyN几次,也不要让CopyN来一次大的.

CopyN对各种EOF的处理

分别覆盖了以下场景:

  • Reader扩展,Writer不扩展,读缓冲3字节,最大读写数是3
    • 预期,写3字节,err为nil
  • Reader扩展,Writer不扩展,读缓冲3字节,最大读写数是4
    • 预期,写3字节,err为EOF
  • Reader扩展,Writer扩展,读缓冲3字节,最大读写数是3
    • 预期,写3字节,err为nil
  • Reader扩展,Writer扩展,读缓冲3字节,最大读写数是4
    • 预期,写3字节,err为EOF
  • Reader报错,Writer扩展,最大读写数是5
    • 预期,写5字节,err为nil
  • Reader报错,Writer不扩展,最大读写数是5
    • 预期,写5字节,err为nil

前面4个测试函数好了解,后面两个为啥也是正确的. CopyN内部,Copy返回的是(5,error),5正好满足CopyN的要求, 所以错误会被忽略掉. 倒数第二个测试函数走的是strings.Buffer.ReadFrom;倒数第一个走的是常规逻辑.

这里有一点值得思考:LimitedReader.N到底有哪些意思:

对于非CopyN的拷贝函数:

  • 如果Writer/Reader有扩展,N具体是多少就没关系了
  • 如果走常规逻辑,N只表示最大读写数
    • 这里的条件非常严:
      • 第一需要非CopyN系列函数
      • 第二需要Reader要支持扩展 LimitedReader

对于CopyN:

  • N表示最大读写数
    • 此时不管Writer/Reader是否还有其他扩展

再次总结:CopyN的N和Reader的LimitedReader扩展,都限定了最大读写的字节数.

ReadAtLeast系列

有三个测试函数,底层调用的都是testReadAtLeast, ReadAtLeast函数是从Reader中最少读xx字节的数据到制定切片.

这个测试的手法,是将ReadAtLeast的所有边界都包含了. 对bytes.Buffer的实现细节不清楚,所以不好分析具体的流程,待后续对bytes包的分析.

TeeReader的测试

TeeReader实现了Reader,具体行为是从Reader读数据到buf切片,后再写回到Writer, TestTeeReader前一部分是常规的测试,后一部分,Writer是通过Pipe()生成的.

Pipe()是io包的其余部分,后面分析.

SectionReader

SectionReader模拟了一个文件读,测试ReadAt时用了表格测试. 实现ReaderAt接口的是strings.Reader类型.

struct {
  data   string
  off    int
  n      int
  bufLen int
  at     int
  exp    string
  err    error
}

前面3个data/off/n用于描述SectionReader结构体,bufLen描述ReadAt()第一个参数, 也就是读到那个缓冲(这里是字节切片), at表示读时的偏移,exp/err是读之后的结果.

表格测试,主要是变化前5个参数,来预测结果.测试函数中也包含了各种场景.

对于seek:

  • for range的容器,是字面量来体现的
  • 测试不再是具体的预期作对比,而是和另一个测试对象作对比
    • 毕竟SectionReader是模拟文件读,而bytes.Reader就是一个优秀的文件读例子

对与Size,也是采用和另一个测试对象作对比.

测试遵循的细节

  • 针对非接口进行测试
  • 一个测试函数只针对一个主题
    • 正常流程
    • 错误参数
    • 边界
    • 返回值
    • 逻辑分支,包括先后顺序
    • 入参的不同组合
  • 对调用层次最高的那个函数做性能测试
  • 主动构建异常对象(读写错误,或部分错误)来测试各种场景
  • 多边界的推荐使用表格测试(ReadAtLeast也适合用表格测试)
  • 函数行为是否ok,可通过判断多个测试点来判断
  • 对于一些模仿性函数,可将预期结果和被模仿函数的执行结果进行对比

令人眼前一亮的写法

测试很少对接口进行测试,之前一直以为接口是核心, 应该会有大篇幅来测试Writer/Reader接口,实际上不是. 大部分篇幅都是针对暴露的函数,少部分针对暴露的功能性结构体.

如何写测试

对于异常流程或预期的返回值,可单独用一个测试函数来测试.

对函数或方法的约束可用一个功能函数(非导出,意味着可能是功能函数), 再针对不同的场景,使用多个测试函数来调用.

表格测试,包含了被测试函数的入参,最后的结果(可能是返回值可能是引用入参),错误等. 对于有多个边界条件的函数,应该引入表格测试.

对于模拟性的实现,针对其的测试应该是直接和被模拟对象的执行结果对比.

测试永远不是为了覆盖率,不是做完单点单元测试就可以了, 应该要有设计.测试是确保一个场景范围,在这个范围内, 包的实现的行为是符合预期,且可靠的.

如果说包的设计是为了实现某个功能,那测试的设计就是为了保证正确性, 从Go的源码中看出,这两者应该是同时进行,同时发展. 两者都是先有了设计,再用代码实现的.