对于分布式存储研发团队来说,测试代码的重要性等同于开发,研发人员花在写测试代码上的时间,也基本等同于写业务代码的时间。很多人不愿意写测试代码,一方面可能觉得测试不重要,一方面也并不知道如何去写测试代码。
关于测试技术,作者将陆续用几篇文章来介绍他与技术团队是如何对分布式存储系统进行测试的,希望能够帮助到大家,让大家不再觉得写测试是很困难的事。
本文系“超融合与分布式块存储”系列文章的第三篇,前文回顾:
白盒测试 vs 黑盒测试
首先我们来介绍一些测试的背景。
通常,一个程序的行为取决于它暴露出来的接口的定义。而程序内部复杂的行为,都被隐藏在了接口的后面。例如对于一个兼容 POSIX 的存储系统,它的行为由 POSIX 接口定义。黑盒测试的重点,就是验证存储系统是否能够满足 POSIX 中定义的行为。
而白盒测试则不仅仅关心接口的行为,还关注程序内部的状态。每个存储系统都有很多特性,例如每个存储系统的磁盘布局都不同。那么所谓的白盒测试,就是要针对这个内部实现的逻辑进行测试,例如在创建、删除一个文件以后,检查磁盘上数据的变化是否符合这个特定存储系统的设计预期。
白盒测试 vs 单元测试
为了进行白盒测试,测试程序必须能够获取程序的状态,以便于进行检查。一个存储系统的状态通常由两部分组成:内存状态和磁盘状态。磁盘状态我们可以通过直接访问磁盘获取到,而内存状态通常不太容易直接获取。
对于 Linux 文件系统来说,往往通过 sysfs 的方式对外暴露状态。例如,我们可以通过 /sys/fs/xfs/[disk]/stats/stats 来获得一个 xfs 文件系统的内存状态。
此外,我们也可以通过暴露一个通信接口的方式,例如 RPC,用于获取内部状态。
然而我们还有更直接的获取程序状态的方式。单元测试就是一个例子。
我们在写单元测试的时候,会把测试代码和程序代码链接在一起,也就是说,测试代码可以直接访问到程序代码,从而可以控制程序的启动、终止,并检查内存状态或验证代码的行为。
我们千万不要把单元测试仅仅局限在对一个小模块、函数进行测试。实际上单元测试中的单元(Unit),可以有更广阔的含义。
每一个架构良好的程序都会划分成多个层次。以 ZBS 举例,ZBS 中的最顶层的模块包含划分为 Meta Server 和 Chunk Server,Meta Server 又由 Chunk Manager、NFS Manager、iSCSI Manager 等子模块组成,这些子模块又可以再被划分成很多的更小的子模块。
我们可以把每一层都看做一个单元,对每一层都要进行测试。小到某一个基础函数,基础的工具类,大到一个 Chunk Server、Meta Server,一个 ZBS 集群,甚至多个 ZBS 集群,都可以通过这种方式进行测试。
例如在 ZBS 的单元测试中,会在一个进程内启动多个 Meta Server 和 Chunk Server,并组成一个 ZBS 集群,以进行测试。这些测试包含了常规 IO 测试,故障恢复测试,数据恢复、迁移测试,添加、删除节点测试等等,我们甚至还可以模拟多个 ZBS 集群间的数据备份测试。
而在这些测试中,我们不仅仅要保证程序的行为和接口定义是一致的,还要保证程序内部状态的正确。例如在测试副本写入的过程中,我们会检查 Chunk Server 是否正确的把写入请求发送给底层的存储引擎,当磁盘 IO 请求超时时,Chunk Server 是否能正常的处理这种异常行为。
单元测试技术之获取程序状态
由于单元测试代码和程序代码链接到了一起,所以理论上,单元测试代码可以获得程序内部的所有状态。但在有些场景下,获取方式并不那么直接。例如,对于 C++ 或其他面向对象语言来说,由于采用了各种形式的封装,从语言层面实现了对程序状态的隐藏,使得测试代码无法直接获取所有的状态。
在 C++ 中,最主要的封装方式就是类里面的 private 成员。对于测试代码来说,无法直接获取一个对象的 private 成员的状态,也无法调用一个类的 private 方法。
通常,为了做到可测试,我们可以把一些类的 private 成员通过 public 函数的方式暴露出来。但过多的暴露内部实现也会破坏封装性。
这个时候,比较常见的方法是使用 friend class。我们可以把的单元测试代码所在的 class 作为要访问的类的 friend class,这样就可以绕开 private 的限制,直接访问类里面的所有成员。
总的来说,C++ 中的封装特性对写单元测试造成了一些困扰。而在其他一些语言中,由于不存在 private 的限制,对于写单元测试来说则相对友好一些。我们需要在封装和可测试性之间寻找一个平衡点,不建议为了写单元测试而随意破坏封装性。在 ZBS 项目中,我们只有在必要的测试代码中才会使用 friend class。
单元测试技术之 Mock
单元测试中另一个常用的技术就是 Mock。通过 Mock,我们可以对程序内部的行为进行检验,也可以模拟一些特定的行为。
例如,当我们想确认存储系统对于『创建文件』这个操作,是否按照预期先写了日志,然后才写的元数据,那么我们可以通过 Mock 底层的 IO 模块,截取存储系统发送给底层 IO 系统的请求的方式,来校验存储系统的行为是否符合预期。
在 ZBS 中,Mock 是我们用的最多的测试手段,因为我们需要模拟各种各样的异常场景,而且还要精确的控制异常触发的时间、顺序、以及具体异常行为。
其实 Mock 并不复杂,在 C/C++ 中 Mock 就是多态,在上层代码并不感知的情况下,下层的模块的逻辑发生了变化。
在 C 中,多态主要是通过函数指针实现的。一个经典的例子就是 Linux 中的 VFS。VFS 中定义了一组函数指针。对于文件系统来说,只需要实现这些函数指针就可以了。不同的文件系统的实现不同,也就是因为他们注册的函数不同。
为了测试 VFS 的行为,我们只需要在测试代码中注册一个 Mock 文件系统。这个 Mock 文件系统和其他文件系统一样,实现了这一组函数指针。这样,我们就可以截获所有 VFS 发给文件系统的请求,并可以按照测试项目预设的逻辑进行返回。
在 C++ 中,除了可以使用函数指针以外,我们实现多态的方式还有继承和模板。我们先来介绍一下继承。
如果我们想要 Mock 一个模块,我们可以将这个类拆分成基类和子类。基类中之定义了接口,实现全部在子类中。在测试代码中,我们可以继承基类,这样就可以实现 Mock 一个模块的目的。
然而,仅仅完成了 Mock 类的实现并不足够。对于完整的程序的来说,内部会划分成很多的层次。如果我们想对上层模块进行测试,且还要 Mock 一个底层的模块,那如何保证上层代码会使用到 Mock 类,而不是原始的类的。
这里我们举个例子:
当我们写单元测试时,如何能够保证当 Bar::func 被调用时,访问的是 FooMock::foo,而不是 FooImpl::foo?
在这里,我们可以借用 Factory 的思想。对象的构建不是直接通过 new,而是通过一个函数来完成。例如:
此时,单元测试代码可以继承 FooFactory,并在 CreateFoo 的时候,返回一个 FooMock,并在创建 Bar 的时候,把我们实现的 FooFactory 作为参数传给 Bar。
这种技术在 LevelDB 中被广泛使用。在 LevelDB 中,存在大量的基类的定义,例如 SequentialFile、RandomAccessFile、WritableFile 等。同时,也定义了一个 Env 的基类,其中包含了这些类的 Factory 方法。
提到多态,C++ 高手们肯定还会提到模板。实际上,模板和继承的方式都可以实现多态,各有优缺点,这里我们就不再对模板的方式进行讨论了。总的来说,思想都是类似的。具体选择模板还是集成,就要看实际场景,以及个人喜好了。
单元测试技术之 Instrumentation
Mock 可以让我们模拟底层模块的行为,以对目标代码进行测试。而有的时候,我们希望直接检查和控制目标代码的行为。例如,我们希望在单元测试中检查一个代码路径被执行的次数。这个时候,我们可以用 Instrumentation 技术。
Instrumentation 技术有很多种不同的实现方式,在单元测试中,我们通常使用一些简单的宏定义的方式实现。
例如,我们有一个函数 Foo,其中包含两段比较重要的代码逻辑。
我们在实现这个函数的时候,使用到了宏 TRACE_POINT,用于注入两个 Trace Point。其中,POINT 1 和 POINT 2 是这两个 Trace Point 的名字。在这个宏里面,我们可以选择执行一些回调函数。默认行为自然是什么都不做,而在单元测试中,我们可以在回调函数中为每一个 Trace Point 分配一个计数器,用于统计代码路径走到的次数。我们也可以在回调函数中做一些更复杂的处理,以进行更复杂的执行控制。
然而简单的宏 + 回调函数的方法实现的 Intrusmentation 存在一定的限制,无法随意的更改程序运行的行为。例如,我们很难通过回调函数的方式,实现在 TRACE_POINT 处从 Foo 函数返回一个特定的返回值(Exception 虽然可以跳出 Foo 函数,但是无法控制返回值)。
这其实就涉及到了 Flow Control 的技术。如果想要实现 Flow Control,需要更深层次的 Intrumentation,或者从语言层面的支持,例如 Continutation。关于 Flow Control 的技术,我们就不再这里展开了,有机会的话,我们会通过单独的文章介绍。
单元测试技术之实战
以上的技术并不是互斥的,可以结合在一起使用使用。而有了这些武器,我们几乎可以完成所有我们想在单元测试中完成的事情。
接下来,我们将简要介绍一下我们如何对 ZBS 的存储引擎进行测试。
我们以写请求举例。通常一次写请求会经历多个 IO 步骤,包括:
write journalsync journalwrite datawrite metadata
在 ZBS 中,我们将磁盘 IO 封装为了 FileHandle 类。为了模拟 IO 的异常行为,我们对底层的 FileHandle 进行 Mock。对于任何一个 IO 请求,都可能会触发以下异常行为:IO 错误,IO 延迟。
其中 IO 错误的模拟很简单,只需要在 Mock 函数中返回一个错误状态就可以了。IO 延迟则可以通过调用特定的 Sleep 函数进行模拟。例如,我们的 FileHandle 底层都是通过 Coroutine 实现的异步 IO,那么可以调用相应的 CoroutineSleep 函数来模拟 IO 延迟,这样可以实现只阻塞特定 IO 请求所在的 Coroutine,而不会阻塞整个线程。在 Mock 函数中,我们还可以通过显示的调用 Yield 函数,让这个 Coroutine 处于长期阻塞状态,直到另一个事件被触发再唤醒,这样可以模拟不同事件发生的顺序。这种方法在验证并发 IO 的正确性时经常会用到。
此外,在每两个步骤之间,还可能发生:因程序 Crash 缓存数据丢失,或 silence data corruption。
为了模拟因程序 Crash 而导致缓存数据丢失,我们可以在 Mock 类中实现一个简单的内存缓存,用于模拟操作系统或硬盘的缓存。在 Write 函数被调用时,并不会把请求下发到磁盘,而是缓存在内存,直到 Sync 函数被调用。我们在单元测试程序中,可以在某次 Sync 函数被调用的时候,不执行 Sync 操作,而是把存储引擎 Shutdown 并重启。在重启后,对数据和元数据的一致性进行检查,来验证程序逻辑是否正确,是否满足了 Crash Consistency 的要求。
Silence data corruption 的模拟也是类似的道理,我们可以通过 Mock,修改下发到磁盘的 IO 请求的 buffer 内容,模拟 silence data corruption。然后再对数据进行读取,并触发 checksum 校验流程。以保证我们的 checksum 机制是可以正常工作的。
有了这些基础后,我们可以通过单元测试框架提供的方法,把所有的故障可能性穷举出来,以尽可能的提高测试覆盖率。
写在最后
到此,我们在单元测试中常用的技术就介绍完了。目前 ZBS 使用的测试框架是 GTest 和 GMock。其实掌握了以上的技术和思想,无论用哪个语言,哪个测试框架,都可以达到很好的测试效果。最重要的是,要对代码和技术保持有敬畏之心,这样才能做出优秀的产品。
如果你和我们一样,都认可测试的价值,那么欢迎加入我们,有兴趣者可联系 kyle@smartx.com 。
作者介绍
张凯,毕业于清华计算机系,毕业以后加入百度基础架构部工作了两年,主要从事分布式系统和大数据相关的工作。张凯也是开源社区的代码贡献者,参与的项目包括 Sheepdog 和 InfluxDB。其中 Sheepdog 是一个开源的分布式块存储项目,InfluxDB 是一个时序数据库(Time Series Database,TSDB)项目。2013 年张凯从百度离职,和清华的两个师兄一起创办了 SmartX 公司。
评论 1 条评论