单元测试学习笔记

单元测试学习笔记

[toc]

理论知识

为什么需要单元测试?

  • 迫使从调用者的角度去思考,从而优化代码结构使其可测试性高
  • 更细致地去测试代码,提高代码的健壮性

单元测试(Unit Test)

一个单元测试是一段测试代码,这段测试代码调用生产代码的一个工作单元(如一个方法),并检验该工作单元的一个具体的最终结果。

如果关于这个最终结果的假设不成立,那这个单元测试就失败了。

最终结果(Final Result)

上面提到工作单元的概念,一个工作单元一般会产生以下三种最终结果的一种:

  1. 返回一个值
  2. 改变了被测系统的某个状态(例如,某个全局变量,或类的某个成员数据)
  3. 调用了一个其他工作单元(例如,某个第三方接口,或类的某个其他方法)

框架简介

XCTest

XCTest 是官方提供的测试框架,使用它可以为 Xcode 项目编写单元测试,同时也可以进行性能测试和 UI 测试。其基本内容有:

  • XCTestCase 用于定义测试用例、测试方法和性能测试的基类。
  • XCTAssertXCTAssertEqualXCTAssertNotEqual 等宏为官方提供的测试断言,通过判断代码执行期间是否满足某些条件,来检查测试方法中的预期值和结果。

XCTest官方文档

XCTest工具类提供了一系列的断言方法,主要分 5 类:

  • 无条件失败 XCTFail
  • 布尔断言 XCTAssertTrueXCTAssertFalse
  • 相等断言 XCTAssertEqualXCTAssertEqualObjectsXCTAssertGreaterThan
  • 空值断言 XCTAssertNilXCTAssertNotNil
  • 异常断言 XCTAssertThrows

在编写断言语句时,应避免滥用 bool 判断断言方法,尽可能避免自行判断

例如:XCTAssertTrue(1 == 2)应该写成XCTAssertEqual(1, 2),这样当报错的时候,后者可以更好告诉我们错误信息是什么

注意:用例的执行顺序并不是按照用例名字典顺序或我们编写用例顺序执行的,而类似于乱序执行,因此我们应保证每个测试用例相互独立且稳定,用例每次的执行都不受其他用例或执行顺序的影响

setUp 实例函数,通常用来做一些所有测试用例都需要执行的准备动作,例如准备 Mock 数据、初始化环境等
tearDown 实例函数,通常用来做一些所有测试用例都需要执行的回收动作,例如资源回收、变量重置等

有时候我们会遇到单独执行每个用例都是通过的,但执行整个测试类就会出现失败的情况,通常都是因为静态变量、单例、环境等未做好重置回收或初始化工作,导致用例间相互影响造成的

setUptearDown 前面一直提到实例方法,为什么一直强调实例方法呢,因为它俩还有同名类方法!

+ (void)setUp;
+ (void)tearDown;

实例方法是用于标识测试类每个用例执行前和执行后所执行的函数,而类方法是用于标识测试类所有用例执行前和执行后所执行的函数

执行顺序:

OCMock

OCMock 是一个用于为 iOS 或 MacOS 项目配置 Mock 的测试框架。其基本内容有:

  • OCMClassMock() 进行 Mock 对象,进行代替真实对象。
  • OCMStub() 指定调用方法要返回的内容。

OCMock官方文档

类Mock

id classMock = OCMClassMock([SomeClass class]);

协议Mock

id protocolMock = OCMProtocolMock(@protocol(SomeProtocol));

严格的类和协议Mock

默认的 mock 方式是nil (方法调用的时候返回nil或者是返回正确的方法)

严格的模式下,mock 的对象在调用没有被 stub (置换)的方法的时候,会抛出异常

id classMock = OCMStrictClassMock([SomeClass class]);
id protocolMock = OCMStrictProtocolMock(@protocol(SomeProtocol));

部分Mock

id partialMock = OCMPartialMock(anObject)

这样创建的对象在调用方法时:

  • 如果方法被stub,调用stub后的方法
  • 如果方法没有被stub,调用原来的对象的方法

partialMock 对象在调用方法后,可以用于稍后的验证此方法的调用情况(被调用,调用结果)

观察者Mock

id observerMock = OCMObserverMock();

这样创建的对象可以用于观察/通知.

Mock创建对象方法

在实际开发过程中,经常会出现 A 对象依赖 B 对象,但 B 对象不是通过依赖注入的方式进行创建,而是在 A 对象内部进行创建 B 对象场景。

什么是依赖?

对象A持有了对象B,我们就可以说对象A依赖对象B,或者对象B是对象A的一个依赖。一个对象需要的依赖越多,该对象耦合度越高,也就越难解耦

什么是依赖注入?

依赖注入(Dependency Injection) 是面向对象编程的一种设计模式,用来减少代码之间的耦合度。通常基于接口来实现,也就是说:不需要亲自new一个对象,而是通过相关的控制器来获取对象

注入的方式

依赖注入的方式常见有两种:构造方法注入和属性注入

对于这种情况,我们也可以使用 OCMock 框架使得 A 对 B 进行隔离依赖,但这里需要注意两种情况:

  1. B 对象是采用 init 方法进行创建,需要通过 mock alloc 方法,保证 init 创建对象的结果是 mock 对象,示例代码如下

    id balanceMock = OCMClassMock(FTBalance.class);
    OCMStub([balanceMock alloc]).andReturn(balanceMock);
    FTBalance *balance1 = [[FTBalance alloc] init];
    
    /// 我们可以发现两个变量为同一个对象
    XCTAssertEqualObjects(balanceMock, balance1);
  2. B 对象是采用 initWithXxx 方法创建,需要同时 mock alloc 方法和其他 initWithXxx 方法,才能保证 init 创建对象的结果是 mock 对象,示例代码如下

    // 由于 FTBalance 具有 initWithMoney: 初始化方法
    id balanceMock = OCMClassMock(FTBalance.class);
    OCMStub([balanceMock initWithMoney:0]).andReturn(balanceMock);
    OCMStub([balanceMock alloc]).andReturn(balanceMock);
    FTBalance *balance2 = [[FTBalance alloc] initWithMoney:0];
    
    /// 我们可以发现两个变量为同一个对象
    XCTAssertEqualObjects(balanceMock, balance2);

这里提到了 alloc 例子,OCMock 也可以对 alloc, new, copy 等方法进行 mock。

Mock方法忽略非对象参数

我们可以使用 ignoringNonObjectArgs() 方法,忽略其方法具体非对象参数,保证所有的传参具有统一的结果,具体代码如下:

OCMStub([balanceMock payAmount:0]).ignoringNonObjectArgs().andReturn(NO);

上面的解决方案是针对非对象参数情况,但实际上我们还会遇到其他传参,如对象传参,接下来我们来看下这些情况需要怎么处理。

mock 方法其他参数约束

行为验证

OCMVerify 可以验证某个方法的调用情况

  • OCMVerify([mock someMethod]): 验证 someMethod 方法是否被调用过
  • OCMVerify(times(n), [mock doStuff]): 验证 doStuff 方法是否被调用过 n 次
  • OCMVerify(never(), [mock doStuff]): 验证 doStuff 方法是否从没有调用过,其实这里 never() 相当于 times(0)
  • OCMVerify(atLeast(n), [mock doStuff]): 验证 doStuff 方法是否最少被调用过 n 次
  • OCMVerify(atMost(n), [mock doStuff]): 验证 doStuff 方法是否最多被调用过 n 次
  • OCMVerifyAllWithDelay(balanceMock, 1): 延迟行为验证,数值 1 (表示为 NSTimeInterval)是模拟将等待的最大值。它通常会在满足预期后立即返回

mock 方法的 block 参数

通过 andReturn() 函数可以 mock 一个方法的返回值,但存在一个场景,方法执行的结果最终会调用某个 block,这个场景在异步/网络中经常出现。

/// invokeBlockWithArgs: 方法第一个参数为 @"First arg"。第二个参数为 [NSNull null],表示对象值为 nil。第三个参数 nil 代表后面没有更多参数
/// 特别注意:这里有 [OCMArg invokeBlockWithArgs:...] 会被一个括号括起来!!!
OCMStub([mock someMethodWithBlock:([OCMArg invokeBlockWithArgs:@"First arg", [NSNull null], nil])]);

/// 生产代码
/// 当生产代码执行到此方法,且该方法已 mock,block 可立刻同步执行,并响应 @"First arg" 和 nil 这两个传参
[self someMethodWithBlock:^(NSString *string, NSObject *obj) {
    /// 此时 string 值为 @"First arg",obj 值为 nil
}];

OCHamcrest

OCHamcrest 简单理解是一个测试断言库,提供了大量匹配断言语句,大大丰富了断言的类型,方便我们编写富有表现力且灵活的单元测试。其基本内容有:

  • 提供方便对象断言的相关断言语句,如conformsToequalToinstanceOf,对于容器、数值、文本、逻辑等还有更多丰富的断言语句

OCHamcrest官方文档

OCHamcrest 主要提供了以下内容:

  • 匹配器仓库,拥有丰富的预定义匹配器(如 equalTocloseTo 等),可用于声明规则以检查给定对象是否匹配
  • 支持自定义匹配器(如可自定一个父类匹配器,isSuperClassOf:

OCHamcrest 的函数实际上是用“HC”包前缀(例如HC_assertThatHC_equalTo)声明的,以避免名称冲突。但为了使编写速度更快、代码更清晰,默认情况下提供了可选的简短语法。例如,不用写HC_assertThat,只需写assertThat

若需要关闭简写模式,只需要声明HC_DISABLE_SHORT_SYNTAX宏即可

Object - 对象

OCHamcrest 框架针对对象类型提供了以下匹配器:

  • conformsTo - 该对象是否遵循了给出的协议
  • equalTo - 该对象是否与参数对象相同
  • hasDescription - 允许使用文本规则对给出的一段文本与该对象的描述进行匹配
  • hasProperty - 该对象是否含有给出的属性
  • instanceOf - 是给出的类的实例,或是给出的类子类的实例
  • isA - 是给出的类的实例,不同于 IsInstanceOf,无法匹配子类实例
  • nilValue,notNilValue - 分别匹对空和非空
  • sameInstance - 与给出的对象是同一个实例
  • throwsException - 该对象是否捕获到异常
  • HCArgumentCaptor - 匹配任何,捕获所有值

String - 字符串

OCHamcrest 框架针对字符串类型提供了以下匹配器:

  • containsSubstring - 匹配某个字符串是否包含某子串的规则
  • endsWith - 匹配某个字符串结尾是否包含某子串
  • equalToIgnoringCase - 匹配某个字符串,但忽略大小写
  • equalToIgnoringWhitespace - 匹配某个字符串,但忽略空格
  • startsWith - 匹配字符串是否已某子为开头
  • stringContainsInOrder, stringContainsInOrderIn - 匹配文本是否包含一定顺序的若干文本片段

Collection - 集合

OCHamcrest 框架针对集合类型提供了以下匹配器:

  • contains, containsIn - 断言包含
  • containsInAnyOrder, containsInAnyOrderIn - 断言乱序包含(数量必须相同)
  • containsInRelativeOrder, containsInRelativeOrderIn - 断言包含相对顺序项目的集合
  • everyItem - 断言每个 item 都满足某个条件
  • hasCountOf - 断言集合与给定数量的元素
  • hasEntries - 断言包含多个键值对
  • hasEntriesIn - 断言字典与字典中的键值对
  • hasEntry - 断言包含键值对的
  • hasItem - 断言给定的项目出现在和集合中
  • hasItems, hasItemsIn - 断言给定的项目以任何顺序出现
  • hasKey - 断言字典存在某个 key
  • hasValue - 断言字典存在某个 value
  • isEmpty - 断言空集合
  • isIn - 断言对象在给定集合中
  • onlyContains, onlyContainsIn - 断言集合的项目出现在给定列表中

Number - 数字

OCHamcrest 框架针对数字类型提供了以下匹配器:

  • closeTo - 匹配靠近给予的数值
  • greaterThan, greaterThanOrEqualTo, lessThan, lessThanOrEqualTo - 匹配比较数字大小
  • isFalse - 匹配错误(0)
  • isTrue - 匹配正确(非 0)

Logical - 逻辑

OCHamcrest 框架针对逻辑提供了以下匹配器:

  • allOf, allOfIn - 匹配满足所有条件
  • anyOf, anyOfIn - 匹配满足任意一个
  • anything - 匹配任何东西(当您不关心特定值时,在复合匹配器中很有用)
  • isNot - 否定匹配器

Decorator - 修饰

OCHamcrest 框架提供了以下修饰型匹配器:

  • describedAs - 提供匹配器一个自定义的失败描述
  • is - 促进增强阅读性

异步断言

开发过程中肯定存在一些异步执行的场景,最常见的就是网络请求,对于此,OCHamcrest 提供了异步断言机制assertWithTimeout,其将会保持验证表达式,直到匹配上或达到超时。

例如,

assertWithTimeout ( 30 , thatEventually(self.code), is( @(200)  ));

上述代码表示,在 30s 内将会一直进行匹配判断 self.code 值是否为 200,规定时间内匹配成功,则执行用例成功,否则执行失败。

自定义匹配器

OCHamcrest 支持自定义匹配器,简单来说只需要继承自 HCBaseMatcher 基类,实现-matches:-describe_to:方法即可,其中-matches:方法需要实现具体匹配逻辑,-describe_to:方法需要实现描述。

------------- 本文结束 感谢阅读 -------------