diff --git a/.DS_Store b/.DS_Store index e080bde..49b801e 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/Chapter1 - iOS/1.75.md b/Chapter1 - iOS/1.75.md index 0207338..9b4ec89 100644 --- a/Chapter1 - iOS/1.75.md +++ b/Chapter1 - iOS/1.75.md @@ -32,246 +32,26 @@ ### 2. TDD +TDD 开发过程类似下图: + +![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-13-TDDStructure.png) + TDD 的思想是:先编写测试用例,再快速开发代码,然后在测试用例的保证下,可以方便安全地进行代码重构,提升应用程序的质量。一言以蔽之就是通过测试来推动开发的进行。正是由于这个特点,TDD 被广泛使用于敏捷开发。 也就是说 TDD 模式下,首先考虑如何针对功能进行测试,然后去编写代码实现,再不断迭代,在测试用例的保证下,不断进行代码优化。 -优点:目标明确、架构分层清晰。可保证开发代码不会偏离需求。每个阶段持续测试 +TDD 强调不断的测试推动代码的开发,保证了代码质量。TDD 强调让写代码的过程形成一个循环,在拿到新需求时: -缺点:技术方案需要先评审结束、架构需要提前搭建好。假如需求变动,则前面步骤需要重新执行,灵活性较差。 +- 第一步:先为该功能编写一个单元测试,跑一下发现没有通过(这时候还没有实现代码),即图中的 TEST FAILS,俗称"红灯" +- 第二步:编写能够通过全部测试的“最小代码”,之所以强调“最小代码”,就是为了防止过度优化,现实中我们经常会因为代码过度优化,或者过度设计,导致很多遗留问题,在这个阶段,快速实现需求就好了,不需要太多设计。这个阶段俗称“绿灯” +- 第三步:也是最重最要的一步,即“重构”(Refactor)。前面为了快速赶业务,代码跨年很脏(屎山),但至少保证是正确的。当有充足的测试来保证逻辑的正确,这时候就可以重构代码了,持续重构来保证代码最优。 -### 3. BDD +这也得出2个信息: -BDD 即行为驱动开发,是敏捷开发**技术**之一,通过自然语言定义系统行为,以功能使用者的角度,编写需求场景,且这些行为描述可以直接形成需求文档,同时也是测试标准。 +1. 单测必须能够快速运行,因为单测是经常需要在本地全量运行的,只有运行足够快,才可以在 TDD 的循环中快速迭代 +2. 好的代码并不是一次完成的,而是持续重构出来的,而单测是持续重构的前提 -BDD 的思想是跳出单一的函数,针对的是行为而展开的测试。BDD 关心的是业务领域、行为方式,而不是具体的函数、方法,通过对行为的描述来验证功能的可用性。BDD 使用 DSL (Domin Specific Language)领域特定语言来描述测试用例,这样编写的测试用例非常易读,看起来跟文档一样易读,BDD 的代码结构是 `Given->When->Then`。 -优点:各团队的成员可以集中在一起,设计基于行为的计测试用例。 - -### 4. 对比 - -根据特点也就是找到了各自的使用场景,TDD 主要针对开发中的最小单元进行测试,适合单元测试。而 BDD 针对的是行为,所以测试范围可以再大一些,在集成测试、系统测试中都可以使用 - -TDD 编写的测试用例一般针对的是开发中的最小单元(比如某个类、函数、方法)而展开,适合单元测试。 - -BDD 编写的测试用例针对的是行为,测试范围更大一些,适合集成测试、系统测试阶段。 - -## 三、 单元测试编码规范 - -本文的主要重点是针对日常开发阶段工程师可以做的事情,也就是单元测试而展开。 - -编写功能、业务代码的时候一般会遵循 `kiss 原则` ,所以类、方法、函数往往不会太大,分层设计越好、职责越单一、耦合度越低的代码越适合做单元测试,单元测试也倒逼开发过程中代码分层、解耦。 - -可能某个功能的实现代码有30行,测试代码有50行。单元测试的代码如何编写才更合理、整洁、规范呢? - -### 1. 编码分模块展开 - -先贴一段代码。 - -```objective-c -- (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 都单一的测试某个分支,可读性也很高。 - -比如对下面的函数做单元测试,测试用例设计如下 - -```objective-c -- (void)shouldIEatSomething -{ - BOOL shouldEat = [self getAteWeight] < self.dailyFoodSupport; - if (shouldEat) { - [self eatSomemuchFood]; - } else { - [self doSomeExercise]; - } -} -``` - -Bad Case: - -```objective-c -- (void)testShouldIEatSomething -{ - // case1 - // case2 -} -``` - -Good Case: - -```objective-c -- (void)testShouldIEatSomethingWhenHungry -{ - // .... -} - -- (void)testShouldIEatSomethingWhenFull -{ - // ... -} -``` - -QA:一个被测方法,有诸多 case,为什么不写在一个测试方法中将这些 case 写全? - -因为测试有个原则,就是每个测试 case 都是能够单独可运行的。如果2个case都在一个方法内,那就不是单独可运行。怎么理解,贴个图(点击最左边的小菱形,该 case 是需要单独可运行的) - -![](./../assets/UnitTest-FunctionStandard.png) - -再有一个参照物,大家都在用的 ITerm2 的源码是开源的,看看 ITerm2 是如何写测试的 - -![](./../assets/ITerm2-TestCase.png) - -### 3. 明确标识被测试类 - -这条主要站在团队合作和代码可读性角度出发来说明。写过单元测试的人都知道,可能某个函数本来就10行代码,可是为了测试它,测试代码写了30行。一个方法这样写问题不大,多看看就看明白是在测试哪个类的哪个方法。可是当这个类本身就很大,测试代码很大的情况下,不管是作者自身还是多年后负责维护的其他同事,看这个代码阅读成本会很大,需要先看测试文件名 `代码类名 + Test` 才知道是测试的是哪个类,看测试方法名 `test + 方法名` 才知道是测试的是哪个方法。 - -这样的代码可读性很差,所以应该为当前的测试对象特殊标记,这样测试代码可读性越强、阅读成本越低。比如定义局部变量 `_sut` 用来标记当前被测试类(sut,System under Test,软件测试领域有个词叫做**被测系统**,用来表示正在被测试的系统)。 - -```objective-c -#import -#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. 使用分类来暴露私有方法、私有变量 - -某些场景下写的测试方法内部可能需要调用被测对象的私有方法,也可能需要访问被测对象的某个私有属性。但是测试类里面是访问不到被测类的私有属性和私有方法的,借助于 Runtime 特性,我们可以给被测类添加 **Category** 来解决无法访问私有方法和私有属性的问题。 - -为测试类添加一个分类,后缀名为 `UnitTest`。如下所示 - - `HermesClient` 类有私有属性 `@property (nonatomic, strong) NSString *name;`,私有方法 `- (void)hello`。为了在测试用例中访问私有属性和私有方法,写了如下分类 - -```objective-c -@interface HermesClient : NSObject -@end - -@interface HermesClient () -@property (nonatomic, assign) int b; -@end - -@implementation HermesClient -- (instancetype)init { - if (self = [super init]) { - self.b = 10; - } - return self; -} - -- (int)add:(int)a { - return a+b; -} -@end - -// HermesClientTest.m -@interface HermesClient (UnitTest) -@property (nonatomic, assign) int b; -- (int)add:(int)a; -@end - -@interface HermesClientTest() { - HermesClientTest *_sut; -} -@end - -@implementation HermesClientTest -- (void)setUp { - _sut = [[HermesClientTest alloc] init]; -} -- (void)testAdd -{ - int result = [_sut add:20]; - XCTAssert(result == 30, - @"Oops, there is something wrong with add: method of HermesClientTest."); -} -@end -``` - - - -## 四、 单元测试下开发模式、技术框架选择 - -单元测试是按照测试范围来划分的。TDD、BDD 是按照开发模式来划分的。因此就有各种排列组合,这里我们只关心单元测试下的 TDD、BDD 方案。 - -在单元测试阶段,TDD 和 BDD 都可以适用。 - -### 1. TDD - -TDD 强调不断的测试推动代码的开发,这样`简化了`代码,保证了代码质量。 - -思想是在拿到一个新的功能时,首先思考该功能如何测试,各种测试用例、各种边界 case;然后完成测试代码的开发;最后编写相应的代码以满足、通过这些测试用例。 - -TDD 开发过程类似下图: - -![](./../assets/2020-07-13-TDDStructure.png) - -- 先编写该功能的测试用例,实现测试代码。这时候去跑测试,是不通过的,也就是到了红色的状态 -- 然后编写真正的功能实现代码。这时候去跑测试,测试通过,也就是到了绿色的状态 -- 在测试用例的保证下,可以重构、优化代码 **抛出一个问题:TDD 看上去很好,应该用它吗?** @@ -280,11 +60,11 @@ TDD 开发过程类似下图: 如何开展 TDD** 1. 新建一个工程,确保 “Include Unit Tests” 选项是选中的状态 - - ![TDD Step 1](./../assets/2020-07-13-TDDStep1.png) + + ![TDD Step 1](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-13-TDDStep1.png) 2. 创建后的工程目录如下 - + ![TDD step2](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-13-TDDStep2.png) 3. 删除 Xcode 创建的测试模版文件 `TDDDemoTests.m` @@ -292,19 +72,19 @@ TDD 开发过程类似下图: 4. 假如我们需要设计一个人类,它具有吃饭的功能,且当他吃完后会说一句“好饱啊”。 5. 那么按照 TDD 我们先设计测试用例。假设有个 Person 类,有个对象方法叫做吃饭,吃完饭后会返回一个“好饱啊”的字符串。那测试用例就是 - - | 步骤 | 期望 | 结果 | - | -------------------------- | ---------- | --- | + + | 步骤 | 期望 | 结果 | + | --------------------------------------- | ------------------ | ---- | | 实例化 Person 对象,调用对象的 eat 方法 | 调用后返回“好饱啊” | ? | 6. 实现测试用例代码。创建继承自 Unit Test Case class 的测试类,命名为 `工程前缀+测试类名+Test`,也就是 `TDDPersonTest.m`。 - - ![TDD step 3](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-13-TDDStep3.png) + + ![TDD step 3](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-13-TDDStep3.png) 7. 因为要测试 Person 类,所以在主工程中创建 Person 类 8. 因为要测试人类在吃饭后说一句“好饱啊”。所以设想那个类目前只有一个吃饭的方法。于是在 `TDDPersonTest.m` 中创建一个测试函数 `-(void)testReturnStatusStringWhenPersonAte;`函数内容如下 - + ```objective-c - (void)testReturnStatusStringWhenPersonAte { @@ -322,7 +102,7 @@ TDD 开发过程类似下图: 9. Xcode 下按快捷键 `Command + U`,跑测试代码发现是失败的。因为我们的 Person 类根本没实现相应的方法 10. 从 [TDD 开发过程](#TDDStructure)可以看到,我们现在是红色的 “Fail” 状态。所以需要去 Person 类中实现功能代码。Person 类如下 - + ```objective-c #import "Person.h" @@ -343,7 +123,15 @@ TDD 开发过程类似下图: 13. 假如 eat 方法实现的不够漂亮。现在在测试用例的保证下,大胆重构,最后确保所有的 Unit Test case 通过即可。 -### 2. BDD + + +### 3. BDD + +BDD 即行为驱动开发,是敏捷开发**技术**之一,通过自然语言定义系统行为,以功能使用者的角度,编写需求场景,且这些行为描述可以直接形成需求文档,同时也是测试标准。 + +问题是什么?拆解成哪些子问题、做成这个事情需要哪些步骤。这就是 BDD 的编写流程。BDD 使用 DSL (Domin Specific Language)领域特定语言来描述测试用例,这样编写的测试用例非常易读,看起来跟文档一样易读,BDD 的代码结构是 `Given->When->Then`。 + + 相比 TDD,BDD 关注的是行为方式的设计,拿上述“人吃饭”举例说明。 @@ -354,7 +142,7 @@ TDD 开发过程类似下图: 6. BDD 需要引入好用的框架 `Kiwi`,使用 Pod 的方式引入 7. 因为要测试人类在吃饭后说一句“好饱啊”。所以设想那个类目前只有一个吃饭的方法。于是在 `TDDPersonTest.m` 中创建一个测试函数 `-(void)testReturnStatusStringWhenPersonAte;`函数内容如下 - + ```objective-c #import "kiwi.h" #import "Person.h" @@ -375,11 +163,27 @@ TDD 开发过程类似下图: SPEC_END ``` -### 3. XCTest + + +### 4. 敏捷开发 + +大多数人觉得写 TDD、BDD 都会影响开发迭代速度,但是 TDD、BDD 恰恰是敏捷开发实践的重要组成部分: + +![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Alige-devement-BDDAndTDD.png) + +我们学习敏捷开发的时候,常常只学习到它的 “快”,而忽略了敏捷开发所提出的质量保证方法。敏捷开发所谓的“快”,是指在代码质量充分保证下的“快”,而不是做完功能就直接上线。 + + + +### 5. 选什么? 看完了 TDD 和 BDD,那我们选什么? +TDD 适合新项目,从0开始起步开发的,大多数情况下,我们的手中的项目基本都是已经跑了好几年的业务项目,基本上没机会去实施 TDD。 +但是对于移动中台来讲,一个新的技术 SDK 是有机会落地 TDD 的。 + +这里聊聊大多数的情况,已有的代码如何开展测试。我们选择 iOS 平台自带的 XCTest。搭配一些测试框架来开展,比如 OCMock 等 **开发步骤** @@ -623,7 +427,7 @@ Xcode 自带的测试系统是 `XCTest`,使用简单。开发步骤如下 **经验小结** 1. XCTestCase 类和其他类一样,你可以定义基类,这里面封装一些常用的方法。 - + ``` // HCTTestCase.h #import @@ -661,8 +465,7 @@ Xcode 自带的测试系统是 `XCTest`,使用简单。开发步骤如下 #pragma mark - life cycle - - (void)setUp - { + - (void)setUp { [super setUp]; self.networkTimeout = 20.0; // 1. 设置平台信息 @@ -674,24 +477,20 @@ Xcode 自带的测试系统是 `XCTest`,使用简单。开发步骤如下 [[HermesClient sharedInstance] setup]; } - - (void)tearDown - { + - (void)tearDown { [super tearDown]; } #pragma mark - public Method - - (void)waitForExpectationsWithCommonTimeout - { + - (void)waitForExpectationsWithCommonTimeout { [self waitForExpectationsWithCommonTimeoutUsingHandler:nil]; } - - (void)waitForExpectationsWithCommonTimeoutUsingHandler:(XCWaitCompletionHandler __nullable)handler - { + - (void)waitForExpectationsWithCommonTimeoutUsingHandler:(XCWaitCompletionHandler __nullable)handler { [self waitForExpectationsWithTimeout:self.networkTimeout handler:handler]; } - - (NSDictionary *)generateCrashMetaDataFromReport - { + - (NSDictionary *)generateCrashMetaDataFromReport { NSMutableDictionary *metaDictionary = [NSMutableDictionary dictionary]; NSDate *crashTime = [NSDate date]; metaDictionary[@"MONITOR_TYPE"] = @"appCrash"; @@ -701,14 +500,13 @@ Xcode 自带的测试系统是 `XCTest`,使用简单。开发步骤如下 } #pragma mark - private method - - (void)setupAppProfile - { + - (void)setupAppProfile { [[CMAppProfile sharedInstance] setMPlatform:@"70"]; // ... - } - @end + } + @end ``` - + 2. 上述说的基本是开发规范相关。测试方法内部如果调用了其他类的方法,则在测试方法内部必须 Mock 一个外部对象,限制好返回值等。 3. 在 XCTest 内难以使用 mock 或 stub,这些是测试中非常常见且重要的功能 @@ -718,8 +516,7 @@ Xcode 自带的测试系统是 `XCTest`,使用简单。开发步骤如下 这里举个例子,是测试一个数据库操作类 `HCTDatabase`,代码只放某个方法的测试代码。 ```objective-c -- (void)testRemoveLatestRecordsByCount -{ +- (void)testRemoveLatestRecordsByCount { XCTestExpectation *exception = [self expectationWithDescription:@"测试数据库删除最新数据功能"]; // 1. 先清空数据表 [dbInstance removeAllLogsInTableType:HCTLogTableTypeMeta]; @@ -764,7 +561,7 @@ Xcode 自带的测试系统是 `XCTest`,使用简单。开发步骤如下 } ``` -### 3. 测试框架 +### 6. 测试框架 #### 1. Kiwi @@ -863,11 +660,70 @@ Expecta 是一个匹配(断言)框架,相比 Xcode 的断言 XCAssert,Ex #### 3. OCMock +使用 OCMock 经常做的事情就是准备数据、添加预期、执行断言。具体用法可以查看[文档](https://ocmock.org/reference/)。 + +简单看看原理。 + +``` +id mockObject = OCMClassMock([Person class]); +OCMStub([mockObject engineer]).andReturn(mockObject); +OCMStub([mockObject work]).andReturn(100); +// Assert +``` + +OCMClassMock 展开如下 + +``` +id mockObject = [OCMockObject niceMockForClass:[Person class]]; +``` + +OCMockObject 其实就是 NSProxy 的子类,用于实现消息转发,`niceMockForClass` 就是调用了 + +``` ++ (id)mockForClass:(Class)aClass { + return [[[OCClassMockObject alloc] initWithClass:aClass] autorelease]; +} +``` + +OCMStub 是 OCMock 最核心的功能, + +``` +({ + [OCMMacroState beginStubMacro]; + OCMStubRecorder *recorder = ((void *)0); + @try{ + [mockObject work]; + } @finally { + recorder = [OCMMacroState endStubMacro]; + } + recorder; +}); +``` + +上面的 begin 和 end 方法就是为了增加一个 OCMStubRecorder 标记,保存在当前线程的字典中 + +``` ++ (void)beginStubMacro { + OCMStubRecorder *recorder = [[[OCMStubRecorder alloc] init] autorelease]; + OCMMacroState *macroState = [[OCMMacroState alloc] initWithRecorder:recorder]; + [NSThread currentThread].threadDictionary[OCMGlobalStateKey] = macroState; + [macroState release]; +} + ++ (OCMStubRecorder *)endStubMacro { + NSMutableDictionary *threadDictionary = [NSThread currentThread].threadDictionary; + OCMMacroState *globalState = threadDictionary[OCMGlobalStateKey]; + OCMStubRecorder *recorder = [(OCMStubRecorder *)[globalState recorder] retain]; + [threadDictionary removeObjectForKey:OCMGlobalStateKey]; + return [recorder autorelease]; +} +``` + +剩下的不展开讲了,不是本文重点。OCMock 其实就是利用 NSProxy 和 Runtime 消息转发去实现的。 - -### 4. 小结 +### 7. 小结 Xcode 自带的 `XCTestCase` 比较适合 TDD,不影响源代码,系统独立且不影响 App 包大小。适合简单场景下的测试。且每个函数在最左侧又个测试按钮,点击后可以单独测试某个函数。 @@ -879,7 +735,349 @@ Excepta 是一个匹配框架,比 XCTest 的断言则更加全面一些。 没办法说哪个最好、最合理,根据项目需求选择合适的组合。 -## 五、网络测试 + + +## 三、 单元测试编码规范 + +本文的主要重点是针对日常开发阶段工程师可以做的事情,也就是单元测试而展开。 + +编写功能、业务代码的时候一般会遵循 `kiss 原则` ,所以类、方法、函数往往不会太大,分层设计越好、职责越单一、耦合度越低的代码越适合做单元测试,单元测试也倒逼开发过程中代码分层、解耦。 + +可能某个功能的实现代码有30行,测试代码有50行。单元测试的代码如何编写才更合理、整洁、规范呢? + +### 1. 编码分模块展开 + +先贴一段代码。 + +```objective-c +- (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 都单一的测试某个分支,可读性也很高。 + +比如对下面的函数做单元测试,测试用例设计如下 + +```objective-c +- (void)shouldIEatSomething { + BOOL shouldEat = [self getAteWeight] < self.dailyFoodSupport; + if (shouldEat) { + [self eatSomemuchFood]; + } else { + [self doSomeExercise]; + } +} +``` + +Bad Case: + +```objective-c +- (void)testShouldIEatSomething { + // case1 + // case2 +} +``` + +Good Case: + +```objective-c +- (void)testShouldIEatSomethingWhenHungry { + // .... +} + +- (void)testShouldIEatSomethingWhenFull +{ + // ... +} +``` + +QA:一个被测方法,有诸多 case,为什么不写在一个测试方法中将这些 case 写全? + +因为测试有个原则,就是每个测试 case 都是能够单独可运行的。如果2个case都在一个方法内,那就不是单独可运行。怎么理解,贴个图(点击最左边的小菱形,该 case 是需要单独可运行的) + +![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/UnitTest-FunctionStandard.png) + +再有一个参照物,大家都在用的 ITerm2 的源码是开源的,看看 ITerm2 是如何写测试的 + +![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/ITerm2-TestCase.png) + +### 3. 明确标识被测试类 + +这条主要站在团队合作和代码可读性角度出发来说明。写过单元测试的人都知道,可能某个函数本来就10行代码,可是为了测试它,测试代码写了30行。一个方法这样写问题不大,多看看就看明白是在测试哪个类的哪个方法。可是当这个类本身就很大,测试代码很大的情况下,不管是作者自身还是多年后负责维护的其他同事,看这个代码阅读成本会很大,需要先看测试文件名 `代码类名 + Test` 才知道是测试的是哪个类,看测试方法名 `test + 方法名` 才知道是测试的是哪个方法。 + +这样的代码可读性很差,所以应该为当前的测试对象特殊标记,这样测试代码可读性越强、阅读成本越低。比如定义局部变量 `_sut` 用来标记当前被测试类(sut,System under Test,软件测试领域有个词叫做**被测系统**,用来表示正在被测试的系统)。 + +```objective-c +#import +#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. 使用分类来暴露私有方法、私有变量 + +某些场景下写的测试方法内部可能需要调用被测对象的私有方法,也可能需要访问被测对象的某个私有属性。但是测试类里面是访问不到被测类的私有属性和私有方法的,借助于 Runtime 特性,我们可以给被测类添加 **Category** 来解决无法访问私有方法和私有属性的问题。 + +为测试类添加一个分类,后缀名为 `UnitTest`。如下所示 + + `HermesClient` 类有私有属性 `@property (nonatomic, strong) NSString *name;`,私有方法 `- (void)hello`。为了在测试用例中访问私有属性和私有方法,写了如下分类 + +```objective-c +@interface HermesClient : NSObject +@end + +@interface HermesClient () +@property (nonatomic, assign) int b; +@end + +@implementation HermesClient +- (instancetype)init { + if (self = [super init]) { + self.b = 10; + } + return self; +} + +- (int)add:(int)a { + return a+b; +} +@end + +// HermesClientTest.m +@interface HermesClient (UnitTest) +@property (nonatomic, assign) int b; +- (int)add:(int)a; +@end + +@interface HermesClientTest() { + HermesClientTest *_sut; +} +@end + +@implementation HermesClientTest +- (void)setUp { + _sut = [[HermesClientTest alloc] init]; +} +- (void)testAdd +{ + int result = [_sut add:20]; + XCTAssert(result == 30, + @"Oops, there is something wrong with add: method of HermesClientTest."); +} +@end +``` + +### 5. 单测需要确定性 + +避免脆弱测试,Mock不确定的依赖:时间、随机数、并发性、基础设施、现存数据、持久化、网络等等。一个 case 应该只针对某个逻辑分支进行测试,那么对于特定输入、一定有一个特定输出,来判断是否符合预期。为此,其他的因素都应该是唯一确定的,保证测试环境的确定性。 + +````objective-c +/// 测试数据库的查询最新一条记录的能力 +- (void)testSelectLastesRecord { + // 首先需要保证数据库只有一些需要的记录 + [_db clear]; + // 插入一批学生数据,学号从0到20自增 + [_db insertAutoIncrementStudent:studentModel count:20]; + + Student *model = [_db selectLastesRecord]; + XCTAssert(model.Sno == 20, @"Oops, there is comething wrong with selectLastesRecord method of DB"); +} +```` + +### 6. 避免耗时操作,导致测试执行缓慢 + +单测的目的就是为了快速验证被测代码的正确性,如果大家写测试代码的时候不注意,加一些 sleep 之类的代码,整个单测工程运行就会很慢,问题验证或者查看整个工程的覆盖率就会很慢。 + +举一个工程中的例子。 + +```objc +// bad case ++ (OKFJsonRequest*)getHotTokensData:(NSString*)chainId walletId:(NSString * _Nonnull)walletId { + OKFMockRequest* request = [OKFMockRequest mockRequestWithResponseJsonString:[MockData jsonStringFromFile:@"hot_token"]]; + request.mockResponseDuration = 0.1; + return request; +} +``` + +网络 Mock 工具类中针对请求耗时都写了 0.1s,整个工程就会很慢。`mockResponseDuration` 字段是为了控制弱网环境下的表现,但是看了测试代码,根本没有这方面的测试。所以 `mockResponseDuration` 可以不用设置,默认就是0. + +### 7. 避免过度指定 + +对于过度指定的讨论,其核心问题就是要我们「判断哪些方法是单元测试应该覆盖的」,哪些是应该留给其他测试手段的。如果一个场景,单元测试覆盖之后,经常导致单测失败,需要不断更新维护,那就需要 double check 下,这段代码是不是可以考虑不做单元测试覆盖。ROI 合适吗? + +像素完美是一个典型的,经常被人们拿出来聊的例子,Flutter 的 Golden Test 就是一个 golden master testing 的例子;下面是《有效的单元测试》中关于像素完美的讨论: + +> 像素完美:顾名思义,是一种特定于图形和图像生成的测试坏味道。它混杂了魔法数字和基本断言,使得测试极难阅读也极其脆弱。 +> +> 这种测试几乎无法阅读,因为即使测试在语义上是处于高层概念的,却仍然会针对硬编码的底层细节例如像素坐标和颜色来进行断言。指定坐标上的像素是黑还是白,与两个图形是否相连或堆叠的概念是有区别的。 +> +> 这种测试极其脆弱,因为即使很小的和不相关的输入变化——是否是另一个图像,或图形对象的渲染方式——都足以影响输出、打破测试,谁让你非要精确地检查像素坐标和颜色呢。同样的问题在采用golden master技术时也会遇到,其做法是事先将图像录制下来,并手工检查其正确性,以后再进行测试时就将渲染出的图像与之进行比对。 +> +> 这些可不是我们愿意去维护的测试。我们不希望带着这种脆弱的精确度去编写测试,而是使用模糊匹配和智能算法来代替繁琐的数值比较。 + +### 8. 测试不要名不副实 + +避免测试的描述和测试内容不符,测试结果必须精准,测试断言信息也需要精准。 + +```objective-c +// bad case +- (void)testDB { + [_sut clear]; + [_sut insert:datasource]; + XCTAssert([_sut count] == datasource.count, @"Oops, there is something wrong with DB"); +} +``` + +单通过方法名根本不知道该测试用例是在测试什么。所以这个就是一个名不副实。 + +### 9. 使用有意义的断言 + +断言的错误信息要有意义,出现问题能够明确错误的原因 + +```objective-c +// good case +- (void)testInsert { + [_sut clear]; + [_sut insert:datasource]; + XCTAssert([_sut count] == datasource.count, @"Oops, there is something wrong with insert: method of DB."); +} +``` + +### 10. 清理测试环境 + +在 teardown 阶段清理测试环境,例如还原全局的配置、清理创建的文件目录、断开数据库连接句柄等 + +```objective-c +- (void)testWriteMethodOfFileManager { + [_sut createFile:filepath]; + [_sut write:filepath content:jsonString encoding:NSUTF8StringEncoding]; + NSString *fetchContent = [_sut readContents:filepath]; + XCTAssertTrue([fetchContent isEqualToString:jsonString], @"Oops, thers is something wrong with write:content:encoding: method of FileManager"); +} + +- (void)tearDown { + [_sut deleteFile:filepath]; +} +``` + +### 11. 写单测的过程也是一次 Code Review 的过程 + +看到某个方法难以编写单测、或者不好测,当梳理清楚后,可以对该段逻辑代码进行重构。 + + + +### 12. 把单测视为“一等公民” + +测试用例应该被视为“一等公民”,同样需要 Code Review,同样需要重视设计和质量,确保单元测试的有效性。 + +单元测试的代码评审的过程,也是团队同学互相学习借鉴的过程,沉淀最佳实践的过程。 + + + +### 13. 结合好官方工具 + +给当前工程打开测试覆盖率开关。路径:项目 icon 下的 Edit Scheme - 左侧选择 Test ,右侧选择 Options,然后选择 Code Coverage,勾选即可 + + + +对于每次测试后,可以在 Xcode 倒数第四个选项可以看到总的测试用例数,失败的个数。如果要清楚的知道哪些 case 失败,则可以点击右下角中间的按钮,可以筛选出失败的 case。 + + + + + +### 14. setUp 方法中做一些设置的初始化配置 + +比如需要对一个数据库工具类进行测试,那么可以在 setUp 方法内,设置数据库连接句柄、设置打开的数据库地址信息,然后给当前测试类设置一个全局变量 + +``` +#import "DBHelper.h" + +@interface DBHelpTest:XCTestCase { + DBHelper *_sut; +} +@end + +@implementation DBHelpTest +- (void)setUp { + NSString *docsPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0]; + NSString *dbPath = [docsPath stringByAppendingPathComponent:HCT_LOG_DATABASE_NAME]; + _sut = [[DBHelper alloc] initWithDB:dbPath]; +} + +- (void)testCreateTable { + [_sut createTable:tableName]; + NSString *tableNameGot = [_sut allTable].first; + XCTAssert([tableNameGot isEqualToString:tableName], @"Oops, there is something wrong with createTable: method of DBHelper"); +} + +// ... +@end + +``` + + + + + +## 四、网络测试 我们在测试某个方法的时候可能会遇到方法内部调用了网络通信能力,网络请求成功,可能刷新 UI 或者给出一些成功的提示;网络失败或者网络不可用则给出一些失败的提示。所以需要对网络通信去看进行模拟。 @@ -964,7 +1162,7 @@ SPEC_END 😂 插一嘴,我贴的代码已经好几次可以看到不同的测试框架组合了,所以不是说选了框架 A 就完事,根据场景选择最优解。 -## 六、UI 测试 +## 五、UI 测试 上面文章大篇幅的讲了单元测试相关的话题,单元测试十分适合代码质量、逻辑、网络等内容的测试,但是针对最终产物 App 来说单元测试就不太适合了,如果测试 UI 界面的正确性、功能是否正确显然就不太适合了。Apple 在 Xcode 7 开始推出的 `UI Testing` 就是苹果自己的 UI 测试框架。 @@ -1098,6 +1296,32 @@ Accessibility 通过对 UI 元素进行分类和标记。分类成类似按钮 第三方 UI 自动化测试框架挺多的,可以查看下典型的 [appium](https://github.com/appium/appium)、[macaca](https://github.com/alibaba/macaca)。 + + +## 六、精准测试 + +精准测试是最近很火的一个概念,但是也不算在概念阶段,很多公司都落地并实施了精准测试。单测是开发者为了方法级别写的测试用例。精准测试是代码级别的测试覆盖。 + +价值: + +- 协助研发小伙伴发现问题和漏测、感知 QA 同学测试范围 +- 测试人员感知研发同学代码质量,反向驱动研发侧质量把控,增加质量把控抓手 +- 在产品交付流程中多个环节赋能质量 + +比如目前都是产品设计师去设计测试用例,那么测试用例全不全是一回事,这个时候就需要业务老司机去参与测试用例的评审,并给出一些建议和测试 case 的补充。 + +另外假设有了n条测试用例,那这 n 条测试用例,能不能覆盖开发同学写的每一行代码?可能有些人会好奇单测不是保证了方法被覆盖了吗?单测主要针对逻辑方法,可是用户使用的产品是经过逻辑运算后,还有一部分逻辑是 UI 层的,可能拿到 Model 后在界面展示的地方,走了几个 UI 方法,数据就不对了,这时候可能就是线上 bug 产生的原因。所以这一环节,精准测试可以去发现并解决。 + +精准测试是拿当前开发的代码和上一次基准分支的代码进行比较,判断变动了m行,如果产品设计师提供的测试用例只覆盖了其中的n行,那么剩下的 m-n 行就是风险代码。要催促补充测试用例,将这剩下的 m-n 行代码覆盖完全,这样的代码才是稳定的、可靠的。 + +下面是之前在有赞,开发完精准测试系统后,落地到一个业务项目中取得的价值,帮助2位 QA 发现漏测的代码,倒逼 QA 去设计更完善的测试 case、分析覆盖率低是开发者的兜底代码太多还是真的漏掉了业务 case。 + + + +精准测试助力业务,质量更加稳定。 + + + ## 七、 测试经验总结 TDD 写好测试再写业务代码,BDD 先写实现代码,再写基于行为的测试代码。另一种思路是没必要针对每个类的私有方法或者每个方法进行测试,因为等全部功能做完后针对每个类的接口测试,一般会覆盖据大多数的方法。等测试完看如果方法未被覆盖,则针对性的补充 `Unit Test`。 @@ -1112,6 +1336,6 @@ UITesting 还是建议在核心逻辑且长时间没有改动的情况下去做 我觉得大家一直有个误区,就是觉得软件测试是为了质量额外做的功,这部分功是有用功还是无用功是不一定的。其实,有了正确的开发姿势后(那什么叫正确的开发姿势?设计一个函数的时候就应该想好该方法如何更方便的被测试),最后可以可以实现事半功倍的效果,良好的质量都是附送的,也就是说测试先行是让开发更快更好的展开。 -![测试占比](./../assets/2020-07-14-TestingPercentage.png) +![测试占比](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-14-TestingPercentage.png) WWDC 这张图也很清楚,UI 其实需要的占比较小,还是要靠单测驱动。 diff --git a/assets/Alige-devement-BDDAndTDD.png b/assets/Alige-devement-BDDAndTDD.png new file mode 100644 index 0000000..e8081b5 Binary files /dev/null and b/assets/Alige-devement-BDDAndTDD.png differ diff --git a/assets/Xcode-CoverageGather.png b/assets/Xcode-CoverageGather.png new file mode 100644 index 0000000..b443c61 Binary files /dev/null and b/assets/Xcode-CoverageGather.png differ diff --git a/assets/Xcode-UnitTestPanel.png b/assets/Xcode-UnitTestPanel.png new file mode 100644 index 0000000..5b1e81c Binary files /dev/null and b/assets/Xcode-UnitTestPanel.png differ diff --git a/assets/iOS_PreciousUnitTest1.PNG b/assets/iOS_PreciousUnitTest1.PNG new file mode 100644 index 0000000..9e75e51 Binary files /dev/null and b/assets/iOS_PreciousUnitTest1.PNG differ diff --git a/assets/iOS_PreciousUnitTest2.PNG b/assets/iOS_PreciousUnitTest2.PNG new file mode 100644 index 0000000..d2aff13 Binary files /dev/null and b/assets/iOS_PreciousUnitTest2.PNG differ