引言:
因为之前工作中经历过几次大的项目重构和组件化,所以陆陆续续学习了一些iOS单元测试相关的一些知识,以下内容是在前人基础上的一些总结和在工程中应用的一些心得。若有不足,望多多指正
目录
- 什么是单元测试
- 为什么要做单元测试
- 如何进行单元测试
3.1. 测试准备
3.2. 公共方法的测试
3.3. 私有方法的测试
3.4. 性能测试
3.5. 运用OCMock进行测试
3.6. 异步测试
3.7. UITest脚本录制
3.8. 引入单元测试遇到的问题 - 扩展阅读
4.1. TDD
4.2. BDD
4.3. Kiwi - 参考
1. 什么是单元测试
单元测试:单元测试又称为模块测试, Unit Testing,是针对【程序模块】来进行正确性检验的测试工作。
程序模块:程序模块是软件设计的最小单位,在过程化编程中,一个单元就是单个程序、函数、过程等。对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法,需要方法具有良好的【可测试性】
可测试性:方法的可测试性提倡一个方法专注于一件事。函数式编程不具备良好的可测试性
总结:
单元测试是针对单个程序、函数、过程进行正确性检验的工作
2. 为什么要做单元测试
单元测试作为敏捷开发实践的组成之一,其目的是提高软件开发的效率,维持代码的健康性
单元测试也有一些高级的作用,比如自动发布、自动测试(特别在一些大的项目,以防止程序被误改或引起新的问题)
注:单元测试的目标是证明软件能够正常运行,而不是发现bug(发现bug这一目的与开发成本是正相关的,虽然发现bug是保证软件质量的一种手段,但是很显然这与降低软件开发成本这一目的背道而驰)。它是对软件质量的一种保证,例如重构之后我们需要保证软件产品的正常运行
总结:
提高软件开发的效率;
维持代码的健壮性;
证明软件能够正常运行(而不是发现bug)
实例
先看一个简单的Demo,因为我们项目中后台金额相关的接口返回都是以“分”为单位,但是实际展示或计算时需要以“元”为单位,同时为了避免 Float、Double 等类型计算带来的误差,所以这里有一个简单的十进制数计算工具类来进行项目中的金额转换和计算,以下是类的声明
/** 运算类型 */
typedef NS_ENUM(NSUInteger, RYDecimalCalculatorCalculationType) {
RYDecimalCalculatorCalculationTypeAdding, /** 加法 */
RYDecimalCalculatorCalculationTypeSubtracting, /** 减法 */
RYDecimalCalculatorCalculationTypeMultiplying, /** 乘法 */
RYDecimalCalculatorCalculationTypeDividing /** 除法 */
};
/** 舍入运算类型 */
typedef NS_ENUM(NSUInteger, RYDecimalCalculatorRoundingModeType) {
RYDecimalCalculatorRoundingModeTypeRoundPlain, /** 四舍五入 */
RYDecimalCalculatorRoundingModeTypeNSRoundDown, /** 去尾法 */
RYDecimalCalculatorRoundingModeTypeNSRoundUp /** 进一法 */
};
@interface RYDecimalCalculator : NSObject
+ (instancetype)shareInstance;
/**
”分“转换为”元“,进一法保留两位小数
@param centsString “分”字符串
@return “元”字符串
*/
- (NSString *)convertCentsIntoYuan:(NSString *)centsString;
/**
”元“转换为”分“,进一法保留零位小数
@param yuanString “元”字符串
@return ”分“字符串
*/
- (NSString *)convertYuanIntoCents:(NSString *)yuanString;
/**
自定义十进制计算
@param numOneString 计算数字一(减法为被减数,除法为被除数)
@param numTwoString 计算数字二(减法为减数,除法为除数)
@param calculationType 运算类型(加、减、乘、除)
@param scale 保留精确位(小数点后零省去)
@param roundingMode 舍入运算类型
@return 计算结果(异常状态返回空字符串@"")
*/
- (NSString *)calculateNumOne:(NSString *)numOneString
numTwo:(NSString *)numTwoString
calculationType:(RYDecimalCalculatorCalculationType)calculationType
scale:(NSUInteger)scale
roundingMode:(RYDecimalCalculatorRoundingModeType)roundingMode;
@end
RYDecimalCalculator类提供了“元”转“分” 和 “分”转“元”的快捷方法,以及一个自定义的十进制数运算。通常情况下对于这样一个工具类我们会怎么测试:
- 在一个便于测试的类中导入RYDecimalCalculator类
- mock数据,调用RYDecimalCalculator中的计算方法
- 运行程序,经过若干次点击,通过断点或者控制台查看计算结果
- 继续mock具有代表性的数据,重复2、3步
- 程序出错,重复2、3、4步
....
对于RYDecimalCalculator类,我们使用单元测试应该如何测试,以下是在XCTest框架下编写的用例:
@interface RYDecimalCalculatorTests : XCTestCase
@property (nonatomic, strong) RYDecimalCalculator *decimalCalculator;
@end
@implementation RYDecimalCalculatorTests
- (void)setUp {
[super setUp];
self.decimalCalculator = [RYDecimalCalculator shareInstance];
}
- (void)tearDown {
[super tearDown];
}
- (void)testConvertCentsIntoYuan {
XCTAssertEqualObjects([self.decimalCalculator convertCentsIntoYuan:@"756"], @"7.56");
}
- (void)testConvertYuanIntoCents {
XCTAssertEqualObjects([self.decimalCalculator convertYuanIntoCents:@"235.35"], @"23535");
}
- (void)testDecimalCalculateAddingCase {
NSString *numOne = @"1";
NSString *numTow = @"0.35";
RYDecimalCalculatorCalculationType calculationType = RYDecimalCalculatorCalculationTypeAdding;
NSUInteger scale = 1;
RYDecimalCalculatorRoundingModeType roundingModeType = RYDecimalCalculatorRoundingModeTypeRoundPlain;
NSString *resultNumString = [self.decimalCalculator calculateNumOne:numOne numTwo:numTow calculationType:calculationType scale:scale roundingMode:roundingModeType];
XCTAssertEqualObjects(resultNumString, @"1.4");
}
- (void)testDecimalCalculateSubtractingCase {
NSString *numOne = @"1";
NSString *numTow = @"0.35";
RYDecimalCalculatorCalculationType calculationType = RYDecimalCalculatorCalculationTypeSubtracting;
NSUInteger scale = 0;
RYDecimalCalculatorRoundingModeType roundingModeType = RYDecimalCalculatorRoundingModeTypeNSRoundUp;
NSString *resultNumString = [self.decimalCalculator calculateNumOne:numOne numTwo:numTow calculationType:calculationType scale:scale roundingMode:roundingModeType];
XCTAssertEqualObjects(resultNumString, @"1");
}
- (void)testDecimalCalculateMultiplyingCase {
NSString *numOne = @"10";
NSString *numTow = @"0.35";
RYDecimalCalculatorCalculationType calculationType = RYDecimalCalculatorCalculationTypeMultiplying;
NSUInteger scale = 0;
RYDecimalCalculatorRoundingModeType roundingModeType = RYDecimalCalculatorRoundingModeTypeNSRoundDown;
NSString *resultNumString = [self.decimalCalculator calculateNumOne:numOne numTwo:numTow calculationType:calculationType scale:scale roundingMode:roundingModeType];
XCTAssertEqualObjects(resultNumString, @"3");
}
- (void)testDecimalCalculateDividingCase {
NSString *numOne = @"10";
NSString *numTow = @"3";
RYDecimalCalculatorCalculationType calculationType = RYDecimalCalculatorCalculationTypeDividing;
NSUInteger scale = 2;
RYDecimalCalculatorRoundingModeType roundingModeType = RYDecimalCalculatorRoundingModeTypeNSRoundDown;
NSString *resultNumString = [self.decimalCalculator calculateNumOne:numOne numTwo:numTow calculationType:calculationType scale:scale roundingMode:roundingModeType];
XCTAssertEqualObjects(resultNumString, @"3.33");
}
- (void)testDecimalCalculateUnusualCase {
NSString *numOne = @"10";
NSString *numTow = @"0";
RYDecimalCalculatorCalculationType calculationType = RYDecimalCalculatorCalculationTypeDividing;
NSUInteger scale = 0;
RYDecimalCalculatorRoundingModeType roundingModeType = RYDecimalCalculatorRoundingModeTypeNSRoundDown;
NSString *resultNumString = [self.decimalCalculator calculateNumOne:numOne numTwo:numTow calculationType:calculationType scale:scale roundingMode:roundingModeType];
XCTAssertEqualObjects(resultNumString, @"");
}
以上测试用例编写完成后,只需要 Command + U。如果测试通过,这个类就算单元测试通过,可以直接使用,跳过冗长的工程运行及手动点击
可能有人会说测试用例代码这么多有这个功夫我都测完了。但是实际上以上的测试用例大多是CV的,只是简单的修改了输入参数,因为XCTest提倡我们一条用例尽量只模拟一种情况。熟练后测试用例可以在很短时间完成
3. 如何进行单元测试
3.1. 测试准备
选择测试框架
方案一:XCTest + OCMock (mock对象、桩程序) + OCHamcrest(断言扩展,非必要)
方案二:Kiwi(Kiwi 与 OCMock相互不兼容)
我先后在两次大的项目重构中采用了两套不同的单元测试方案进行测试,因为篇幅关系,本文主要讲解方案一的使用
主要测试对象
- 网络请求或数据重构方法
- 工具类公共方法
- 部分可测试视图逻辑
- 私有方法(若有测试必要可创建类拓展进行测试)
XCTest
XCTest是Xcode集成的一套单元测试框架,以下是它的一些基本使用
- 基本方法
- (void)setUp {
[super setUp];
// 每个test方法执行前调用,在这个测试用例里进行一些通用的初始化工作
// Put setup code here. This method is called before the invocation of each test method in the class.
}
- (void)tearDown {
[super tearDown];
// 每个test方法执行后调用
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
- (void)testExample {
// 测试方法样例
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct results.
}
- (void)testPerformanceExample {
//这个方法主要是做性能测试的,所谓性能测试,主要就是评估一段代码的运行时间。该方法就是性能测试方法的样例。
// This is an example of a performance test case.
[self measureBlock:^{
// Put the code you want to measure the time of here.
}];
}
- 断言
单元测试是以代码测试代码。不是靠 NSLog 来测试,而是使用断言来测试的,提前预判条件必须满足。
// XCTAssert(expression, ...)
// XCTAssert(条件, 不满足条件的描述)
- (void)testExample {
NSLog(@"自定义测试 testExample");
int a = 3;
XCTAssertTrue(a == 0, "a 不能等于 0");
}
无条件报错
XCTFail. 生成一个无条件报错XCTFail(format...)
等价测试
XCTAssertEqualObjects. 当expression1不等于expression2时报错(或者一个对象为空,另一个不为空)XCTAssertEqualObjects(expression1, expression2, format...)
XCTAssertNotEqualObjects. 当expression1等于expression2时报错。XCTAssertNotEqualObjects(expression1, expression2, format...)
XCTAssertEqual. 当expression1不等于expression2时报错,这个测试用于C语言的标量。XCTAssertEqual(expression1, expression2, format...)
XCTAssertNotEqual. 当expression1等于expression2时报错,这个测试用于C语言的标量。XCTAssertNotEqual(expression1, expression2, format...)
XCTAssertEqualWithAccuracy. 当expression1和expression2之间的差别高于accuracy 将报错。这种测试适用于floats和doubles这些标量,两者之间的细微差异导致它们不完全相等,但是对所有的标量都有效。XCTAssertEqualWithAccuracy(expression1, expression2, accuracy, format...)
XCTAssertNotEqualWithAccuracy. 当expression1和expression2之间的差别低于accuracy将产生失败。这种测试适用于floats和doubles这些标量,两者之间的细微差异导致它们不完全相等,但是对所有的标量都有效。XCTAssertNotEqualWithAccuracy(expression1, expression2, accuracy, format...)
空测试
XCTAssertNil. 当expression参数非nil时报错。XCTAssertNil(expression, format...)
XCTAssertNotNil. 当expression参数为nil时报错。XCTAssertNotNil(expression, format...)
XCTAssertTrue. 当expression计算结果为false时报错。XCTAssertTrue(expression, format...)
XCTAssert. 当expression计算结果为false时报错,与XCTAssertTrue同义。XCTAssert(expression, format...)
XCTAssertFalse. 当expression计算结果为true报错。XCTAssertFalse(expression, format...)
异常测试
XCTAssertThrows.当expression不抛出异常时报错。XCTAssertThrows(expression, format...)
XCTAssertThrowsSpecific.当expression针对指定类不抛出异常时报错。XCTAssertThrowsSpecific(expression, exception_class, format...)
XCTAssertThrowsSpecificNamed. 当expression针对特定类和特定名字不抛出异常时报错。对于AppKit框架或Foundation框架非常有用,抛出带有特定名字的NSException(NSInvalidArgumentException等等)。XCTAssertThrowsSpecificNamed(expression, exception_class, exception_name, format...)
XCTAssertNoThrow. 当expression抛出异常时报错。XCTAssertNoThrow(expression, format...)
XCTAssertNoThrowSpecific. 当expression针对指定类抛出异常时报错。任意其他异常都可以;也就是说它不会报错。XCTAssertNoThrowSpecific(expression, exception_class, format...)
XCTAssertNoThrowSpecificNamed. 当expression针对特定类和特定名字抛出异常时报错。对于AppKit框架或Foundation框架非常有用,抛出带有特定名字的NSException(NSInvalidArgumentException等等)XCTAssertNoThrowSpecificNamed(expression, exception_class, exception_name, format...)
OCMock
我们要测试的方法会引用很多外部依赖的对象,而我们没法控制这些外部依赖的对象。为了解决这个问题,我们需要用到Stub和Mock来模拟这些外部依赖的对象,从而控制它们。单独依靠XCTest难以完成Mock或者Stub,但是结合OCMock可以在测试代码中实现这以下功能。
- Stub --- 桩程序, 人为地让一个对象对某个方法返回我们事先规定好的值
OCMStub([mockCar getCarBrand:[OCMArg any]]).andReturn(@"XXX");
[OCMArg any]
表示可以为任意参数,若改为具体参数(如:张三),表示只有参数为张三时才会触发stup,否则不会触发
- Mock --- 模拟对象, 一个对象, 它是对现有类的行为一种模拟(或是对现有接口实现的模拟), 它只能响应那些你添加了期望或者 stub 的方法
// 1.nice mock - 不会在一个没有被stub的方法被调用时抛出异常
Car *mockCar = OCMClassMock([Car class]);
// 2.vanilla mock - 在mock的生命周期中每一个方法调用都必须是stub过的方法。当调用一个没有stub的方法的时候会抛出一个异常
Car *mockCar = OCMStrictClassMock([Car class]);
// 3.partial mock - 当一个没有stub过的方法被调用了,这个方法会被转发到真实的对象上。
Car *mockCar = OCMPartialMock([Car class]);
创建测试类
对于新创建的工程我们可以勾选 Include Unit Tests 以及 Include UI Tests(后面会在UI自动化测试中讲到)
对于已经创建的工程但未引入Unit Tests的工程,可以在File -> New -> Target中创建单元测试Target
假定一个Teacher类,我们需要对其进行单元测试。在单元测试Target下新建测试类,通常以“被测试类 + Tests”来命名
3.2. 公共方法的测试
Teather.h
@interface Teather : NSObject
- (float)sumStudentsGrade:(float)gradeOne gradeTwo:(float)gradeTwo;
@end
TeatherTests.m
#import
#import "Teather.h"
@interface TeatherTests : XCTestCase
@property (nonatomic, strong) Teather *teather;
@end
@implementation TeatherTests
- (void)setUp {
Teather *teather = [Teather new];
self.teather = teather;
// Put setup code here. This method is called before the invocation of each test method in the class.
}
- (void)tearDown {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
- (void)testExample {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct results.
}
- (void)testPerformanceExample {
// This is an example of a performance test case.
[self measureBlock:^{
// Put the code you want to measure the time of here.
}];
}
// 公共方法测试
- (void)testSumStudentsGrade {
float sumGrade = [self.teather sumStudentsGrade:80 gradeTwo:90];
XCTAssertEqual(sumGrade, 170);
}
@end
3.3. 私有方法的测试
通常情况下,类的私有方法不需要测试,因为对一个类的公共方法进行测试其私有方法也会被执行,我们仅需要对黑盒进行输入输出测试而不需要了解其内部的调用
特殊情况下,我们需要测试一些不被外部直接调用的方法,如生命周期或代理调用的私有方法。如果有测试必要也可以进行测试。具体方法如下
对于私有方法的测试,我们可以将私有方法暴露至被测试类的.h文件中,这样测试类就能很轻易的获取到被测试类的私有方法。但是为了单元测试而破坏类的封装,这样的方式得不偿失,无法满足单元测试不影响程序代码的要求
一个可行的方式是新建一个被测试类的专用于单元测试的分类,将分类暴露给测试类来获取其私有方法
实例
Teacher.h
@interface Teather : NSObject
- (float)sumStudentsGrade:(float)gradeOne gradeTwo:(float)gradeTwo;
@end
Teacher.m
@implementation Teather
- (float)sumStudentsGrade:(float)gradeOne gradeTwo:(float)gradeTwo {
return gradeOne + gradeTwo;
}
- (BOOL)privateMethodAndRetuenTure {
return YES;
}
@end
这里我们将对私有方法privateMethodAndRetuenTure进行测试
Teather+Test.h
@interface Teather (Test)
- (BOOL)privateMethodAndRetuenTure;
@end
Teather+Test.m
@implementation Teather (Test)
@end
TeatherTests.m (省去了本例无关代码)
#import "Teather+Test.h"
@implementation TeatherTests
// 私有方法测试
- (void)testPrivateMethodAndRetuenTure {
BOOL testBool = [self.teather privateMethodAndRetuenTure];
XCTAssertEqual(testBool, YES);
}
分类中声明了与私有方法同名的方法,我们知道方法会优先调用分类中的方法。但是分类的.m中并未作方法的实现,那会出现什么情况?IMP会转而到Teacher.m中寻找方法的实现。这样我们就达到了间接调用被测试类中的私有方法的目的。
因为Teather+Test.m中未做任何方法的实现,我们完全可以删除掉这个没有作用的.m文件。并且吧Teather+Test.h移动到测试类所在的测试目录,保证我们主工程Target的简洁,如下图
3.4. 性能测试
性能测试我们只需要将被测试代码放入measureBlock代码块中
// 性能测试
- (void)testPerformanceSumStudentsGrade {
// This is an example of a performance test case.
[self measureBlock:^{
// Put the code you want to measure the time of here.
float sumGrade = [self.teather sumStudentsGrade:80 gradeTwo:90];
XCTAssertEqual(sumGrade, 170);
}];
}
运行后会报灰色警告,因为我们没有设置基准运行时间
我们可以直接将本次运行时间设置为基准时间
再次运行,会自动根据前面几次运行的平均时间计算本次运行效率
3.5. 运用OCMock进行测试
对于一些方法的测试,我们可能需要依赖外部参数,这些参数可能不太容易模拟,比如
@interface Teather : NSObject
- (float)autoSumStudentsGrade:(Student *)studentOne gradeTwo:(Student *)studentTwo;
@end
@implementation Teather
- (float)autoSumStudentsGrade:(Student *)studentOne gradeTwo:(Student *)studentTwo {
return studentOne.grade + studentTwo.grade;
}
@end
对于这个方法的测试,依赖Student对象,并且在方法的实现中调用了Student中属性的get方法。这个时候我们就需要用到OCMock
OCMock的mock能力异常强大,mock对象、桩程序、mock协议、mock观察者等等。本文仅讨论OCMock的对象mock以及桩程序,其他用法读者可以查阅OCMock官网或者关注我的后续分享
// 依赖外部对象
- (void)testAutoSumStudentsGrade {
// 调用被测方法前,我们先创建准备依赖对象
// OCMClassMock 的作用是返回指定类的模拟对象,它是对现有类的行为一种模拟(或是对现有接口实现的模拟), 它只能响应那些你添加了期望或者 stub 的方法
// OCMock的对象模拟有三种方式:
// 1.nice mock - 不会在一个没有被stub的方法被调用时抛出异常
Car *mockCar = OCMClassMock([Car class]);
// 2.vanilla mock - 在mock的生命周期中每一个方法调用都必须是stub过的方法。当调用一个没有stub的方法的时候会抛出一个异常
Car *mockCar = OCMStrictClassMock([Car class]);
// 3.partial mock - 当一个没有stub过的方法被调用了,这个方法会被转发到真实的对象上。
Car *mockCar = OCMPartialMock([Car class]);
// 桩程序:人为地让一个对象对某个方法返回我们事先规定好的值
OCMStub([mockCar getCarBrand:[OCMArg any]]).andReturn(@"XXX");
// [OCMArg any]表示可以为任意参数,若改为具体参数(如:张三),表示只有参数为张三时才会触发stup,否则不会触发
// ======================================================================================
// mock Student对象,当调用grade的get方法时,我们指定返回结果
Student *mockStudentOne = OCMClassMock([Student class]);
OCMStub([mockStudentOne grade])._andReturn(OCMOCK_VALUE(90));
Student *mockStudentTwo = OCMClassMock([Student class]);
OCMStub([mockStudentTwo grade])._andReturn(OCMOCK_VALUE(100));
// 调用被测方法
float sumGrade = [self.teather autoSumStudentsGrade:mockStudentOne gradeTwo:mockStudentTwo];
// 使用断言判断结果
XCTAssertNotEqual(sumGrade, 200);
}
3.6. 异步测试
对于网络请求或者异步回调方法的测试,我们可以用到XCTest提供的XCTestExpectation来进行异步测试
// 异步测试
- (void)testExpectation {
// 参数 |description| 超时错误提示,异步操作时间超过了预设时间时才会在Log中打印出来。
XCTestExpectation *expect = [self expectationWithDescription:@"timeout!"];
// 这里我们用一个异步执行来模拟网络请求回调
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
sleep(2); //延迟两秒向下执行
XCTAssertTrue(YES); //通过测试
// 该方法用于表示这个异步测试结束了,每一个XCTestExpectation对象都需要对应一个fulfill,否则将会导致测试失败
[expect fulfill]; //告知异步测试结束
});
[self waitForExpectationsWithTimeout:10 handler:^(NSError *error) {
// 等待10秒,若该测试未结束(未收到 fulfill方法)则测试结果为失败
// Do something when time out
}];
}
3.7. UITest脚本录制
对于UI的测试,我们可以利用TCTest的脚本录制功能进行自动化测试
同样的我们需要创建一个UI Tests的target,完成后我们同样根据被测试类在UI Tests target下创建对应的测试类
完成后点击录制按钮,可以进行脚本的录制
录制过程中XCTestCase会为我们生成一系列自动化测试脚本,点击测试按钮即可执行脚本,过程中如果修改了UI元素则会导致测试不通过
3.8. 引入单元测试遇到的问题
因为我们目前的项目在一开始并没有引入单元测试,所以后续引入单元测试的过程中难免遇到一些问题
问题1
编译对应的测试Target编译无法找到部分第三方框架
问题1原因
测试Target Build Setting中对应的Library Search Paths未添加对应的第三方框架
这是工程Target下以levels状态展示Search Paths层级关系,我们可以发现主工程相对测试Target多一层由cocoapods管理的Search Paths。
问题1解决
我们可以选择在测试Target的Search Paths中一个一个添加所有由cocoapods管理所有第三方框架。更简单的方式我们可以通过cocoapods帮我们自动生成Search Paths
我们只需在podfile中新增两个Target即可自动生成由pod管理的Search Paths
问题2
No type or protocol named 'YYModel';Unknown type name 'XXXXXXXType'。无法识别某些协议、类型、宏定义
问题2原因
主工程target存在pch文件,且在pch文件中导入了部分头文件或宏定义
问题2解决
在测试target 的 prefix header中添加pch文件路径
问题3
directory not found for option '-L/Users/XXXX/SDK/Lib'
问题3原因
Build Phases 中 Compile Sources中引入了AppDelegate + JPush;Link Binary With Libraries引入了手动导入的jshare-ios-1.6.0。而实际对于单元测试 target 不需要编译以上类及框架
问题3解决
直接删除Build Phases中引入框架
4. 扩展阅读
4.1. TDD
以下引自王巍博客:
测试驱动开发(Test Driven Development,以下简称TDD)是保证代码质量的不二法则,也是先进程序开发的共识。
测试驱动开发并不是一个很新鲜的概念了。软件开发工程师们(当然包括你我)最开始学习程序编写时,最喜欢干的事情就是编写一段代码,然后运行观察结果是否正确。如果不对就返回代码检查错误,或者是加入断点或者输出跟踪程序并找出错误,然后再次运行查看输出是否与预想一致。如果输出只是控制台的一个简单的数字或者字符那还好,但是如果输出必须在点击一系列按钮之后才能在屏幕上显示出来的东西呢?难道我们就只能一次一次地等待编译部署,启动程序然后操作UI,一直点到我们需要观察的地方么?这种行为无疑是对美好生命和绚丽青春的巨大浪费。于是有一些已经浪费了无数时间的资深工程师们突然发现,原来我们可以在代码中构建出一个类似的场景,然后在代码中调用我们之前想检查的代码,并将运行的结果与我们的设想结果在程序中进行比较,如果一致,则说明了我们的代码没有问题,是按照预期工作的。
TDD是一种相对于普通思维的方式来说,比较极端的一种做法。我们一般能想到的是先编写业务代码,然后为其编写测试代码,用来验证产品方法是不是按照设计工作。而TDD的思想正好与之相反,在TDD的世界中,我们应该首先根据需求或者接口情况编写测试,然后再根据测试来编写业务代码,而这其实是违反传统软件开发中的先验认知的。但是我们可以举一个生活中类似的例子来说明TDD的必要性:有经验的砌砖师傅总是会先拉一条垂线,然后沿着线砌砖,因为有直线的保证,因此可以做到笔直整齐;而新入行的师傅往往二话不说直接开工,然后在一阶段完成后再用直尺垂线之类的工具进行测量和修补。TDD的好处不言自明,因为总是先测试,再编码,所以至少你的所有代码的public部分都应该含有必要的测试。另外,因为测试代码实际是要使用产品代码的,因此在编写产品代码前你将有一次深入思考和实践如何使用这些代码的机会,这对提高设计和可扩展性有很好的帮助,试想一下你测试都很难写的接口,别人(或者自己)用起来得多纠结。在测试的准绳下,你可以有目的有方向地编码;另外,因为有测试的保护,你可以放心对原有代码进行重构,而不必担心破坏逻辑。这些其实都指向了一个最终的目的:让我们快乐安心高效地工作。
4.2. BDD
以下引自王巍博客:
XCTest(作者注:苹果官方测试框架)是基于OCUnit的传统测试框架,在书写性和可读性上都不太好。在测试用例太多的时候,由于各个测试方法是割裂的,想在某个很长的测试文件中找到特定的某个测试并搞明白这个测试是在做什么并不是很容易的事情。所有的测试都是由断言完成的,而很多时候断言的意义并不是特别的明确,对于项目交付或者新的开发人员加入时,往往要花上很大成本来进行理解或者转换。另外,每一个测试的描述都被写在断言之后,夹杂在代码之中,难以寻找。使用XCTest测试另外一个问题是难以进行mock或者stub,而这在测试中是非常重要的一部分。
行为驱动开发(BDD)正是为了解决上述问题而生的,作为第二代敏捷方法,BDD提倡的是通过将测试语句转换为类似自然语言的描述,开发人员可以使用更符合大众语言的习惯来书写测试,这样不论在项目交接/交付,或者之后自己修改时,都可以顺利很多。如果说作为开发者的我们日常工作是写代码,那么BDD其实就是在讲故事。一个典型的BDD的测试用例包活完整的三段式上下文,测试大多可以翻译为Given..When..Then的格式,读起来轻松惬意。BDD在其他语言中也已经有一些框架,包括最早的Java的JBehave和赫赫有名的Ruby的RSpec和Cucumber。而在objc社区中BDD框架也正在欣欣向荣地发展,得益于objc的语法本来就非常接近自然语言,再加上C语言宏的威力,我们是有可能写出漂亮优美的测试的。在objc中,现在比较流行的BDD框架有cedar,specta和Kiwi。
- 第三方BDD测试框架
OC: specta Kiwi ceder
Swift: Quick Sleipnir
4.3. Kiwi
Kiwi是一个iOS平台十分好用的行为驱动开发(Behavior Driven Development,以下简称BDD)的测试框架,有着非常漂亮的语法,可以写出结构性强,非常容易读懂的测试。个人使用的感受上来说,相对于XCTest,Kiwi的语法更趋向于人类表达的自然语言,拥有更好的可读性,但是学习成本相对较高且使用上不如XCTest灵活
- Kiwi基本用法:
Given..When..Then的三段式自然语言
describe(@"Team", ^{
context(@"when newly created", ^{
it(@"should have a name", ^{
id team = [Team team];
[[team.name should] equal:@"Black Hawks"];
});
it(@"should have 11 players", ^{
id team = [Team team];
[[[team should] have:11] players];
});
});
});
- Kiwi框架关键字:
describe(aString,aBlock) - 描述需要测试的对象内容,也即我们三段式中的Given
context(aString,aBlock) - context描述测试上下文,也就是这个测试在When来进行
it(aString,aBlock) - 测试的本体,描述了这个测试应该满足的条件
beforeEach(aBlock) - 在scope内的每个it之前调用一次,对于context的配置代码应该写在这里
afterEach(aBlock) - 在scope内的每个it之后调用一次,用于清理测试后的代码
beforeAll(aBlock) - 当前scope内部的所有的其他block运行之前调用一次
afterAll(aBlock) - 当前scope内部的所有的其他block运行之后调用一次
specify(aBlock) - 可以在里面直接书写不需要描述的测试
pending(aString, aBlock) - 只打印一条log信息,不做测试。这个语句会给出一条警告,可以作为一开始集中书写行为描述时还未实现的测试的提示。
xit(aString, aBlock) - 和pending一样,另一种写法。因为在真正实现时测试时只需要将x删掉就是it,但是pending语意更明确,因此还是推荐pending
- Kiwi测试类的命名
一个测试文件应该专注于测试一个类
一个describe可以包含多个context,来描述类在不同情景下的行为
一个context可以包含多个it的测试例
- Kiwi进阶用法
// stub --- 桩程序, 人为地让一个对象对某个方法返回我们事先规定好的值
// 我们有一个 Person 类的实例,我们想要 stub 让它返回一个固定的名字,可以这么写:
Person *person = [Person somePerson];
[person stub:@selector(name) andReturn:@“Tom”];
// mock --- 模拟对象, 一个对象, 它是对现有类的行为一种模拟(或是对现有接口实现的模拟), 它只能响应那些你添加了期望或者 stub 的方法
// 创建模拟对象
id weatherForecasterMock = [WeatherForecaster mock];
// 设置模拟对象期望
[[weatherForecasterMock should] receive:@selector(resultWithTemprature:humidity:)andReturn:someResultWithArguments:theValue(23),theValue(50)];
// 用stup替换属性
[weatherRecorder stub:@selector(weatherForecaster) andReturn:weatherForecasterMock];
// 注:对于 Kiwi 的 stub,需要注意的是它不是永久有效的,在每个 it block 的结尾 stub 都会被清空,超出范围的方法调用将不会被 stub 截取到
本文仅简单介绍Kiwi,详细请参考王巍博客TDD的iOS开发初步以及Kiwi使用入门
5. 参考
iOS - UnitTests 单元测试 --- QianChia
行为驱动开发 --- 吴迪
TDD的iOS开发初步以及Kiwi使用入门 --- 王巍