测试,你写代码时最好的朋友(中篇)

上篇中,我们主要讨论了写测试的理由,原则和如何写出易于测试的代码。这一篇里,我们会来真正讨论如何写出好的测试。

这里重要的事情再重复一遍:测试不是一剂万能药,加几个测试并不能帮我们改善多少代码质量,把代码本身写好才是根本。如果还没有读过上篇,这里强烈推荐先阅读上篇


1. 好的测试

现在,我们终于可以开始写测试了!好的代码只是我们实现好测试的第一步,要写出好的测试我们还需要更多的一些技巧:

1.1. 选择合理的测试

我们知道测试的类型多种多样,有且不限于如下一些类型:

  • 单元测试:通过调用一些特定的函数(比如公开的成员函数)来测试一个类或者一个小的模块是否工作正常
  • 模块/服务测试:通过模拟一个模块或服务的上游服务和下游服务来测试它是否工作正常
  • 端到端测试(End-To-End Test / E2E Test):通过模拟整个系统的上下游服务来测试这个完整的系统是否工作正常
  • 集成测试:将所有服务,甚至多个系统,按照要求组装起来,进行测试
  • 在线测试或探针测试:服务内部定时使用API对其服务状态进行测试

这些测试也各有利弊,大致如下:

测试类型 实现难度 本地测试? 测试运行时间 发现问题的时间 发现问题的复杂度 调试问题的难度
单元测试 容易 简单 简单
模块/服务测试 较为容易 可以实现 较快 较早 一般 较为简单(如果可以本地调试)
端到端测试 一般 可以实现 一般 一般 较为复杂 较为简单(如果可以本地调试)
集成测试 较难 较晚 复杂
探针测试 一般 较快 一般 较难

所以,在编写测试的时候,我们需要综合考虑我们要测试的内容,并选择合适的测试方式进行测试。

选择测试时,我们一定要注意最短距离原则。比如,我们会觉得集成测试中被测的对象最多,覆盖范围最广,所以我们应该大力推行集成测试。但正是因为其覆盖面大,如果真的这样做,我们可能会花费极大的工作量构造这些测试,并且最后可能什么问题都调试不出来。试着想象一下,几十个服务之间互相调用了好几百次之后,某个服务在一个很奇怪的地方抛了一个异常,最后要来查错的工作量吧。

1.2. 合理的提示信息

“Transparency is a passive quality. A program is transparent when it is possible to form a simple mental model of its behavior that is actually predictive for all or most cases, because you can see through the machinery to what is actually going on.”

- Eric Steven Raymond, from “The Art of Unix Programming”

虽然测试可以告诉我们有东西出错了,但是很多人往往忽略了一点:什么东西出错了,该检查什么更加重要。

1.2.1. 清晰的测试场景

首先最容易被忽略的就是测试的名称,以下列出的这些名称都是我在真实项目里看到过的:

1
2
3
4
5
6
Test
Test1/2/3/...
TestSwitch
TestBasic
MyTest
FooTest/TestFoo // Foo是被测试的类的类名

这些测试名完全不能帮助我们理解它们的目的,所以出了错我们也不知道要看什么。这里更好的方式是描述好测试场景和使用断言式命名

1
2
3
4
ScopeHandle_AfterDtor_UnderlyingHandleShouldBeClosed
CrontabTimer_ParsingValidCrontabSyntax_ShouldSucceed
DataProcessor_ProcessingDataWithUnexpectedFormat_ShouldFail
HealthProber_ProbeUnresponsiveHttpTarget_ShouldFailWithTimeout

不要担心函数名长,有时候,长既是美。

1.2.2. 明确的错误信息

当测试失败时,错误信息一定要明确,或者说要能Actionable。

比如:写断言时,不要就简单的写类似Assert(a != 0);,而最好能给出一些建议,比如:

1
2
Assert(dataIterator != dataMap.end(),
"Data is not created while id still exists. It might be a leak on id. Please check CreateData/RemoveData functions.");

由于错误明确且指向性非常强,调试这样的错误将会变得容易很多。

1.2.3. 一目了然的行为变化

我们上面提到,测试的作用之一是确认代码改动。其实不仅如此,为了保证高可观察性,这些改动导致的程序行为变化必须要一目了然。这些变化包括数据状态的变化,数据上报,甚至是调试辅助工具(比如,获取服务的状态)。

这个想法很好,但这也导致了一个难题,如果我们要看到一个改动对于程序的所有行为变化,也就意味着,我们要对所有程序的行为进行测试。这让维护测试的工作量变得极其巨大!

这里举一个简单的栗子,通过模拟服务上下游的方式来测试一个微服务是一个非常常见的测试方法,所以相信大家都看过不少类似这样的测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
TEST_METHOD(MyService_WhenReceivingValidRequest_DownstreamServicesShouldBeProgrammed)
{
std::shared_ptr<MyService> service = CreateMyService();

MyServiceRequest mockRequest;
mockRequest.Foo = 1;
mockRequest.Bar = 2;
// ... Update all fields here.

// Simulating upstream service sending state to my service
service->SendRequestSync(mockRequest);

// Test service internal states
std::shared_ptr<const MyServiceState> serviceState = service->GetInternalState()->GetPayload<MyServiceState>();
Assert::AreEqual(1, serviceState->Foo);
Assert::AreEqual(2, serviceState->Bar);

// Validate downstream services
ServiceDownstreamStates statesToDownstreamServices = service->GetStatesToDownStreamServices();
Assert::AreEqual(2ull, statesToDownstreamServices.size());

// Validate states send to downstream service 1
Assert::IsTrue(statesToDownstreamServices.find(L"MyDownstreamService1") != statesToDownstreamServices.end());
Assert::AreEqual(1ull, statesToDownstreamServices[L"MyDownstreamService1"].States.size());
std::shared_ptr<const MyDownstreamService1Request> stateToDownstreamService1 = statesToDownstreamServices[L"MyDownstreamService1"].States[0].GetPayload<MyDownstreamService1Request>();
Assert::IsNotNull(stateToDownstreamService1);
Assert::AreEqual(1, stateToDownstreamService1->Foo);
// ... Validate all other states

// Validate states send to downstream service 2
Assert::IsTrue(statesToDownstreamServices.find(L"MyDownstreamService2") != statesToDownstreamServices.end());
Assert::AreEqual(1ull, statesToDownstreamServices[L"MyDownstreamService2"].States.size());
std::shared_ptr<const MyDownstreamService2Request> stateToDownstreamService2 = statesToDownstreamServices[L"MyDownstreamService2"].States[0].GetPayload<MyDownstreamService2Request>();
Assert::IsNotNull(stateToDownstreamService2);
Assert::AreEqual(2, stateToDownstreamService2->Bar);
// ... Validate all other states

// Validate metrics
MyServiceTrackedStats trackedStats = service->GetStatsTracker()->GetTrackedStats();
Assert::AreEqual(1, trackedStates.RequestCount);
Assert::AreEqual(1, trackedStates.Service1RequestSent);
Assert::AreEqual(1, trackedStates.Service2RequestSent);
// ... Validate all other stats
}

可以看到,哪怕已经通过大量帮助函数简化代码,这种测试写起来依然是非常繁琐。而且一旦有任何改动,所有的测试都必须跟着一起改动。这导致了维护测试的工作量异常的大,让大家对测试充满了恐惧。有没有什么两全其美的方法呢?有!我们在下面将介绍面向数据的测试技巧来帮助大家解决这个问题。

1.3. 重视需求和缺陷的覆盖率

现在有很多测试工具都提供了代码覆盖率的检测,而覆盖率也是很多项目的强制规定,虽然这确实可以一定程度的起到督促的作用,但是也会在一定程度上产生误导,让大家过分看重那100%的数据而忽略了测试要做的真正要做的事情。

为什么说代码覆盖率有误导呢?因为代码覆盖率100%并不代表所有情况都被测试到了。这听起来也许很奇怪,但是请看下面的代码:

1
2
3
4
5
6
7
8
Access GetUserAccess(UserRole role) {
Access access;
access.AddFlag(Access::Read); // Anyone can read.
if (role >= UserRole.Reader) { // Bug!!!
access.AddFlag(Access::Write); // Only writers can write.
}
return access;
}

这段代码中的错误显而易见,但是如下代码覆盖率100%的测试却不能发现其中的问题:

1
Assert::AreEqual(Access::Read | Access::Write, GetUserAccess(UserRole.Writer));

所以仅仅追求代码覆盖率完全就是在舍本逐末。那什么才是真正的本呢?这个本永远都是我们的程序到底是用来做什么的,换句话说,也就是需求。这个需求小可以是某一个类的作用,大可以是我们产品的用户场景,写测试是为了保证我们的需求实现正确且不会变坏,这就是所谓的需求覆盖率。当然,达到高的需求覆盖率很难,这需要我们对代码对产品有了解,并且先知先觉才能做到。

然后,就是同样重要,但却是后知后觉的缺陷覆盖率:以前出了错,加上测试,保证以后同样的错误不会再次发生。

这两种覆盖率才是我们真正应该看重的东西。

1.4. 专业的脚手架

有些测试直接做起来会比较困难,比如微服务设计中,由于每一个服务足够的小,我们可以对某一个服务进行整体测试,这样的测试很难直接来做,这个时候我们就可以通过预先搭建一些通用的环境来帮助我们,这就是脚手架。它将我们要测试的东西先固定在我们想要的位置上,然后再进行测试。

在测试微服务的这个例子中,我们可以使用mock模拟该服务的通信层,并且提供通用的上游服务和下游服务的mock,这样测试就简单了,我们只要通过上游服务发送我们想要的请求,并检查下游服务中的状态是否正常即可。

脚手架在大型的回归测试,集成测试和端到端测试中也使用的特别频繁,这些测试通常需要搭建一定的环境,而无法直接进行,比如需要将所有的服务创建好之后,再模拟最终用户的请求并对整个系统做验证。这些工作异常繁琐,而拥有一套通用且方便使用的脚手架能让整个团队都效率倍增。

如果你正好打算实现一个脚手架,请把它看成一个产品来对待,只不过用户是我们内部开发。这也意味着在实现之前我们需要认真的了解产品需求,时不时也需要收集用户反馈帮助我们改进,迭代。这里也列举一些实现好的脚手架的原则,也许会起到一定的帮助:

  • 使用脚手架的代价必须要比我们实现测试主逻辑的代价要低。
    • 脚手架就是用来帮助我们简化测试的。
    • 如果使用了脚手架之后,还经常出现一个测试80%的代码都在初始化脚手架上,那么这个脚手架必然是失败的。
  • 脚手架是框架,它有责任帮助大家简化测试,更有责任帮助大家避免犯错。
    • 一个好的框架即是帮手也是约束。框架的实现者有责任先知先觉,帮助大家使用正确的方法进行测试。
    • 比如actor模型,如果我们使用了任何actor模型的框架,我们将很难在一个actor中直接获取另一个actor的数据,而必须要进行消息传递。这个有时候会给我们制造些麻烦,但是也正因为有了它,我们才很难犯错。(扩展阅读:Go Proverbs: Don’t communicate by sharing memory, share memory by communicating.

2. 数据驱动测试

“Put Abstractions in Code, Details in Metadata” - Andy Hunt, from “The Pragmatic Programmer: Your Journey to Mastery”

在上面一目了然的行为变化中,我们遇到了一个难题,那就是测试越多,维护越复杂。这让我们最后对写测试望而却步。这里,我们可以通过面向数据测试来拯救我们。

2.1. 分离数据与测试

为了帮助我们简化测试,我推荐在测试中应用程序与元数据分离的思想,这也是数据驱动测试(DDT)的基本思想:把测试尽可能的进行抽象变成一个统一的脚手架,然后将变化的部分抽取成数据,并用文本储存。这样如果需要编写新的测试用例,只需要添加一组新的测试数据即可,核心逻辑不需要任何变化,而文本化的数据都能被代码管理工具轻松管理,这样所有的变动也能使用diff工具一目了然的查看。

比如,上面那段繁琐的代码,我们就可以使用如下方法进行简化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
TEST_METHOD(MyService_WhenReceivingValidRequest_DownstreamServicesShouldBeProgrammed)
{
RunMyServiceStateHandlingTest(L"MyServiceTests\\ValidRequest-Input.json", L"MyServiceTests\\ValidRequest-ExpectedStatesAfterUpdate.json");
}

struct StatesToTest
{
ServiceInternalState ServiceInternalState;
ServiceDownstreamStates StatesToDownstreamServices;
MyServiceTrackedStats TrackedStats;
};

void RunMyServiceStateHandlingTest(_In_ const std::wstring& inputFilePath, _In_ const std::wstring& expectedStatesAfterUpdateFilePath)
{
std::shared_ptr<MyService> service = CreateMyService();

MyServiceRequest mockRequest;
JsonConverter::FromJsonFile(inputFilePath, mockRequest);
service->SendRequestSync(mockRequest);

StatesToTest actualStatesAfterUpdate;
actionStatesAfterUpdate.ServiceInternalState = service->GetInternalState();
actionStatesAfterUpdate.StatesToDownstreamServices = service->GetStatesToDownStreamServices();
actualStatesAfterUpdate.TrackedStats = service->GetStatsTracker()->GetTrackedStats();

StatesToTest expectedStatesAfterUpdate;
JsonConverter::FromJsonFile(expectedStatesAfterUpdateFilePath, expectedStatesAfterUpdate);
UnitTestUtils::AssertEquality(expectedStatesAfterUpdate, actualStatesAfterUpdate);
}

这样修改之后,所有的变化都被分离成了数据,我们可以将其保存在Json文件之中,如下(如果你和我一样更喜欢ymal,也可以用yaml来存储):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"ServiceInternalState": {
"Foo": 1,
"Bar": 2,
},
"ServiceDownstreamStates": {
"MyDownstreamService1": {
"Foo": 1,
},
"MyDownstreamService2": {
"Bar": 2,
},
},
"TrackedStats": {
"RequestCount": 1,
"Service1RequestSent": 1,
"Service2RequestSent": 1,
}
}

这样,不仅代码变短了,更好阅读了,添加测试变得极其简单了,并且如果我们改动了业务逻辑,测试的代码还不用进行任何的变化。

2.2. 生成基线数据

这里,也许你会疑问,那工作量并没有减少啊,无非是从改代码,变成了改变Json的数据文件而已,这又什么本质区别吗?别着急,正是因为这个小小的变化,马上天翻地覆的变化就要开始了!

这些测试数据,真的要我们自己手写吗?其实,根本不需要!因为当测试执行完成的时候,我们已经知道了当前的状态是什么了,将当前状态存下来,不就是我们用来测试的数据吗?所以,我们只需要进行小小的改动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void RunMyServiceStateHandlingTest(_In_ const std::wstring& inputFilePath, _In_ const std::wstring& expectedStatesAfterUpdateFilePath)
{
// ...
// Replacing UnitTestUtils::AssertEquality(expectedStatesAfterUpdate, actualStatesAfterUpdate);
UnitTestUtils::GenerateBaselineOrAssertEquality(expectedStatesAfterUpdateFilePath, actualStatesAfterUpdate);
}

template <class T>
void UnitTestUtils::GenerateBaselineOrAssertEquality(_In_ const std::wstring& expectedDataFilePath, _In_ const T& actualData);
{
if (UnitTestUtils::IsBaselineGenerationEnabled())
{
JsonConverter::ToJsonFile(expectedDataFilePath, actualData);
return;
}

T expectedData;
JsonConverter::FromJsonFile(expectedDataFilePath, expectedData);
UnitTestUtils::AssertEquality(expectedStatesAfterUpdate, actualStatesAfterUpdate);
}

这样,便完成了基准数据的生成。如果我们改变了服务的行为,我们只需要将基准数据生成的开关打开,再运行一次所有的测试,这样所有的测试数据便会被全部更新,而我们一行代码都不用写。

2.3. 生成测试失败的对照数据

这还没有结束,数据生成带来的另外一个好处是,调试也简单了!不知道你有没有过这种经历,一个测试失败在两个异常复杂的对象的比较上(如下)………所以到底是哪个元素出了问题?哪些字段出了问题?我是谁?我在哪?

1
Assert::AreEqual(longListWithDeeplyNestedStructs1, longListWithDeeplyNestedStructs2);

而对于面向数据的测试来说,这些问题都不是问题,因为我们还可以生成对照数据!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template <class T>
void UnitTestUtils::GenerateBaselineOrAssertEquality(_In_ const std::wstring& expectedDataFilePath, _In_ const T& actualData);
{
if (UnitTestUtils::IsBaselineGenerationEnabled())
{
JsonConverter::ToJsonFile(expectedDataFilePath, actualData);
return;
}

T expectedData;
JsonConverter::FromJsonFile(expectedDataFilePath, expectedData);

try
{
UnitTestUtils::AssertEquality(expectedStatesAfterUpdate, actualStatesAfterUpdate);
}
catch (...)
{
UnitTestUtils::GenerateActualDataFileWhenTestFailed(actualData, expectedDataFilePath);
throw;
}
}

这样,一旦测试失败,我们就会得到对照数据,两个文件用diff工具打开一看,有任何改动都一目了然。

也许你会觉得奇怪,我们难道不可以直接生成基准数据,然后直接看diff吗?这个本地开发确实是可以的,但是有时候某些错误只发生在每日构建服务器上,这个测试失败的对照数据就变得相当有用了。这个极大的增加了测试失败的可观察性。

2.4. 卖个关子

我们项目组目前就是采用了这种测试模式,并非常有效的降低了测试的开发和维护成本。希望到此,你也会对数据驱动测试开始感兴趣,并开始慢慢的使用它。不过,如果它的威力并不仅仅止于此。由于篇幅的限制,我们就暂时讨论到这。下一篇(应该也是最后一篇),我们会来讨论它的进阶版本 —— 面向数据的测试,并演示如何使用其更好的帮助我们项目的开发。


3. “假”测试

在写测试时,我们要对几种测试特别小心,这些测试即便存在,也不会对我们有任何帮助。很多时候,这些测试还会拖慢我们开发的进度。

3.1. 不稳定的测试(Flaky tests)

首先,也就是最臭名昭著的不稳定的测试了,这个我们在上篇中也提到过。对于这一种测试,我们应该尽快禁用,并将其当作高优先级开发任务处理,如果测试不修复,则新功能的开发必须停止。原因很简单,如果测试都不能通过,我们怎么知道这个问题不会在最终用户那边引起什么问题呢?

发现这种错误的方法除了听到多个开发都在抱怨以外,都比较简单粗暴,因为只有把测试运行多次才能知道它是否稳定。所以,要么查看测试的历史数据(下一节会提及),要么就每隔一段时间触发一次测试稳定性测试,把每个测试都跑上很多次,找出不稳定的测试。

我曾经被要求修复过这样一个测试,问题是这样的:“这个测试在一周前大概只有20%的机率会失败,但是这一周感觉上涨到了30%,你能看一下是怎么回事吗?”。对这个问题我真的无从下手,首先,我不知道是不是因为这一周你水逆,结果多挂了几次,然后,我也不知道我遇到的错误是已经存在的可以忽略的20%,还是我需要修复的10%?所以,最后,我的处理方法是:

  1. 先把这个测试完全禁用,反正所有人遇到这个错误也都是直接重试,浪费编译和测试服务器的资源;
  2. 把这个测试完全读懂;
  3. 根据出现的错误创建新的测试,并稳定的将错误重现;
  4. 修复新的测试并提交,再重复步骤3-4,直到所有的错误都修复完毕;

最后,由于原测试自己写的就有问题,我又创建了一个新的稳定的测试用来测试原测试本身想要测试的场景,提交之后,我就把原测试删除了。

3.2. 缓慢的测试

测试是用来帮助我们快速找到问题的,也就是说我们应该尽可能的早测试,多测试,但是如果一个测试速度很慢的话,我们就无法做到这一点,久而久之就没有人会来运行这个测试了。像上篇中那个动不动就盲目Sleep30秒的测试,在我们项目中几乎重来没有人会去运行它,那些测试一旦出了错,大家甚至连调试的欲望都没有,就直接把测试注释掉……所以千万不要忽视测试的性能。

当然虽然我们不提倡盲目的追求测试的速度,但是对于不同类型的测试的速度,我们会有不同的的期望,比如大型的集成测试或者端到端测试,可能需要跑上几个小时,不过这种测试我们可能一天也就跑一次,用于衡量每日构建出来的版本的质量,所以就算花上一个小时也是没有什么太多影响的。但是,如果说这种测试一次要跑好几天,可能就不是很好了。比如,你需要一个版本做紧急修复一个线上问题,版本构建完毕之后,测试需要等上三天才能知道这个版本是不是能发布,这个时候最终用户估计就已经发狂了。

另外,这里还有一个例外,就是性能测试。性能测试一般都需要将一个场景重复跑很多遍,所以花时间是理所当然的,但是同样的,这也导致了我们并不会频繁的运行它们。为了保证这些测试不拖累其他的测试,我们可以将性能测试单独放在一个测试类或者测试模块中,以起到隔离的作用。

3.3. 浅探针

探针测试非常有用,一般用来检查线上服务运行是否正常。一旦出错,就触发报警,修复或者回滚。这个在自动化中相当重要,不然每次服务升级都必须要定时手动检查,或者手动修复,效率极其低下。

很多服务治理的框架都提供了探针支持,比如,k8s的Readiness Probe和Liveness Probe。这些探针提供了方便配置且多种多样的实现支持,比如,发起tcp连接,或者发送http请求,并检查连接和返回结果是否正确。这些探针本意在于提供一个方便和统一的机制来检查服务是否健康,但是这也导致了一个很常见的问题:探针测试检查的内容也过于简单。比如,只要服务启动成功就算是健康了。然而,要能正确的服务用户的请求,仅仅服务成功启动是远远不够的。这也就牵涉到了一个问题:**服务要多健康才算健康?**这也是实现正确实现探针测试的关键。

这个问题,其实很难经的起仔细思考,因为我们很快就会发现一个很恐怖的事实:**一个服务要完全没有问题,才能说是健康!**这意味着一个错误数据都不能有,一个数据不一致都不能有!因为只有这样我们才能说,这个服务能够正确服务任何用户的请求。这也就是说探针测试应该检查服务中所有部分的运行状态是否正常,所有的配置和用户数据是否正确。我把这种探针实现叫做深探针(Deep Probe)。

不过,深探针也会带来一个问题:它的执行时间太长了,根本没办法在一个探针请求中完成。所以,一般深探针的实现会和普通的探针有所不同:

  • 利用定时器和主动上报:有些服务治理框架对于健康检查的模型不是拉模型(定时探针),而是推模型(健康上报),比如Service Fabric。所以我们可以使用定时器定时检查服务中的状态,如果发现问题就进行错误上报,或者停止心跳,从而报告服务健康状况。
  • 创建专用的深探针服务:如果需要测试的数据量太大,我们还可以创建专用的深探针服务来进行测试。深探针服务可以将相关的信息从其他服务中读取过来,并运行测试和上报错误。这样做可以非常方便的控制整体的测试频率,避免对正常服务造成太大压力。当然这也需要我们的服务有着非常良好和统一的服务发现和状态发现机制,以支持探针服务的运行。

4. 再休息一下吧

好了,感觉写的又有点长了,我们就当这是中篇的结束吧,这里继续总结一下:

  1. 首先,我们讨论了如何写出好的测试:
    1. 选择合理的测试类型;
    2. 对测试错误给出合理的提示信息,其中包括断言式的测试名,明确的错误信息和一目了然的行为变化;
    3. 追求需求覆盖率和缺陷覆盖率,而不是代码覆盖率;
    4. 搭建专业的脚手架帮助我们简化测试工作量。
  2. 为了达到一目了然的行为变化,维护测试的代价变得极其高昂,所以,我们讨论了面向数据的测试,并演示了如何其大大地降低测试维护的成本,提高测试失败的可观察性和加快我们调试错误失败的速度。
  3. 最后,我们讨论了几种虚假的测试:不稳定的测试,缓慢的测试和浅探针,这些测试都没有办法起到测试应有的作用;

下一篇,我们来继续讨论更多关于面向数据测试的问题。


同系列文章:
原创文章,转载请标明出处:Soul Orbit
本文链接地址:测试,你写代码时最好的朋友(中篇)