Files
knowledge-kit/Chapter1 - iOS/1.75.md
2026-01-02 10:28:57 +08:00

78 KiB
Raw Blame History

写好测试,提升应用质量

相信在国内一些中小型公司,开发者很少会去写软件测试相关的代码。当然这背后有一些原因在。本文就讲讲 iOS 开发中的软件测试相关的内容。

一、 测试的重要性

测试很重要!测试很重要!测试很重要!重要的事情说三遍。

场景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 被广泛使用于敏捷开发。

也就是说 TDD 模式下,首先考虑如何针对功能进行测试,然后去编写代码实现,再不断迭代,在测试用例的保证下,不断进行代码优化。

TDD 强调不断的测试推动代码的开发保证了代码质量。TDD 强调让写代码的过程形成一个循环,在拿到新需求时:

  • 第一步:先为该功能编写一个单元测试,跑一下发现没有通过(这时候还没有实现代码),即图中的 TEST FAILS俗称"红灯"
  • 第二步:编写能够通过全部测试的“最小代码”,之所以强调“最小代码”,就是为了防止过度优化,现实中我们经常会因为代码过度优化,或者过度设计,导致很多遗留问题,在这个阶段,快速实现需求就好了,不需要太多设计。这个阶段俗称“绿灯”
  • 第三步也是最重最要的一步即“重构”Refactor。前面为了快速赶业务代码跨年很脏屎山但至少保证是正确的。当有充足的测试来保证逻辑的正确这时候就可以重构代码了持续重构来保证代码最优。

这也得出2个信息

  1. 单测必须能够快速运行,因为单测是经常需要在本地全量运行的,只有运行足够快,才可以在 TDD 的循环中快速迭代
  2. 好的代码并不是一次完成的,而是持续重构出来的,而单测是持续重构的前提

抛出一个问题TDD 看上去很好,应该用它吗?

这个问题不用着急回答,回答了也不会有对错之分。开发中经常是这样一个流程,新的需求出来后,先经过技术评审会议,确定宏观层面的技术方案、确定各个端的技术实现、使用的技术等,整理出开发文档、会议文档。工期评估后开始编码。事情这么简单吗?前期即使想的再充分、再细致,可能还是存在特殊 case 漏掉的情况,导致技术方案或者是技术实现的改变。如果采用 TDD那么之前新功能给到后就要考虑测试用例的设计、编写了测试代码在测试用例的保证下再去实现功能。如果遇到了技术方案的变更之前的测试用例要改变、测试代码实现要改变。可能新增的某个 case 导致大部分的测试代码和实现代码都要改变。

如何开展 TDD**

  1. 新建一个工程,确保 “Include Unit Tests” 选项是选中的状态

    TDD Step 1

  2. 创建后的工程目录如下

    TDD step2

  3. 删除 Xcode 创建的测试模版文件 TDDDemoTests.m

  4. 假如我们需要设计一个人类,它具有吃饭的功能,且当他吃完后会说一句“好饱啊”。

  5. 那么按照 TDD 我们先设计测试用例。假设有个 Person 类,有个对象方法叫做吃饭,吃完饭后会返回一个“好饱啊”的字符串。那测试用例就是

    步骤 期望 结果
    实例化 Person 对象,调用对象的 eat 方法 调用后返回“好饱啊”
  6. 实现测试用例代码。创建继承自 Unit Test Case class 的测试类,命名为 工程前缀+测试类名+Test,也就是 TDDPersonTest.m

    TDD step 3

  7. 因为要测试 Person 类,所以在主工程中创建 Person 类

  8. 因为要测试人类在吃饭后说一句“好饱啊”。所以设想那个类目前只有一个吃饭的方法。于是在 TDDPersonTest.m 中创建一个测试函数 -(void)testReturnStatusStringWhenPersonAte;函数内容如下

    - (void)testReturnStatusStringWhenPersonAte
    {
        // Given
        Person *somebody = [[Person alloc] init];
    
        // When
        NSString *statusMessage = [somebody performSelector:@selector(eat)];
    
        // Then
        XCTAssert([statusMessage isEqualToString:@"好饱啊"], @"Person 「吃饭后返回“好饱啊”」功能异常");
    }
    
  9. Xcode 下按快捷键 Command + U,跑测试代码发现是失败的。因为我们的 Person 类根本没实现相应的方法

  10. TDD 开发过程可以看到,我们现在是红色的 “Fail” 状态。所以需要去 Person 类中实现功能代码。Person 类如下

    #import "Person.h"
    
    @implementation Person
    
    - (NSString *)eat
    {
        [NSThread sleepForTimeInterval:1];
        return @"好饱啊";;
    }
    
    @end
    
  11. 再次运行,跑一下测试用例(Command + U 快捷键)。发现测试通过,也就是TDD 开发过程中的绿色 “Success” 状态。

  12. 例子比较简单,假如情况需要,可以在 -(void)setUp 方法里面做一些测试的前置准备工作,在 -(void)tearDown 方法里做资源释放的操作

  13. 假如 eat 方法实现的不够漂亮。现在在测试用例的保证下,大胆重构,最后确保所有的 Unit Test case 通过即可。

3. BDD

BDD 即行为驱动开发,是敏捷开发技术之一,通过自然语言定义系统行为,以功能使用者的角度,编写需求场景,且这些行为描述可以直接形成需求文档,同时也是测试标准。

问题是什么?拆解成哪些子问题、做成这个事情需要哪些步骤。这就是 BDD 的编写流程。BDD 使用 DSL Domin Specific Language领域特定语言来描述测试用例这样编写的测试用例非常易读看起来跟文档一样易读BDD 的代码结构是 Given->When->Then

相比 TDDBDD 关注的是行为方式的设计,拿上述“人吃饭”举例说明。

和 TDD 相比第14步骤相同。

  1. BDD 则需要先实现功能代码。创建 Person 类,实现 -(void)eat;方法。代码和上面的相同

  2. BDD 需要引入好用的框架 Kiwi,使用 Pod 的方式引入

  3. 因为要测试人类在吃饭后说一句“好饱啊”。所以设想那个类目前只有一个吃饭的方法。于是在 TDDPersonTest.m 中创建一个测试函数 -(void)testReturnStatusStringWhenPersonAte;函数内容如下

    #import "kiwi.h"
    #import "Person.h"
    
    SPEC_BEGIN(BDDPersonTest)
    
    describe(@"Person", ^{
        context(@"when someone ate", ^{
            it(@"should get a string",^{
                  Person *someone = [[Person alloc] init];
                NSString *statusMessage = [someone eat];
                [[statusMessage shouldNot] beNil];
                [[statusMessage should] equal:@"好饱啊"];
            });
        });
    });
    
    SPEC_END
    

4. 敏捷开发

大多数人觉得写 TDD、BDD 都会影响开发迭代速度,但是 TDD、BDD 恰恰是敏捷开发实践的重要组成部分:

我们学习敏捷开发的时候,常常只学习到它的 “快”,而忽略了敏捷开发所提出的质量保证方法。敏捷开发所谓的“快”,是指在代码质量充分保证下的“快”,而不是做完功能就直接上线。

5. 选什么?

看完了 TDD 和 BDD那我们选什么

TDD 适合新项目从0开始起步开发的大多数情况下我们的手中的项目基本都是已经跑了好几年的业务项目基本上没机会去实施 TDD。

但是对于移动中台来讲,一个新的技术 SDK 是有机会落地 TDD 的。

这里聊聊大多数的情况,已有的代码如何开展测试。我们选择 iOS 平台自带的 XCTest。搭配一些测试框架来开展比如 OCMock 等

开发步骤

Xcode 自带的测试系统是 XCTest,使用简单。开发步骤如下

  • Tests 目录下为被测的类创建一个继承自 XCTestCase 的测试类。

  • 删除新建的测试代码模版里面的无用方法 - (void)testPerformanceExample- (void)testExample

  • 跟普通类一样,可以继承,可以写私有属性、私有方法。所以可以在新建的类里面,根据需求写一些私有属性等

  • - (void)setUp 方法里面写一些初始化、启动设置相关的代码。比如测试数据库功能的时候,写一些数据库连接池相关代码

  • 为被测类里面的每个方法写测试方法。被测类里面可能是 n 个方法,测试类里面可能是 m 个方法m >= n根据我们在第三部分:单元测试编码规范里讲过的 一个测试用例只测试一个分支,方法内部有 if、switch 语句时,需要为每个分支写测试用例

  • 为测试类每个方法写的测试方法有一定的规范。命名必须是 test+被测方法名。函数无参数、无返回值。比如 - (void)testSharedInstance

  • 测试方法里面的代码按照 Given->When->Then 的顺序展开。测试环境所需的先决条件准备;调用所要测试的某个方法、函数;使用断言验证输出和行为是否符合预期。

  • - (void)tearDown 方法里面写一些释放掉资源或者关闭的代码。比如测试数据库功能的时候,写一些数据库连接池关闭的代码

断言相关宏

/*!
 * @function XCTFail(...)
 * Generates a failure unconditionally.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTFail(...) \
    _XCTPrimitiveFail(self, __VA_ARGS__)

/*!
 * @define XCTAssertNil(expression, ...)
 * Generates a failure when ((\a expression) != nil).
 * @param expression An expression of id type.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTAssertNil(expression, ...) \
    _XCTPrimitiveAssertNil(self, expression, @#expression, __VA_ARGS__)

/*!
 * @define XCTAssertNotNil(expression, ...)
 * Generates a failure when ((\a expression) == nil).
 * @param expression An expression of id type.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTAssertNotNil(expression, ...) \
    _XCTPrimitiveAssertNotNil(self, expression, @#expression, __VA_ARGS__)

/*!
 * @define XCTAssert(expression, ...)
 * Generates a failure when ((\a expression) == false).
 * @param expression An expression of boolean type.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTAssert(expression, ...) \
    _XCTPrimitiveAssertTrue(self, expression, @#expression, __VA_ARGS__)

/*!
 * @define XCTAssertTrue(expression, ...)
 * Generates a failure when ((\a expression) == false).
 * @param expression An expression of boolean type.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTAssertTrue(expression, ...) \
    _XCTPrimitiveAssertTrue(self, expression, @#expression, __VA_ARGS__)

/*!
 * @define XCTAssertFalse(expression, ...)
 * Generates a failure when ((\a expression) != false).
 * @param expression An expression of boolean type.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTAssertFalse(expression, ...) \
    _XCTPrimitiveAssertFalse(self, expression, @#expression, __VA_ARGS__)

/*!
 * @define XCTAssertEqualObjects(expression1, expression2, ...)
 * Generates a failure when ((\a expression1) not equal to (\a expression2)).
 * @param expression1 An expression of id type.
 * @param expression2 An expression of id type.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTAssertEqualObjects(expression1, expression2, ...) \
    _XCTPrimitiveAssertEqualObjects(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__)

/*!
 * @define XCTAssertNotEqualObjects(expression1, expression2, ...)
 * Generates a failure when ((\a expression1) equal to (\a expression2)).
 * @param expression1 An expression of id type.
 * @param expression2 An expression of id type.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTAssertNotEqualObjects(expression1, expression2, ...) \
    _XCTPrimitiveAssertNotEqualObjects(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__)

/*!
 * @define XCTAssertEqual(expression1, expression2, ...)
 * Generates a failure when ((\a expression1) != (\a expression2)).
 * @param expression1 An expression of C scalar type.
 * @param expression2 An expression of C scalar type.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTAssertEqual(expression1, expression2, ...) \
    _XCTPrimitiveAssertEqual(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__)

/*!
 * @define XCTAssertNotEqual(expression1, expression2, ...)
 * Generates a failure when ((\a expression1) == (\a expression2)).
 * @param expression1 An expression of C scalar type.
 * @param expression2 An expression of C scalar type.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTAssertNotEqual(expression1, expression2, ...) \
    _XCTPrimitiveAssertNotEqual(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__)

/*!
 * @define XCTAssertEqualWithAccuracy(expression1, expression2, accuracy, ...)
 * Generates a failure when (difference between (\a expression1) and (\a expression2) is > (\a accuracy))).
 * @param expression1 An expression of C scalar type.
 * @param expression2 An expression of C scalar type.
 * @param accuracy An expression of C scalar type describing the maximum difference between \a expression1 and \a expression2 for these values to be considered equal.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTAssertEqualWithAccuracy(expression1, expression2, accuracy, ...) \
    _XCTPrimitiveAssertEqualWithAccuracy(self, expression1, @#expression1, expression2, @#expression2, accuracy, @#accuracy, __VA_ARGS__)

/*!
 * @define XCTAssertNotEqualWithAccuracy(expression1, expression2, accuracy, ...)
 * Generates a failure when (difference between (\a expression1) and (\a expression2) is <= (\a accuracy)).
 * @param expression1 An expression of C scalar type.
 * @param expression2 An expression of C scalar type.
 * @param accuracy An expression of C scalar type describing the maximum difference between \a expression1 and \a expression2 for these values to be considered equal.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTAssertNotEqualWithAccuracy(expression1, expression2, accuracy, ...) \
    _XCTPrimitiveAssertNotEqualWithAccuracy(self, expression1, @#expression1, expression2, @#expression2, accuracy, @#accuracy, __VA_ARGS__)

/*!
 * @define XCTAssertGreaterThan(expression1, expression2, ...)
 * Generates a failure when ((\a expression1) <= (\a expression2)).
 * @param expression1 An expression of C scalar type.
 * @param expression2 An expression of C scalar type.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTAssertGreaterThan(expression1, expression2, ...) \
    _XCTPrimitiveAssertGreaterThan(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__)

/*!
 * @define XCTAssertGreaterThanOrEqual(expression1, expression2, ...)
 * Generates a failure when ((\a expression1) < (\a expression2)).
 * @param expression1 An expression of C scalar type.
 * @param expression2 An expression of C scalar type.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTAssertGreaterThanOrEqual(expression1, expression2, ...) \
    _XCTPrimitiveAssertGreaterThanOrEqual(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__)

/*!
 * @define XCTAssertLessThan(expression1, expression2, ...)
 * Generates a failure when ((\a expression1) >= (\a expression2)).
 * @param expression1 An expression of C scalar type.
 * @param expression2 An expression of C scalar type.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTAssertLessThan(expression1, expression2, ...) \
    _XCTPrimitiveAssertLessThan(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__)

/*!
 * @define XCTAssertLessThanOrEqual(expression1, expression2, ...)
 * Generates a failure when ((\a expression1) > (\a expression2)).
 * @param expression1 An expression of C scalar type.
 * @param expression2 An expression of C scalar type.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTAssertLessThanOrEqual(expression1, expression2, ...) \
    _XCTPrimitiveAssertLessThanOrEqual(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__)

/*!
 * @define XCTAssertThrows(expression, ...)
 * Generates a failure when ((\a expression) does not throw).
 * @param expression An expression.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTAssertThrows(expression, ...) \
    _XCTPrimitiveAssertThrows(self, expression, @#expression, __VA_ARGS__)

/*!
 * @define XCTAssertThrowsSpecific(expression, exception_class, ...)
 * Generates a failure when ((\a expression) does not throw \a exception_class).
 * @param expression An expression.
 * @param exception_class The class of the exception. Must be NSException, or a subclass of NSException.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTAssertThrowsSpecific(expression, exception_class, ...) \
    _XCTPrimitiveAssertThrowsSpecific(self, expression, @#expression, exception_class, __VA_ARGS__)

/*!
 * @define XCTAssertThrowsSpecificNamed(expression, exception_class, exception_name, ...)
 * Generates a failure when ((\a expression) does not throw \a exception_class with \a exception_name).
 * @param expression An expression.
 * @param exception_class The class of the exception. Must be NSException, or a subclass of NSException.
 * @param exception_name The name of the exception.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTAssertThrowsSpecificNamed(expression, exception_class, exception_name, ...) \
    _XCTPrimitiveAssertThrowsSpecificNamed(self, expression, @#expression, exception_class, exception_name, __VA_ARGS__)

/*!
 * @define XCTAssertNoThrow(expression, ...)
 * Generates a failure when ((\a expression) throws).
 * @param expression An expression.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTAssertNoThrow(expression, ...) \
    _XCTPrimitiveAssertNoThrow(self, expression, @#expression, __VA_ARGS__)

/*!
 * @define XCTAssertNoThrowSpecific(expression, exception_class, ...)
 * Generates a failure when ((\a expression) throws \a exception_class).
 * @param expression An expression.
 * @param exception_class The class of the exception. Must be NSException, or a subclass of NSException.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTAssertNoThrowSpecific(expression, exception_class, ...) \
    _XCTPrimitiveAssertNoThrowSpecific(self, expression, @#expression, exception_class, __VA_ARGS__)

/*!
 * @define XCTAssertNoThrowSpecificNamed(expression, exception_class, exception_name, ...)
 * Generates a failure when ((\a expression) throws \a exception_class with \a exception_name).
 * @param expression An expression.
 * @param exception_class The class of the exception. Must be NSException, or a subclass of NSException.
 * @param exception_name The name of the exception.
 * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted.
*/
#define XCTAssertNoThrowSpecificNamed(expression, exception_class, exception_name, ...) \
    _XCTPrimitiveAssertNoThrowSpecificNamed(self, expression, @#expression, exception_class, exception_name, __VA_ARGS__)

经验小结

  1. XCTestCase 类和其他类一样,你可以定义基类,这里面封装一些常用的方法。

    // HCTTestCase.h
    #import <XCTest/XCTest.h>
    
    NS_ASSUME_NONNULL_BEGIN
    
    @interface HCTTestCase : XCTestCase
    
    @property (nonatomic, assign) NSTimeInterval networkTimeout;
    
    /// 用一个默认时间设置异步测试 XCTestExpectation 的超时处理
    - (void)waitForExpectationsWithCommonTimeout;
    
    /**
    用一个默认时间设置异步测试的
    @param handler 超时的处理逻辑
    */
    - (void)waitForExpectationsWithCommonTimeoutUsingHandler:(XCWaitCompletionHandler __nullable)handler;
    
    /**
    生成 Crash 类型的 meta 数据
    @return meta 类型的字典
    */
    - (NSDictionary *)generateCrashMetaDataFromReport;
    
     @end
    
     NS_ASSUME_NONNULL_END
    
    // HCTTestCase.m
    #import "HCTTestCase.h"
    #import ...
    
    @implementation HCTTestCase
    
    #pragma mark - life cycle
    
    - (void)setUp {
        [super setUp];
        self.networkTimeout = 20.0;
        // 1. 设置平台信息
        [self setupAppProfile];
        // 2. 设置 Mget 配置
        [[TITrinityInitManager sharedInstance] setup];
        // ....
        // 3. 设置 HermesClient
        [[HermesClient sharedInstance] setup];
    }
    
    - (void)tearDown {
        [super tearDown];
    }
    
    #pragma mark - public Method
    - (void)waitForExpectationsWithCommonTimeout {
        [self waitForExpectationsWithCommonTimeoutUsingHandler:nil];
    }
    
    - (void)waitForExpectationsWithCommonTimeoutUsingHandler:(XCWaitCompletionHandler __nullable)handler {
        [self waitForExpectationsWithTimeout:self.networkTimeout handler:handler];
    }
    
    - (NSDictionary *)generateCrashMetaDataFromReport {
        NSMutableDictionary *metaDictionary = [NSMutableDictionary dictionary];
        NSDate *crashTime = [NSDate date];
        metaDictionary[@"MONITOR_TYPE"] = @"appCrash";
        // ...
        metaDictionary[@"USER_CRASH_DATE"] = @([crashTime timeIntervalSince1970] * 1000);
        return [metaDictionary copy];
    }
    
    #pragma mark - private method
    - (void)setupAppProfile {
        [[CMAppProfile sharedInstance] setMPlatform:@"70"];
        // ... 
    } 
    @end
    
  2. 上述说的基本是开发规范相关。测试方法内部如果调用了其他类的方法,则在测试方法内部必须 Mock 一个外部对象,限制好返回值等。

  3. 在 XCTest 内难以使用 mock 或 stub这些是测试中非常常见且重要的功能

例子

这里举个例子,是测试一个数据库操作类 HCTDatabase,代码只放某个方法的测试代码。

- (void)testRemoveLatestRecordsByCount {
    XCTestExpectation *exception = [self expectationWithDescription:@"测试数据库删除最新数据功能"];
    // 1. 先清空数据表
    [dbInstance removeAllLogsInTableType:HCTLogTableTypeMeta];
    // 2. 再插入一批数据
    NSMutableArray *insertModels = [NSMutableArray array];
    NSMutableArray *reportIDS = [NSMutableArray array];

    for (NSInteger index = 1; index <= 100; index++) {
        HCTLogMetaModel *model = [[HCTLogMetaModel alloc] init];
        model.log_id = index;
        // ...
        if (index > 90 && index <= 100) {
            [reportIDS addObject:model.report_id];
        }
        [insertModels addObject:model];
    }
    [dbInstance add:insertModels inTableType:HCTLogTableTypeMeta];

    // 3. 将早期的数据删除掉id > 90 && id <= 100
    [dbInstance removeLatestRecordsByCount:10 inTableType:HCTLogTableTypeMeta];

    // 4. 拿到当前的前10条数据和之前存起来的前10条 id 做比较。再判断当前表中的总记录条数是否等于 90
    [dbInstance getLatestRecoreds:10 inTableType:HCTLogTableTypeMeta completion:^(NSArray<HCTLogModel *> * _Nonnull records) {
        NSArray<HCTLogModel *> *latestRTentRecords = records;

        [dbInstance getOldestRecoreds:100 inTableType:HCTLogTableTypeMeta completion:^(NSArray<HCTLogModel *> * _Nonnull records) {
            NSArray<HCTLogModel *> *currentRecords = records;

            __block BOOL isEarlyData = NO;
            [latestRTentRecords enumerateObjectsUsingBlock:^(HCTLogModel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
                if ([reportIDS containsObject:obj.report_id]) {
                    isEarlyData = YES;
                }
            }];

            XCTAssert(!isEarlyData && currentRecords.count == 90, @"***Database「删除最新n条数据」功能异常");
            [exception fulfill];
        }];

    }];
    [self waitForExpectationsWithCommonTimeout];
}

6. 测试框架

1. Kiwi

BDD 框架里的 Kiwi 可圈可点。使用 CocoaPods 引入 pod 'Kiwi'。看下面的例子

被测类Planck 项目是一个基于 WebView 的 SDK根据业务场景发现针对 WebView 的大部分功能定制都是基于 WebView 的生命周期内发生的,所以参考 NodeJS 的中间件思想,设计了基于生命周期的 WebView 中间件)

#import <Foundation/Foundation.h>

@interface TPKTrustListHelper : NSObject

+(void)fetchRemoteTrustList;

+(BOOL)isHostInTrustlist:(NSString *)scheme;

+(NSArray *)trustList;

@end

测试类

SPEC_BEGIN(TPKTrustListHelperTest)
describe(@"Middleware Wrapper", ^{

    context(@"when get trustlist", ^{
        it(@"should get a array of string",^{
            NSArray *array = [TPKTrustListHelper trustList];
            [[array shouldNot] beNil];
            NSString *first = [array firstObject];
            [[first shouldNot] beNil];
            [[NSStringFromClass([first class]) should] equal:@"__NSCFString"];
        });
    });

    context(@"when check a string wether contained in trustlist ", ^{
        it(@"first string should contained in trustlist",^{
            NSArray *array = [TPKTrustListHelper trustList];
            NSString *first = [array firstObject];
            [[theValue([TPKTrustListHelper isHostInTrustlist:first]) should] equal:@(YES)];
        });
    });
});
SPEC_END

例子包含 Kiwi 的最基础元素。SPEC_BEGINSPEC_END 表示测试类;describe 描述需要被测试的类;context 表示一个测试场景,也就是 Given->When->Then 里的 Givenit 表示要测试的内容,也就是也就是 Given->When->Then 里的 WhenThen。1个 describe 下可以包含多个 context1个 context 下可以包含多个 it

Kiwi 的使用分为:SpecsExpectationsMocks and StubsAsynchronous Testing 四部分。点击可以访问详细的说明文档。

it 里面的代码块是真正的测试代码,使用链式调用的方式,简单上手。

测试领域中 Mock 和 Stub 非常重要。Mock 模拟对象可以降低对象之间的依赖模拟出一个纯净的测试环境类似初中物理课上“控制变量法”的思想。Kiwi 也支持的非常好,可以模拟对象、模拟空对象、模拟遵循协议的对象等等,点击 Mocks and Stubs 查看。Stub 存根可以控制某个方法的返回值,这对于方法内调用别的对象的方法返回值很有帮助。减少对于外部的依赖,单一测试当前行为是否符合预期。

针对异步测试XCTest 则需要创建一个 XCTestExpectation 对象,在异步实现里面调用该对象的 fulfill 方法,最后设置最大等待时间和完成的回调 - (void)waitForExpectationsWithTimeout:(NSTimeInterval)timeout handler:(nullable XCWaitCompletionHandler)handler; 如下例子

XCTestExpectation *exception = [self expectationWithDescription:@"测试数据库插入功能"];
    [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];
    }
    [dbInstance add:insertModels inTableType:HCTLogTableTypeMeta];
    [dbInstance recordsCountInTableType:HCTLogTableTypeMeta completion:^(NSInteger count) {
        XCTAssert(count == insertModels.count, @"**Database「数据增加」功能异常");
        [exception fulfill];
    }];
    [self waitForExpectationsWithCommonTimeout];

2. expecta、Specta

expecta 和 Specta 都出自 orta 之手,他也是 Cocoapods 的开发者之一。太牛逼了,工程化、质量保证领域的大佬。

Specta 是一个轻量级的 BDD 测试框架,采用 DSL 模式,让测试更接近于自然语言,因此更易读。

特点:

  • 易于集成到项目中。在 Xcode 中勾选 Include Unit Tests ,和 XCTest 搭配使用
  • 语法很规范,对比 Kiwi 和 Specta 的文档,发现很多东西都是相同的,也就是很规范,所以学习成本低、后期迁移到其他框架很平滑。

Expecta 是一个匹配(断言)框架,相比 Xcode 的断言 XCAssertExcepta 提供更加丰富的断言。

特点:

  • Eepecta 没有数据类型限制,比如 1并不关心是 NSInteger 还是 CGFloat
  • 链式编程,写起来很舒服
  • 反向匹配,很灵活。断言匹配用 except(...).to.equal(...),断言不匹配则使用 .notTo 或者 .toNot
  • 延时匹配,可以在链式表达式后加入 .will.willNot.after(interval)

3. OCMock

使用 OCMock 经常做的事情就是准备数据、添加预期、执行断言。具体用法可以查看文档

简单看看原理。

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 消息转发去实现的。

7. 小结

Xcode 自带的 XCTestCase 比较适合 TDD不影响源代码系统独立且不影响 App 包大小。适合简单场景下的测试。且每个函数在最左侧又个测试按钮,点击后可以单独测试某个函数。

Kiwi 是一个强大的 BDD 框架,适合稍微复杂写的项目,写法舒服、功能强大,模拟对象、存根语法、异步测试等满足几乎所有的测试场景。不能和 XCTest 继承。

Specta 也是一个 BDD 框架,基于 XCTest 开发,可以和 XCTest 模版集合使用。相比 KiwiSpecta 轻量一些。开发中一般搭配 Excepta 使用。如果需要使用 Mock 和 Stud 可以搭配 OCMock。

Excepta 是一个匹配框架,比 XCTest 的断言则更加全面一些。

没办法说哪个最好、最合理,根据项目需求选择合适的组合。

三、 单元测试编码规范

本文的主要重点是针对日常开发阶段工程师可以做的事情,也就是单元测试而展开。

编写功能、业务代码的时候一般会遵循 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];
   }
}

Bad Case

- (void)testShouldIEatSomething {
	// case1
  // case2
}

Good Case

- (void)testShouldIEatSomethingWhenHungry {
   // ....
}

- (void)testShouldIEatSomethingWhenFull
{
  // ...
}

QA一个被测方法有诸多 case为什么不写在一个测试方法中将这些 case 写全?

因为测试有个原则,就是每个测试 case 都是能够单独可运行的。如果2个case都在一个方法内那就不是单独可运行。怎么理解贴个图点击最左边的小菱形该 case 是需要单独可运行的)

再有一个参照物,大家都在用的 ITerm2 的源码是开源的,看看 ITerm2 是如何写测试的

3. 明确标识被测试类

这条主要站在团队合作和代码可读性角度出发来说明。写过单元测试的人都知道可能某个函数本来就10行代码可是为了测试它测试代码写了30行。一个方法这样写问题不大多看看就看明白是在测试哪个类的哪个方法。可是当这个类本身就很大测试代码很大的情况下不管是作者自身还是多年后负责维护的其他同事看这个代码阅读成本会很大需要先看测试文件名 代码类名 + Test 才知道是测试的是哪个类,看测试方法名 test + 方法名 才知道是测试的是哪个方法。

这样的代码可读性很差,所以应该为当前的测试对象特殊标记,这样测试代码可读性越强、阅读成本越低。比如定义局部变量 _sut 用来标记当前被测试类(sutSystem 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. 使用分类来暴露私有方法、私有变量

某些场景下写的测试方法内部可能需要调用被测对象的私有方法,也可能需要访问被测对象的某个私有属性。但是测试类里面是访问不到被测类的私有属性和私有方法的,借助于 Runtime 特性,我们可以给被测类添加 Category 来解决无法访问私有方法和私有属性的问题。

为测试类添加一个分类,后缀名为 UnitTest。如下所示

HermesClient 类有私有属性 @property (nonatomic, strong) NSString *name;,私有方法 - (void)hello。为了在测试用例中访问私有属性和私有方法,写了如下分类

@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 应该只针对某个逻辑分支进行测试,那么对于特定输入、一定有一个特定输出,来判断是否符合预期。为此,其他的因素都应该是唯一确定的,保证测试环境的确定性。

/// 测试数据库的查询最新一条记录的能力
- (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 之类的代码,整个单测工程运行就会很慢,问题验证或者查看整个工程的覆盖率就会很慢。

举一个工程中的例子。

// 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. 测试不要名不副实

避免测试的描述和测试内容不符,测试结果必须精准,测试断言信息也需要精准。

// bad case
- (void)testDB {
	[_sut clear];
	[_sut insert:datasource];
	XCTAssert([_sut count] == datasource.count, @"Oops, there is something wrong with DB");
}

单通过方法名根本不知道该测试用例是在测试什么。所以这个就是一个名不副实。

9. 使用有意义的断言

断言的错误信息要有意义,出现问题能够明确错误的原因

// 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 阶段清理测试环境,例如还原全局的配置、清理创建的文件目录、断开数据库连接句柄等

- (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

15. Swift 测试用例代码测试 OC 被测代码

编写 Swift 测试代码去测试 OC 被测类的时候,需要做一些处理:

  1. 主工程不管是不是混编,但为了让 Swift 测试代码,可以访问到 OC 被测类,需要创建一个 Swift 文件,系统会自动创建 bridge 文件,且需要在 AppTestingExplore-Bridging-Header.h 文件中导出需要被测的头文件
  2. 在 Swift 测试文件中,导入主工程 module。

@testable 是 Swift 语言的一个特性,它允许测试用例访问应用程序或框架中标记为 internalprivate 的属性、方法和其他成员。这样做可以在不改变访问级别的情况下编写测试用例,从而保持代码的封装性和安全性。使用 @testable 可以增强测试覆盖率,因为它允许测试那些通常因为访问级别限制而无法测试的内部实现细节。同时,它还有助于保持代码的封装性,因为不需要将内部实现细节暴露为 public 就可以进行单元测试。此外,@testable 提高了测试的灵活性,在不修改代码访问级别的情况下,能够对代码进行全面的测试

四、网络测试

我们在测试某个方法的时候可能会遇到方法内部调用了网络通信能力,网络请求成功,可能刷新 UI 或者给出一些成功的提示;网络失败或者网络不可用则给出一些失败的提示。所以需要对网络通信去看进行模拟。

iOS 中很多网络都是基于 NSURL 系统下的类实现的。所以我们可以利用 NSURLProtocol 的能力来监控网络并 mock 网络数据。如果感兴趣可以查看这篇文章

开源项目 OHHTTPStubs 就是一个对网络模拟的库。它可以拦截 HTTP 请求,返回 json 数据,定制各种头信息。

Stub your network requests easily! Test your apps with fake network data and custom response time, response code and headers!

几个主要类及其功能:HTTPStubsProtocol 拦截网络请求;HTTPStubs 单例管理 HTTPStubsDescriptor 实例对象;HTTPStubsResponse 伪造 HTTP 请求。

HTTPStubsProtocol 继承自 NSURLProtocol,可以在 HTTP 请求发送之前对 request 进行过滤处理

+ (BOOL)canInitWithRequest:(NSURLRequest *)request
{
   BOOL found = ([HTTPStubs.sharedInstance firstStubPassingTestForRequest:request] != nil);
   if (!found && HTTPStubs.sharedInstance.onStubMissingBlock) {
      HTTPStubs.sharedInstance.onStubMissingBlock(request);
   }
   return found;
}

firstStubPassingTestForRequest 方法内部会判断请求是否需要被当前对象处理

紧接着开始发送网络请求。实际上在 - (void)startLoading 方法中可以用任何网络能力去完成请求,比如 NSURLSession、NSURLConnection、AFNetworking 或其他网络框架。OHHTTPStubs 的做法是获取 request、client 对象。如果 HTTPStubs 单例中包含 onStubActivationBlock 对象,则执行该 block然后利用 responseBlock 对象返回一个 HTTPStubsResponse 响应对象。

OHHTTPStubs 的具体 API 可以查看文档

举个例子,利用 Kiwi、OHHTTPStubs 测试离线包功能。代码如下

@interface HORouterManager (Unittest)

- (void)fetchOfflineInfoIfNeeded;

@end

SPEC_BEGIN(HORouterTests)

describe(@"routerTests", ^{
    context(@"criticalPath", ^{
        __block HORouterManager *routerManager = nil;
        beforeAll(^{
            routerManager = [[HORouterManager alloc] init];
        });
        it(@"getLocalPath", ^{
            __block NSString *pagePath = nil;
            dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(4 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
                pagePath = [routerManager filePathOfUrl:@"http://***/resource1"];
            });
            [[expectFutureValue(pagePath) shouldEventuallyBeforeTimingOutAfter(5)] beNonNil];

            __block NSString *rescPath = nil;
            dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(4 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
                rescPath = [routerManager filePathOfUrl:@"http://***/resource1"];
            });
            [[expectFutureValue(rescPath) shouldEventuallyBeforeTimingOutAfter(5)] beNonNil];
        });
        it(@"fetchOffline", ^{
            [HOOfflineManager sharedInstance].offlineInfoInterval = 0;
            [OHHTTPStubs stubRequestsPassingTest:^BOOL(NSURLRequest *request) {
                return [request.URL.absoluteString containsString:@"h5-offline-pkg"];
            } withStubResponse:^OHHTTPStubsResponse*(NSURLRequest *request) {
                NSMutableDictionary *dict = [NSMutableDictionary dictionary];
                dict[@"code"] = @(0);
                dict[@"data"] = @"f722fc3efce547897819e9449d2ac562cee9075adda79ed74829f9d948e2f6d542a92e969e39dfbbd70aa2a7240d6fa3e51156c067e8685402727b6c13328092ecc0cbc773d95f9e0603b551e9447211b0e3e72648603e3d18e529b128470fa86aeb45d16af967d1a21b3e04361cfc767b7811aec6f19c274d388ddae4c8c68e857c14122a44c92a455051ae001fa7f2b177704bdebf8a2e3277faf0053460e0ecf178549e034a086470fa3bf287abbdd0f79867741293860b8a29590d2c2bb72b749402fb53dfcac95a7744ad21fe7b9e188881d1c24047d58c9fa46b3ebf4bc42a1defc50748758b5624c6c439c182fe21d4190920197628210160cf279187444bd1cb8707362cc4c3ab7486051af088d7851846bea21b64d4a5c73bd69aafc4bb34eb0862d1525c4f9a62ce64308289e2ecbc19ea105aa2bf99af6dd5a3ff653bbe7893adbec37b44a088b0b74b80532c720c79b7bb59fda3daf85b34ef35";
                NSData *data = [NSJSONSerialization dataWithJSONObject:dict options:0 error:nil];
                return [OHHTTPStubsResponse responseWithData:data
                                                  statusCode:200
                                                     headers:@{@"Content-Type":@"application/json"}];
            }];
            [routerManager fetchOfflineInfoIfNeeded];
            [[HOOfflineInfo shouldEventually] receive:@selector(saveToLocal:)];
        });
    });
});

SPEC_END

😂 插一嘴,我贴的代码已经好几次可以看到不同的测试框架组合了,所以不是说选了框架 A 就完事,根据场景选择最优解。

五、UI 测试

概念

UI 测试:属于端到端测试,是从应用程序启动到结束的测试过程。完全按照用户与应用程序交互的方式来复制与应用程序的交互。比但愿测试慢得多,运行起来更消耗资源。

测试原则

FIRST

  • Fast测试模块应该是快速高效的
  • Independent/Isolated测试模块应该是独立、互相不影响的
  • Repeatable测试实例应该是可以重复使用的测试结果应该是相同的
  • Self-validating测试应完全自动化。输出结果要么成功、要么失败
  • Timely理想情况下应该在编写要测试的生产代码之前编写测试测试驱动开发

需要测试什么?

  • 视觉表现验证

    • 元素渲染控件尺寸、颜色、字体、图标资源accessibilityIdentifier 定位)
    • 布局规则动态布局Auto Layout 约束断裂检测)、横竖屏适配、多语言截断
    • 动效完整性:转场动画时长、交互反馈(如按钮点击态)
  • 交互行为验证

    交互类型 测试要点 工具示例
    手势操作 滑动/长按/捏合等触发事件 XCUITest: swipeUp()
    表单输入 键盘类型切换、输入校验(正则)、自动填充 typeText("test@email.com")
    导航流 页面跳转栈深度、返回逻辑(物理返回 vs 程序返回) navigationBars.buttons["Back"].tap()
    异步状态 加载中/空状态/错误页的显示与隐藏 waitForExistence(timeout: 5)
  • 数据驱动验证

    • API 数据映射Mock 不同 API 响应200/404/500检查 UI 渲染正确性
    • 本地数据同步Core Data/Realm 更新后 UI 即时刷新
    • 动态内容:富文本(含超链接)、图片懒加载、视频播放器状态
  • 边界场景验证Edge Cases

    • 设备兼容:从 iPhone SE 到 iPad Pro 的适配
    • 系统版本iOS 14~17 的关键行为差异(如权限弹窗样式)
    • 极端操作:快速连续点击、低内存告警恢复
    • 无障碍支持VoiceOver 焦点顺序、Dynamic Type 超大字体布局

基础使用

上面文章大篇幅的讲了单元测试相关的话题,单元测试十分适合代码质量、逻辑、网络等内容的测试,但是针对最终产物 App 来说单元测试就不太适合了,如果测试 UI 界面的正确性、功能是否正确显然就不太适合了。Apple 在 Xcode 7 开始推出的 UI Testing 就是苹果自己的 UI 测试框架。

很多 UI 自动化测试框架的底层实现都依赖于 Accessibility,也就是 App 可用性。UI Accessibility 是 iOS 3.0 引入的一个人性化功能,帮助身体不便的人士方便使用 App。

Accessibility 通过对 UI 元素进行分类和标记。分类成类似按钮、文本框、文本等类型,使用 identifier 来区分不同 UI 元素。无痕埋点的设计与实现里面也使用 accessibilityIdentifier 来绑定业务数据。

  1. 使用 Xcode 自带的 UI测试则在创建工程的时候需要勾选 “Include UI Tests”。
  2. 像单元测试意义UI 测试方法命名以 test 开头。将鼠标光标移到方法内,点击 Xcode 左下方的红色按钮,开始录制 UI 脚本。

UI 脚本录制

解释说明:

/*! Proxy for an application that may or may not be running. */
@interface XCUIApplication : XCUIElement
// ...
@end
  • XCUIApplication launch 来启动测试。XCUIApplication 是 UIApplication 在测试进程中的代理,用来和 App 进行一些交互。

  • 使用 staticTexts来获取当前屏幕上的静态文本UILabel元素的代理。等价于 [app descendantsMatchingType:XCUIElementTypeStaticText]。XCUIElementTypeStaticText 参数是枚举类型。

    typedef NS_ENUM(NSUInteger, XCUIElementType) {
        XCUIElementTypeAny = 0,
        XCUIElementTypeOther = 1,
        XCUIElementTypeApplication = 2,
        XCUIElementTypeGroup = 3,
        XCUIElementTypeWindow = 4,
        XCUIElementTypeSheet = 5,
        XCUIElementTypeDrawer = 6,
        XCUIElementTypeAlert = 7,
        XCUIElementTypeDialog = 8,
        XCUIElementTypeButton = 9,
        XCUIElementTypeRadioButton = 10,
        XCUIElementTypeRadioGroup = 11,
        XCUIElementTypeCheckBox = 12,
        XCUIElementTypeDisclosureTriangle = 13,
        XCUIElementTypePopUpButton = 14,
        XCUIElementTypeComboBox = 15,
        XCUIElementTypeMenuButton = 16,
        XCUIElementTypeToolbarButton = 17,
        XCUIElementTypePopover = 18,
        XCUIElementTypeKeyboard = 19,
        XCUIElementTypeKey = 20,
        XCUIElementTypeNavigationBar = 21,
        XCUIElementTypeTabBar = 22,
        XCUIElementTypeTabGroup = 23,
        XCUIElementTypeToolbar = 24,
        XCUIElementTypeStatusBar = 25,
        XCUIElementTypeTable = 26,
        XCUIElementTypeTableRow = 27,
        XCUIElementTypeTableColumn = 28,
        XCUIElementTypeOutline = 29,
        XCUIElementTypeOutlineRow = 30,
        XCUIElementTypeBrowser = 31,
        XCUIElementTypeCollectionView = 32,
        XCUIElementTypeSlider = 33,
        XCUIElementTypePageIndicator = 34,
        XCUIElementTypeProgressIndicator = 35,
        XCUIElementTypeActivityIndicator = 36,
        XCUIElementTypeSegmentedControl = 37,
        XCUIElementTypePicker = 38,
        XCUIElementTypePickerWheel = 39,
        XCUIElementTypeSwitch = 40,
        XCUIElementTypeToggle = 41,
        XCUIElementTypeLink = 42,
        XCUIElementTypeImage = 43,
        XCUIElementTypeIcon = 44,
        XCUIElementTypeSearchField = 45,
        XCUIElementTypeScrollView = 46,
        XCUIElementTypeScrollBar = 47,
        XCUIElementTypeStaticText = 48,
        XCUIElementTypeTextField = 49,
        XCUIElementTypeSecureTextField = 50,
        XCUIElementTypeDatePicker = 51,
        XCUIElementTypeTextView = 52,
        XCUIElementTypeMenu = 53,
        XCUIElementTypeMenuItem = 54,
        XCUIElementTypeMenuBar = 55,
        XCUIElementTypeMenuBarItem = 56,
        XCUIElementTypeMap = 57,
        XCUIElementTypeWebView = 58,
        XCUIElementTypeIncrementArrow = 59,
        XCUIElementTypeDecrementArrow = 60,
        XCUIElementTypeTimeline = 61,
        XCUIElementTypeRatingIndicator = 62,
        XCUIElementTypeValueIndicator = 63,
        XCUIElementTypeSplitGroup = 64,
        XCUIElementTypeSplitter = 65,
        XCUIElementTypeRelevanceIndicator = 66,
        XCUIElementTypeColorWell = 67,
        XCUIElementTypeHelpTag = 68,
        XCUIElementTypeMatte = 69,
        XCUIElementTypeDockItem = 70,
        XCUIElementTypeRuler = 71,
        XCUIElementTypeRulerMarker = 72,
        XCUIElementTypeGrid = 73,
        XCUIElementTypeLevelIndicator = 74,
        XCUIElementTypeCell = 75,
        XCUIElementTypeLayoutArea = 76,
        XCUIElementTypeLayoutItem = 77,
        XCUIElementTypeHandle = 78,
        XCUIElementTypeStepper = 79,
        XCUIElementTypeTab = 80,
        XCUIElementTypeTouchBar = 81,
        XCUIElementTypeStatusItem = 82,
    };
    
  • 通过 XCUIApplication 实例化对象调用 descendantsMatchingType: 方法得到的是 XCUIElementQuery 类型。比如 @property (readonly, copy*) XCUIElementQuery *staticTexts;

    /*! Returns a query for all descendants of the element matching the specified type. */
    - (XCUIElementQuery *)descendantsMatchingType:(XCUIElementType)type;
    
  • descendantsMatchingType 返回所有后代的类型匹配对象。childrenMatchingType 返回当前层级子元素的类型匹配对象

    /*! Returns a query for direct children of the element matching the specified type. */
    - (XCUIElementQuery *)childrenMatchingType:(XCUIElementType)type;
    
  • 拿到 XCUIElementQuery 后不能直接拿到 XCUIElement。和 XCUIApplication 类似,XCUIElement 不能直接访问 UI 元素,它是 UI 元素在测试框架中的代理。可以通过 Accessibility 中的 frameidentifier 来获取。

对比很多自动化测试框架都需要找出 UI 元素,也就是借助于 Accessibilityidentifier。这里的唯一标识生成对比为 UIAutomation 添加自动化测试标签的探索]

第三方 UI 自动化测试框架挺多的,可以查看下典型的 appiummacaca

经验心得

UI 测试另一个问题是,某些 UI 方法比如 AppDelegate 里包含太多 SDK 的或者拉接口的场景,启动会比较慢,测试的诉求是:单个测试 case 需要快速运行。而 UI 测试聚焦的不是借口业务逻辑,所以期望 AppDelegate 里的拉接口这样的逻辑不要走,太慢影响测试速度。

理论分析:如果可以从 NSClassFromString(@"XCTestCase") 方式获取到值,说明是测试环境,可以简化 AppDelegate 逻辑。

具体做法是在开发阶段预留测试口子。非测试模式,走正常的业务逻辑;测试模式,走简化版 AppDelegate 逻辑。

第一步:改造 main.m

int main(int argc, char * argv[]) {
    NSString * appDelegateClassName;
    @autoreleasepool {
        // Setup code that might create autoreleased objects goes here.
        id testClass = NSClassFromString(@"XCTestCase");
        appDelegateClassName = testClass ? @"TestMockAppDelegate" : NSStringFromClass([AppDelegate class]);
    }
    return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}

第二步:创建 mock 的简化版 TestMockAppDelegate,可以剔除一些 UI 测试不关心的逻辑。甚至只需要完成这个方法基础实现都可以。

单元测试的原理窥探

开发的单元测试代码,运行的背后也是一个可执行文件。查看内部,可以发现一堆和测试相关的 framework。

思考:一个非 UI 测试工程(正常的 App 工程),是不是加载这几个测试相关的动态库,写好测试代码,就可以运行测试用例了?

细节不贴了。Demo App 引入 XCTest.framework 后业务代码里即可引入 #import <XCTest/XCTest.h> 然后就可以编写测试代码了。

写法1

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    // 测试管理集
    XCTestSuite *suite = [XCTestSuite defaultTestSuite];
    // 初始化 TestCase
    LoginUITests *loginTest = [LoginUITests testCaseWithSelector:@selector(testDidClickLoginAction)];
    
    // 添加测试用例到当前 suite
    [suite addTest:loginTest];
    
    // 遍历并运行测试用例
    for (XCTest *test in suite.tests) {
        [test runTest];
    }
}

写法2:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    // 测试管理集
    XCTestSuite *suite = [XCTestSuite testSuiteForTestCaseClass:LoginUITests.class];
    // 初始化 TestCase
    LoginUITests *loginTest = [LoginUITests new];
    
    // 添加测试用例到当前 suite
    [suite addTest:loginTest];
    
    // 遍历并运行测试用例
    for (XCTest *test in suite.tests) {
        [test runTest];
    }
}

执行时机

这种情况下,整体流程为:

graph TD
    A[测试套件启动] --> B[创建测试类实例]
    B --> C1[执行 testMethod1]
    C1 --> D1[调用 setUp]
    D1 --> E1[执行 testMethod1 本体]
    E1 --> F1[调用 tearDown]
    
    B --> C2[执行 testMethod2]
    C2 --> D2[调用 setUp]
    D2 --> E2[执行 testMethod2 本体]
    E2 --> F2[调用 tearDown]
    
    B --> C3[执行 testMethod3]
    C3 --> D3[调用 setUp]
    D3 --> E3[执行 testMethod3 本体]
    E3 --> F3[调用 tearDown]

分析:

  • 当有多个测试方法时XCTest 会为每个测试方法创建单独的类实例

  • 每个测试方法执行时都会触发完整的生命周期

    // 伪代码展示 XCTest 内部执行流程
    for (XCTest *test in allTests) {
        [test invokeTest]; // 实际执行入口
    }
    
    // invokeTest 内部实现:
    - (void)invokeTest {
        [self setUp];          // 每次测试前调用
        [self performTest];    // 执行测试方法本体
        [self tearDown];       // 每次测试后调用
    }
    
  • 三个测试方法的执行示例

    测试代码:

    - (void)testValidLogin { ... }
    - (void)testInvalidPassword { ... }
    - (void)testNetworkErrorHandling { ... }
    

    实际执行顺序

    // 测试1
    [LoginUITests setUp];
    [LoginUITests testValidLogin];
    [LoginUITests tearDown];
    
    // 测试2
    [LoginUITests setUp];          // 全新状态!
    [LoginUITests testInvalidPassword];
    [LoginUITests tearDown];
    
    // 测试3
    [LoginUITests setUp];          // 再次重置状态
    [LoginUITests testNetworkErrorHandling];
    [LoginUITests tearDown];
    

思考:为什么需要每次重置?

  1. 测试隔离原则
    • 防止测试间的状态污染
    • 确保每个测试都是独立可重复的
  2. 资源管理
    • 每次 tearDown 释放测试占用的资源
    • 避免内存泄漏累积
  3. 环境一致性
    • setUp 确保每次测试初始条件相同
    • 不受前次测试副作用影响

QA单独执行每个测试方法都会走 setup 和 teardown。按了快捷键 command + u运行所有的测试 case会执行几次 setup比如 Login 有3个测试 case。点击后执行流程是什么样的

sequenceDiagram
    participant X as XCTestRunner
    participant C as LoginUITests Class
    participant I1 as 实例1 (testA)
    participant I2 as 实例2 (testB)
    participant I3 as 实例3 (testC)
    
    X->>C: 调用 +[LoginUITests setUp] (类方法)
    activate C
    
    X->>I1: 创建实例1 (testA)
    activate I1
    I1->>I1: -setUp (实例方法)
    I1->>I1: -testA (测试方法)
    I1->>I1: -tearDown (实例方法)
    deactivate I1
    
    X->>I2: 创建实例2 (testB)
    activate I2
    I2->>I2: -setUp (实例方法)
    I2->>I2: -testB (测试方法)
    I2->>I2: -tearDown (实例方法)
    deactivate I2
    
    X->>I3: 创建实例3 (testC)
    activate I3
    I3->>I3: -setUp (实例方法)
    I3->>I3: -testC (测试方法)
    I3->>I3: -tearDown (实例方法)
    deactivate I3
    
    X->>C: 调用 +[LoginUITests tearDown] (类方法)
    deactivate C

分析:

  • 类级别初始化(只执行一次)。+[LoginUITests setUp] 类方法
    • 所有测试开始前执行一次
    • 适合做全局初始化(如启动模拟服务器)
    • 执行频率1次/测试类
  • 每个测试方法的独立执行。对于每个测试方法testA, testB, testC
    • 创建新的测试类实例
    • -setUp 实例方法(每个测试方法前执行)
    • 执行测试方法(如 -testA
    • -tearDown 实例方法(每个测试方法后执行)
  • 类级别清理(只执行一次)。+[LoginUITests tearDown] 类方法
    • 所有测试结束后执行一次
    • 适合做全局清理(如关闭模拟服务器)
    • 执行频率1次/测试类

六、精准测试

精准测试是最近很火的一个概念,但是也不算在概念阶段,很多公司都落地并实施了精准测试。单测是开发者为了方法级别写的测试用例。精准测试是代码级别的测试覆盖。

价值:

  • 协助研发小伙伴发现问题和漏测、感知 QA 同学测试范围
  • 测试人员感知研发同学代码质量,反向驱动研发侧质量把控,增加质量把控抓手
  • 在产品交付流程中多个环节赋能质量

比如目前都是产品设计师去设计测试用例,那么测试用例全不全是一回事,这个时候就需要业务老司机去参与测试用例的评审,并给出一些建议和测试 case 的补充。

另外假设有了n条测试用例那这 n 条测试用例,能不能覆盖开发同学写的每一行代码?可能有些人会好奇单测不是保证了方法被覆盖了吗?单测主要针对逻辑方法,可是用户使用的产品是经过逻辑运算后,还有一部分逻辑是 UI 层的,可能拿到 Model 后在界面展示的地方,走了几个 UI 方法,数据就不对了,这时候可能就是线上 bug 产生的原因。所以这一环节,精准测试可以去发现并解决。

精准测试是拿当前开发的代码和上一次基准分支的代码进行比较判断变动了m行如果产品设计师提供的测试用例只覆盖了其中的n行那么剩下的 m-n 行就是风险代码。要催促补充测试用例,将这剩下的 m-n 行代码覆盖完全,这样的代码才是稳定的、可靠的。

下面是之前在有赞开发完精准测试系统后落地到一个业务项目中取得的价值帮助2位 QA 发现漏测的代码,倒逼 QA 去设计更完善的测试 case、分析覆盖率低是开发者的兜底代码太多还是真的漏掉了业务 case。

精准测试助力业务,质量更加稳定。

精准测试怎么实现?核心问题是 iOS 侧开发语言有 OC、Swift分别对应不同的编译器clang、swiftc插桩手段不一样。具体实现原理和细节可以看这篇文章精准测试最佳实践

七、 测试经验总结

TDD 写好测试再写业务代码BDD 先写实现代码,再写基于行为的测试代码。另一种思路是没必要针对每个类的私有方法或者每个方法进行测试,因为等全部功能做完后针对每个类的接口测试,一般会覆盖据大多数的方法。等测试完看如果方法未被覆盖,则针对性的补充 Unit Test

目前UI 测试appium 还是建议在核心逻辑且长时间没有改动的情况下去做,这样子每次发版本的时候可以当作核心逻辑回归了,目前来看价值是方便后续的迭代和维护上有一些便利性。其他的功能性测试还是走 BDD。

对于类、函数、方法的走 TDD老老实实写 UT、走 UT 覆盖率的把控。

UITesting 还是建议在核心逻辑且长时间没有改动的情况下去做,这样子每次发版本的时候可以当作核心逻辑回归,目前来看价值是方便后续的迭代和维护上有一些便利性。例如用户中心 SDK 升级后当时有了UITesing基本上免去了测试人员介入。

如果是一些活动页和逻辑经常变动的,老老实实走测试黑盒...

我觉得大家一直有个误区,就是觉得软件测试是为了质量额外做的功,这部分功是有用功还是无用功是不一定的。其实,有了正确的开发姿势后(那什么叫正确的开发姿势?设计一个函数的时候就应该想好该方法如何更方便的被测试),最后可以可以实现事半功倍的效果,良好的质量都是附送的,也就是说测试先行是让开发更快更好的展开。

测试占比

WWDC 这张图也很清楚UI 其实需要的占比较小,还是要靠单测驱动。