作者 | 棋李
来源 | 阿里技术公众号
一 业务背景
高德在线导航服务作为有很强业务特性和多年历史积累的存量系统,不可避免的存在大量的不合理代码,而业务演进对系统性能、算法、底层架构等不断提出更高要求,存量的各种业务代码和算法、架构快速演进的诉求存在严重冲突,如何有效保障质量地进行快速重构式演进,成为业务发展面临的首要工程难题。
二 现有质量保障方法问题与分析
1 现有测试方法的问题
常规方法是对新老服务批量进行请求比较diff,这种方式简单有效,是我们一直在用的方法,但存在以下问题:
- 无效diff问题:以公交规划引擎为例,依赖步导引擎、搜索、公交突发事件、路况等多个下游服务,获取结果的差异导致很多无效diff。
- 运行时间较长:case量较多时运行时间较长,在10分钟级别。由于这一步成本较高,一般开发人员跑diff的频率不会太高,无法进行"每次一小步"的测试。
- 排查困难:当发现diff后进行排查非常困难,因为是整个请求级别的diff,中间步骤可能都存在问题。
2 业界主流方法实践
ThoughtWorks、Google等公司使用TDD方式进行敏捷开发,通过编写单元测试用例保障开发、重构的质量,目前已经成为主流最佳实践。
三 单元测试介绍
1 什么是单元测试?
单元测试是对一个模块、一个函数或者一个类进行正确性检验的测试工作。
测试的粒度更小更轻量,运行时间在秒级,特别适合渐进式重构中的"每次一小步"的质量保障。
由于单元测试用例针对的是一个函数、类更细粒度的目标,所以当某个用例不通过时,可以快速锁定问题点。
2 单元测试框架
常见单元测试框架有 xUnit 系列,多种语言都有对应实现,如CppUnit、JUnit、NUnit...
GTest是Google开发的单元测试框架,此框架具有一些高级功能,如death test, mock等。
我们选择的是GTest框架。
3 单元测试、重构、TDD与敏捷
TDD(Test Driven Development)是强调测试先行的开发方式,这种方式的好处在于编写任何函数、修改任何代码时可以通过编写一个单元测试用例代码来表达要实现的代码功能,一个测试用例本身就是一个代码表达的需求。而积累起来的测试用例可以有效保障开发及后续重构演进的质量。
重构和TDD是敏捷方法的核心构成要素,脱离了TDD的敏捷是危险的,没有用例保障的重构一旦启动,就像一匹脱缰的野马。而单元测试和TDD则是缚住野马的缰绳。
四 公交服务单元测试实践
1 GTest框架集成
Git库地址:https://github.com/google/googletest
GTest框架集成非常简单,把googletest库加入到工程中, 增加链接 libgtest 即可:
通过如下代码即可驱动用例执行:
int RCUnitTest::Excute()
{
int argc = 2;
char* argv[] = {const_cast<char*>(""), const_cast<char*>("--gtest_output=\"xml:./testAll.xml\"")};
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
开关控制:为避免影响到正式版本, 可以考虑通过编译控制,也可以增加一个配置项开关。
我们在使用时是在入口处通过一个配置项控制是否触发单元测试用例,编译时默认只链接入口文件,需要运行单元测试时添加上单元测试用例文件进行链接运行。
2 测试代码编写
通过实现一个Test类的派生类,然后使用TEST_F宏添加测试函数即可,如下示例:
class DateTimeUtilTest : public ::testing::Test
{
protected:
virtual void SetUp()
{
}
virtual void TearDown()
{
}
};
TEST_F(DateTimeUtilTest, TestAddSeconds_leap)
{
//闰年测试 2020-02-28
tm tt;
tt.tm_year = (2020 - 1900);
tt.tm_mon = 1;
tt.tm_mday = 28;
tt.tm_hour = 23;
tt.tm_min = 59;
tt.tm_sec = 50;
DateTimeUtil::AddSeconds(tt, 30);
EXPECT_TRUE(tt.tm_sec == 20);
EXPECT_TRUE(tt.tm_min == 0);
EXPECT_TRUE(tt.tm_hour == 0);
EXPECT_TRUE(tt.tm_mday == 29);
EXPECT_TRUE(tt.tm_mon == 1);
//非闰年测试 2019-02-28
tm tt1;
tt1.tm_year = (2019 - 1900);
tt1.tm_mon = 1;
tt1.tm_mday = 28;
tt1.tm_hour = 23;
tt1.tm_min = 59;
tt1.tm_sec = 50;
DateTimeUtil::AddSeconds(tt1, 30);
EXPECT_TRUE(tt1.tm_sec == 20);
EXPECT_TRUE(tt1.tm_min == 0);
EXPECT_TRUE(tt1.tm_hour == 0);
EXPECT_TRUE(tt1.tm_mday == 1);
EXPECT_TRUE(tt1.tm_mon == 2);
};
测试用例执行效果:
目前公交引擎已经积累了23个模块测试用例,基本覆盖了寻站、寻路、ETA、票价、风险停运等核心功能,持续积累中。通过单元测试保障,每个版本开发活动中都在进行渐进式重构活动,能够有效保障质量,提测迭代次数和线上新增代码引入问题数量持续较低。
3 问题与难点
数据依赖问题
在线导航引擎是对数据重度依赖的业务,多组数据结构之间互相关联,字段繁多,很难脱离数据构建有效的单元测试。通过mock方式构造假数据成本很高。而数据变化将导致用例不能通过。
我的实践:
能够简单构造假数据的通过构造假数据来搞定。
对于很难构建假数据的情况,直接使用真实数据即可。数据变化可能导致这部分用例不通过,没有关系,只需要保障在每次重构前把相关的用例调通即可,这样仍可以确保重构过程的质量。即:不需要做到用例随时随地都能运行通过,而是保证重构前后都可以通过。
4 常见错误认知
对于没有真正实践过单元测试和TDD开发方式的同学来说,有一些认知上的常见误区,比如:
开发时间都不够, 哪有时间编写单元测试?
我的理解:
- 首先TDD的开发方式强调的是测试先行,编写测试代码是在前面的,这个过程等于是理解需求的过程。即想清楚你要实现的是什么功能?这个测试代码是理清需求的产物, 如此而已,不存在更多时间成本。
- TDD开发方式属于典型的一次投入,持续受益的事情,用例积累越多,越容易在早期发现问题,重构有了质量保障,代码越来越整洁清晰,开发同学们再也不用哀叹历史代码。
历史代码那么多,怎么补单元测试?
那就从添加第一个用例开始。我的做法是对应本次修改涉及到的代码添加用例,逐步积累。
添加用例的过程是理解现有代码的过程,对于存量的历史代码,各种硬性编码侵入,各种耦合,全局变量或长生命周期大对象,通过编写单元测试用例能够有效理清函数真正的输入输出,也为重构增加了有效保障。
五 存量复杂系统代码渐进式重构
对于我们一线码农,每天大部分时间都在和代码打交道,如果你维护的代码结构合理、易读易扩展,那么恭喜你!但大部分情况我们面对的是存在各种历史"积淀"的存量工程,各种牵一发而动全身,这种情况下小改动还可以靠多花时间,认真仔细来搞定,但想要做一些大的系统升级就难了。
而对于巨型业务系统来说,重写在成本和质量控制方面显得更不现实。那么设置几个大的节点,通过渐进式重构逐渐优化,变量变为质变,是综合来看最优的方式。
而单元测试和TDD,则是渐进式重构有效开展的必选方法。