背景我们团队主要负责淘宝的BehaviX模块。代码主要是一些逻辑功能,很少涉及UI。为了减少两端的不一致性,提高性能,我们采用了CodeC++策略。由于团队项目水平低,测试同学难以全覆盖,回归成本高,部分功能依赖研发同学自测。为了提高系统的稳定性,我们在团队中实施了单元测试。同时,由于本人相关经验不多,所以分享一下团队在单元测试中遇到的问题和解决方法,希望对大家有所帮助。?为什么要使用单元测试1.快速运行如果测试人员手动测试,测试周期可能会很长。对于功能比较复杂的功能,测试人员可能无法完全覆盖所有预期的环节,或者可能因为某些操作而遗漏了一些关键步骤。2.降低回归成本使用单元测试,您可以在每次代码修改后重新运行整套测试,尽可能确保新代码不会破坏现有功能。3.优化代码结构当代码耦合度很大时,可能难以进行单元测试。为您的代码编写测试将自然地将您的类按其预期功能分开。单测工程搭建流程?单测环境搭建运行环境的选择C++工程由于对一些第三方库的依赖(需要准备多平台链接库),在不同操作系统上运行有点困难.为了使单测项目快速运行,也方便开发者调试,考虑到Android/iOS同学的开发习惯,运行环境支持单测,支持在MacOS和Linux下运行。依赖剥离由于单机测试环境运行在计算机环境中,因此必须去除一些外部依赖。当Java/OC的API依赖涉及跨语言通信时,由NativeBridge封装,内部通过macros或cpp文件链接来区分Android和iOS环境。外部库的依赖一般采用源码依赖或类型多平台链接库(需要MacOS和Linux版本的依赖)以依赖的方式解决。?单测框架目前业界主流的C++单测框架是google的gtest+gmock。gtest在单元测试中提供了一些断言工具,gmock提供了一些mock函数,但是功能比较弱。?MOCK工具gtest提供的gmock工具比较弱,只能通过继承来mock虚函数,对于C++来说极为不便。在Java中,成员方法默认可以被派生类重写。Java中主流的mock工具Mockito就是利用这个特性来完成mock操作的。在C++中,默认情况下所有的函数都不能被重写,还有一些静态函数和实用函数是不能通过继承和重写来mock的。最后我们基于开源的hook工具frida(地址:https://github.com/frida/frida)进行封装,实现了自己的mock工具。fridagmock普通成员函数??虚函数??静态函数??final函数??不需要为mocks重构代码???部署到服务器运行依赖安装为了让单元测试项目和其他系统打通(比如:钉钉群,Aone),单元测试项目也支持在Linux环境下运行。由于C++语言的特殊性,从原生环境(MacOS)迁移到Linux并不容易。群机服务器使用CentOS,只能下载内网环境已有的软件,版本比较老,群机对C++环境的支持较弱,如:编译器不支持C++11语法,CMake版本低,没有Clang编译器等。因此,我们将大部分依赖以源码的形式导入服务器机器,编译安装可执行文件。依赖安装为了单元测试项目与其他系统(如钉钉、Aone)的连接,单元测试项目也支持在Linux环境下运行。由于C++语言的特殊性,从原生环境(MacOS)迁移到Linux并不容易。群机服务器使用CentOS,只能下载内网环境已有的软件,版本比较老,群机对C++环境的支持较弱,如:编译器不支持C++11语法,CMake版本低,没有Clang编译器等。因此,我们将大部分依赖以源码的形式导入服务器机器,编译安装可执行文件。?外围函数构建覆盖率单测代码覆盖率通过加入编译参数-fprofile-arcs和-ftest-coverage,编译完成后每个源文件都会生成一个对应的.gcno文件,最后会生成一个.gcda程序运行文件,然后可以使用lcov/gcov统计单元测试运行后代码运行的覆盖率。请注意,建议使用动态链接将您的被测项目库链接到每个测试用例。如果使用静态链接,在运行单元测试后,可能会有一些文件没有被任何用例覆盖。.gcda文件,这些源文件在计算代码覆盖率时被忽略。增量代码覆盖使用gitmerge-base来获得两个提交的最佳共同祖先。获取最佳共同祖先和当前节点的提交记录,通过gitdiff和gitblame可以得到两次提交的增量代码行数,结合代码覆盖率计算增量代码覆盖率。内存泄漏检查C++代码很容易写出内存泄漏,所以我们在单元测试项目中集成了valgrind工具,可以有效检测内存泄漏代码。下面是钉钉群广播的一个简单例子,每次代码合并到develop分支时,钉钉群会广播本次测试通过率、代码覆盖率与上次合并的时间差等信息,以便大家及时修复Problem,覆盖率增加差异也可以调动团队编写单元测试的积极性。codereviewcheckpoint提交codereview时,可以看到代码的单测通过率、单测覆盖率、增量覆盖率等信息。如果单元测试运行失败,或者增量覆盖率检查点未通过(目前团队要求增量单元测试覆盖率达到90%),不允许合并代码。单元测试实践?如何编写有效的单元测试用例一个单元测试的组成单元测试一般由以下部分组成测试数据:尽可能稳定,减少对不确定因素的依赖逻辑执行体:理清当前测试用例测试哪个函数,哪个分支逻辑,不要一次性覆盖大部分结果Check:尽可能完整,不要只检查函数的返回值单元测试原则单元测试必须遵循的原则:Independence:单元测试是独立的,可以独立运行,不依赖于文件系统或数据库等任何外部因素。幂等性:每个单元测试运行应该与其结果一致,在测试中不依赖时间日期等不确定因素快速:不依赖网络请求等耗时操作经验总结在编写单元测试时,它建议从以下几个角度思考实现什么功能,处理什么数据,最终输出什么?例外和界限在哪里?功能的关键结果是否经过验证?包含返回值和中间值。函数的风险在哪里,哪部分逻辑不自信,最容易出错的不是所有的函数都需要测试,比如get/set等简单的逻辑,不一定需要写。?提高代码的可测试性C++是多范式语言,由于C+语言本身的一些特性(RAII、模板等),网上很多基于Java等语言提高可测试性的方法都是对于C++来说可能太麻烦了,比如依赖注入等,可能不是特别适用。下面是一些简单且常用的提高可测试性的方法。影响可测性的常见因素过多的外部依赖需要mock数据依赖链太长,导致构建测试数据困难分支逻辑过于复杂全局变量/静态变量内部lambda表达式依赖过多类对象无法构造/难以构造函数函数过多减少全局变量/静态变量的使用如果你的对象依赖于一些全局变量/静态变量,而这些全局变量会在多个测试用例中使用,这种情况比较难测试,你必须在每个测试用例结束后手动重置全局变量。这不符合单元测试的独立性原则,所以应该尽量避免使用全局变量。MyTest类{public:intGetIndex(){returnindex++;}静态整数索引;//静态变量};intMyTest::index=0;TEST(test,demo){ASSERT_EQ(0,MyTest().GetIndex());}TEST(test,demo2){ASSERT_EQ(0,MyTest().获取索引());//错误}TEST(测试,演示){MyTest::index=0;ASSERT_EQ(0,MyTest().GetIndex());}TEST(test,demo2){MyTest::index=0;ASSERT_EQ(0,MyTest().GetIndex());}迪米特法则如果在代码中引入一些复杂的外部依赖,可以考虑将依赖转移给调用者如:classMyClass{public:voiddoSomething(){if(getUserManager().getUser(123).getProfile().isAdmin()){//糟糕的复杂依赖链//xxxx}else{}}};classMyClass{public:voiddoSomething(boolisAdmin){//简单参数依赖if(isAdmin){//xxxx}else{}}};直接依赖需要的参数,避免了类似Context的依赖和全参数(可能构造起来非常困难)如:classMyClass{public:voidprocessOrderBefore(constUserContext&userContext){//constUser&修改前的用户=userContext.getUser();constPlanLevel&level=userContext.getLevel();constOrder&order=userContext.getOrder();//...process}voidprocessOrderAfter(constUserContext&userContext){//修改后的constUser&user=userContext.getUser();constPlanLevel&level=userContext.getLevel();constOrder&order=userContext.getOrder();processOrderAfter(用户,级别,订单);//核心逻辑提取到一个新的函数中}voidprocessOrderAfter(constUser&user,constPlanLevel&level,constOrder&order){//只需要对新的封装函数进行单元测试//...process}};封装分支逻辑如果一个函数中的分支太多,可以考虑将不同的分支封装成不同的函数进行处理,然后针对封装好的函数编写单元测试用例合理使用MOCK工具考虑在以下场景中使用mock工具,这可以降低您的单元测试成本。代码中你依赖的某个功能在本次测试中你并不关心,比如:db数据读取,发送请求测试用例依赖一些复杂的数据源,比如:db数据读取,管道上游数据,网络requests,一些非幂等函数调用或返回不稳定结果的函数调用,如:随机数获取,时间获取,对象的某些状态写入db难以创建或重现,如:网络错误或文件读写错误验证一些中间过程值,比如:你的函数没有返回值,或者中间过程值不方便验证,可以在中间mock一个函数调用来验证中间过程结果是否正确,试试test-驱动开发(TDD)?如果你需要实现的功能比较明确,可以先定义接口,写最简单的实现并运行,辅以单元测试用例,再逐步完善细节。实施细节。不能先写测试用例也没关系,重要的是在开发中尽早写测试用例,不要拖到最后才写,这样才能及时重构自己的代码。?常见误区只测试正常数据,尽量补充一些特殊值(如空值、边界值)或异常数据,以验证目标函数在不同输入下是否符合预期,并尽可能多地覆盖代码分支逻辑。结果验证不完整如果在目标测试函数中修改了属性,应该尝试验证这些修改是否符合预期,而不是仅仅验证函数的返回值。输入数据过于复杂生成测试输入数据的代码应避免与实际工程代码耦合,如:读取db或从pipeline的上游生成,使用最小数据依赖原则,只输入将要生成的数据影响当前的测试用例。如果数据源结构过于复杂,可以将一个大的测试用例拆分成多个小的测试用例。测试代码中有分支条件,避免在测试用例代码中使用if、switch等分支逻辑,使用例尽可能简单。如果需要测试不同分支的代码逻辑,应该拆分成多个测试用例。?在维护测试用例和重构代码时,要同步修改测试用例。当发现新的bug时,单元测试项目中应增加能验证bug修复的测试用例?测试用例命名规则参考TEST_F(TestUCPPipelineCenter,checkTaskInProcess_Repeattrigger_true);测试宏测试类名,测试函数名_核心测试逻辑简单描述_待验证结果值总结我们团队的单元测试项目已经稳定运行了一段时间,代码提交过程逐渐固化下来,如图下面的图片。以后我们会寻找一些指标来量化和衡量单元测试带来的收益。希望本文能帮助大家更快的搭建C++单元测试环境。限于我的水平。如有不足或错误,敬请批评指正。
