一、 测试的重要性
测试很重要!测试很重要!测试很重要!重要的事情说三遍。
场景1:每次我们写完代码后都需要编译运行,以查看应用程序的表现是否符合预期。假如改动点、代码量小,那验证成本低一些,假如不符合预期,则说明我们的代码有问,人工去排查问题花费的时间也少一些。假如改动点很多、受影响的地方较多,我们首先要大概猜测受影响的功能,然后去定位问题、排查问题的成本就很高。
场景2:你新接手的 SDK 某个子功能需要做一次技术重构。但是你只有在公司内部的代码托管平台上可以看到一些 Readme、接入文档、系统设计文档、技术方案评估文档等一堆文档。可能你会看完再去动手重构。当你重构完了,找了公司某条业务线的 App 接入测试,点了几下发现发生了奔溃。😂 心想,本地测试、debug 都正常可是为什么接入后就 Crash 了。其实想想也好理解,你本地重构只是确保了你开发的那个功能运行正常,你很难确保你写的代码没有影响其他类、其他功能。假如之前的 SDK 针对每个类都有单元测试代码,那你在新功能开发完毕后完整跑一次单元测试代码就好了,保证每个 Unit Test 都通过、分支覆盖率达到约定的线,那么基本上是没问题的。
场景3:在版本迭代的时候,计划功能 A,从开发、联调、测试、上线共2周时间。老司机做事很自信,这么简单的 UI、动画、交互,代码风骚,参考服务端的「领域驱动」在该 feature 开发阶段落地试验了下。联调、本地测试都通过了,还剩3天时间,本以为测试1天,bug fix 一天,最后一天提交审核。代码跟你开了个玩笑,测试完 n 个 bug(大大超出预期)。为了不影响 App 的发布上架,不得不熬夜修 bug。将所有的测试都通过测试工程师去处理,这个阶段理论上质量应该很稳定,不然该阶段发现代码异常、技术设计有漏洞就来不及了,你需要协调各个团队的资源(可能接口要改动、产品侧要改动),这个阶段造成改动的成本非常大。
相信大多数开发者都遇到过上述场景的问题。其实上述这几个问题都有解,那就是“软件测试”。
二、软件测试
1. 分类
软件测试就是在规定的条件下对应用程序进行操作,以发现程序错误,衡量软件质量,并对其是否能满足设计要求进行评估的过程。
合理应用软件测试技术,就可以规避掉第一部分的3个场景下的问题。
软件测试强调开发、测试同步进行,甚至是测试先行,从需求评审阶段就先考虑好软件测试方案,随后才进行技术方案评审、开发编码、单元测试、集成测试、系统测试、回归测试、验收测试等。
软件测试从测试范围分为:单元测试、集成测试、系统测试、回归测试、验收测试(有些公司会谈到“冒烟测试“,这个词的精确定义不知道,但是学软件测试课的时候按照范围就只有上述几个分类)。工程师自己负责的是单元测试。测试工程师、QA 负责的是集成测试、系统测试。
单元测试(Unit Testing):又称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。「单元」的概念会比较抽象,它不仅仅是我们所编写的某个方法、函数,也可能是某个类、对象等。
软件测试从开发模式分为:面向测试驱动开发 TDD (Test-driven development)、面向行为驱动开发 BDD (Behavior-driven development)。
2. TDD
TDD 的思想是:先编写测试用例,再快速开发代码,然后在测试用例的保证下,可以方便安全地进行代码重构,提升应用程序的质量。一言以蔽之就是通过测试来推动开发的进行。正是由于这个特点,TDD 被广泛使用于敏捷开发。
也就是说 TDD 模式下,首先考虑如何针对功能进行测试,然后去编写代码实现,再不断迭代,在测试用例的保证下,不断进行代码优化。
优点:目标明确、架构分层清晰。可保证开发代码不会偏离需求。每个阶段持续测试
缺点:技术方案需要先评审结束、架构需要提前搭建好。假如需求变动,则前面步骤需要重新执行,灵活性较差。
3. BDD
BDD 即行为驱动开发,是敏捷开发技术之一,通过自然语言定义系统行为,以功能使用者的角度,编写需求场景,且这些行为描述可以直接形成需求文档,同时也是测试标准。
BDD 的思想是跳出单一的函数,针对的是行为而展开的测试。BDD 关心的是业务领域、行为方式,而不是具体的函数、方法,通过对行为的描述来验证功能的可用性。BDD 使用 DSL (Domin Specific Language)领域特定语言来描述测试用例,这样编写的测试用例非常易读,看起来跟文档一样易读,BDD 的代码结构是 Given->When->Then
。
优点:各团队的成员可以集中在一起,设计基于行为的计测试用例。
4. 对比
根据特点也就是找到了各自的使用场景,TDD 主要针对开发中的最小单元进行测试,适合单元测试。而 BDD 针对的是行为,所以测试范围可以再大一些,在集成测试、系统测试中都可以使用
TDD 编写的测试用例一般针对的是开发中的最小单元(比如某个类、函数、方法)而展开,适合单元测试。
BDD 编写的测试用例针对的是行为,测试范围更大一些,适合集成测试、系统测试阶段。
三、 单元测试编码规范
本文的主要重点是针对日常开发阶段工程师可以做的事情,也就是单元测试而展开。
编写功能、业务代码的时候一般会遵循 kiss 原则
,所以类、方法、函数往往不会太大,分层设计越好、职责越单一、耦合度越低的代码越适合做单元测试,单元测试也倒逼开发过程中代码分层、解耦。
可能某个功能的实现代码有30行,测试代码有50行。单元测试的代码如何编写才更合理、整洁、规范呢?
1. 编码分模块展开
先贴一段代码。
- (void)testInsertDataInOneSpecifiedTable
{
XCTestExpectation *exception = [self expectationWithDescription:@"测试数据库插入功能"];
// given
[dbInstance removeAllLogsInTableType:HCTLogTableTypeMeta];
NSMutableArray *insertModels = [NSMutableArray array];
for (NSInteger index = 1; index <= 10000; index++) {
HCTLogMetaModel *model = [[HCTLogMetaModel alloc] init];
model.log_id = index;
// ...
[insertModels addObject:model];
}
// when
[dbInstance add:insertModels inTableType:HCTLogTableTypeMeta];
// then
[dbInstance recordsCountInTableType:HCTLogTableTypeMeta completion:^(NSInteger count) {
XCTAssert(count == insertModels.count, @"「数据增加」功能:异常");
[exception fulfill];
}];
[self waitForExpectationsWithCommonTimeout];
}
可以看到这个方法的名称为 testInsertDataInOneSpecifiedTable,这段代码做的事情通过函数名可以看出来:测试插入数据到某个特定的表。这个测试用例分为3部分:测试环境所需的先决条件准备;调用所要测试的某个方法、函数;验证输出和行为是否符合预期。
其实,每个测试用例的编写也要按照该种方式去组织代码。步骤分为3个阶段:Given->When->Then。
所以单元测试的代码规范也就出来了。此外单元测试代码规范统一后,每个人的测试代码都按照这个标准展开,那其他人的阅读起来就更加容易、方便。按照这3个步骤去阅读、理解测试代码,就可以清晰明了的知道在做什么。
2. 一个测试用例只测试一个分支
我们写的代码有很多语句组成,有各种逻辑判断、分支(if...else、swicth)等等,因此一个程序从一个单一入口进去,过程可能产生 n 个不同的分支,但是程序的出口总是一个。所以由于这样的特性,我们的测试也需要针对这样的现状走完尽可能多的分支。相应的指标叫做「分支覆盖率」。
假如某个方法内部有 if...else...,我们在测试的时候尽量将每种情况写成一个单独的测试用例,单独的输入、输出,判断是否符合预期。这样每个 case 都单一的测试某个分支,可读性也很高。
比如对下面的函数做单元测试,测试用例设计如下
- (void)shouldIEatSomething
{
BOOL shouldEat = [self getAteWeight] < self.dailyFoodSupport;
if (shouldEat) {
[self eatSomemuchFood];
} else {
[self doSomeExercise];
}
}
- (void)testShouldIEatSomethingWhenHungry
{
// ....
}
- (void)testShouldIEatSomethingWhenFull
{
// ...
}
3. 明确标识被测试类
这条主要站在团队合作和代码可读性角度出发来说明。写过单元测试的人都知道,可能某个函数本来就10行代码,可是为了测试它,测试代码写了30行。一个方法这样写问题不大,多看看就看明白是在测试哪个类的哪个方法。可是当这个类本身就很大,测试代码很大的情况下,不管是作者自身还是多年后负责维护的其他同事,看这个代码阅读成本会很大,需要先看测试文件名 代码类名 + Test
才知道是测试的是哪个类,看测试方法名 test + 方法名
才知道是测试的是哪个方法。
这样的代码可读性很差,所以应该为当前的测试对象特殊标记,这样测试代码可读性越强、阅读成本越低。比如定义局部变量 _sut
用来标记当前被测试类(sut,System under Test,软件测试领域有个词叫做被测系统,用来表示正在被测试的系统)。
#import <XCTest/XCTest.h>
#import "HCTLogPayloadModel.h"
@interface HCTLogPayloadModelTest : HCTTestCase
{
HCTLogPayloadModel *_sut;
}
@end
@implementation HCTLogPayloadModelTest
- (void)setUp
{
[super setUp];
HCTLogPayloadModel *model = [[HCTLogPayloadModel alloc] init];
model.log_id = 1;
// ...
_sut = model;
}
- (void)tearDown
{
_sut = nil;
[super tearDown];
}
- (void)testGetDictionary
{
NSDictionary *payloadDictionary = [_sut getDictionary];
XCTAssert([(NSString *)payloadDictionary[@"report_id"] isEqualToString:@"001"] &&
[payloadDictionary[@"size"] integerValue] == 102 &&
[(NSString *)payloadDictionary[@"meta"] containsString:@"meiying"],
@"HCTLogPayloadModel 的 「getDictionary」功能异常");
}
@end
4. 使用分类来暴露私有方法、私有变量
某些场景下写的测试方法内部可能需要调用被测对象的私有方法,也可能需要访问被测对象的某个私有属性。但是测试类里面是访问不到被测类的私有属性和私有方法的,借助于 Category
可以实现这样的需求。
为测试类添加一个分类,后缀名为 UnitTest
。如下所示
HermesClient
类有私有属性 @property (nonatomic, strong) NSString *name;
,私有方法 - (void)hello
。为了在测试用例中访问私有属性和私有方法,写了如下分类
// HermesClientTest.m
@interface HermesClient (UnitTest)
- (NSString *)name;
- (void)hello;
@end
@implementation HermesClientTest
- (void)testPrivatePropertyAndMethod
{
NSLog(@"%@",[HermesClient sharedInstance].name);
[[HermesClient sharedInstance] hello];
}
@end
四、 单元测试下开发模式、技术框架选择
单元测试是按照测试范围来划分的。TDD、BDD 是按照开发模式来划分的。因此就有各种排列组合,这里我们只关心单元测试下的 TDD、BDD 方案。
在单元测试阶段,TDD 和 BDD 都可以适用。
1. TDD
TDD 强调不断的测试推动代码的开发,这样简化了
代码,保证了代码质量。
思想是在拿到一个新的功能时,首先思考该功能如何测试,各种测试用例、各种边界 case;然后完成测试代码的开发;最后编写相应的代码以满足、通过这些测试用例。
TDD 开发过程类似下图:
- 先编写该功能的测试用例,实现测试代码。这时候去跑测试,是不通过的,也就是到了红色的状态
- 然后编写真正的功能实现代码。这时候去跑测试,测试通过,也就是到了绿色的状态
- 在测试用例的保证下,可以重构、优化代码
抛出一个问题:TDD 看上去很好,应该用它吗?
这个问题不用着急回答,回答了也不会有对错之分。开发中经常是这样一个流程,新的需求出来后,先经过技术评审会议,确定宏观层面的技术方案、确定各个端的技术实现、使用的技术等,整理出开发文档、会议文档。工期评估后开始编码。事情这么简单吗?前期即使想的再充分、再细致,可能还是存在特殊 case 漏掉的情况,导致技术方案或者是技术实现的改变。如果采用 TDD,那么之前新功能给到后,就要考虑测试用例的设计、编写了测试代码,在测试用例的保证下再去实现功能。如果遇到了技术方案的变更,之前的测试用例要改变、测试代码实现要改变。可能新增的某个 case 导致大部分的测试代码和实现代码都要改变。
如何开展 TDD**
-
新建一个工程,确保 “Include Unit Tests” 选项是选中的状态
-
创建后的工程目录如下
-
删除 Xcode 创建的测试模版文件
TDDDemoTests.m
-
假如我们需要设计一个人类,它具有吃饭的功能,且当他吃完后会说一句“好饱啊”。
-
那么按照 TDD 我们先设计测试用例。假设有个 Person 类&