转载

Swift的世界,如何写好单元测试?

前言

Swift的世界,如何写好单元测试?

Unit Test.png

作为一名无所事事的公司蛀虫,总是想在平静的日子里搞出点事情。于是我发现,公司的网络层作为基础库竟然没有单元测试覆盖,是不是有失软件工程水准呢?于是就有了接下来的故事...

Why?

当我们做某件事情的时候,我们常常抱有强烈的目的性,那么单元测试的目的是什么呢?为什么要有单元测试呢?

遗憾的是,作为一个‘人’,我们无法控制我们想控制的事物按照预想的情况运作下去。即便是那些很厉害很厉害的开发人员,在介绍他的时候也只能说“几乎没有BUG”,而那些肉眼我们无法察觉的BUG就需要我们通过测试来发现并且修正它了。

What?

那么说了那么多,到底什么是单元测试呢?我们可以来看一下维基百科上的定义。

In computer programming, unit testing is a software testing method by which individual units of source code, sets of one or more computer program modules together with associated control data, usage procedures, and operating procedures, are tested to determine whether they are fit for use.

在计算机编程中,单元测试是一种软件测试方法,通过该方法测试各个单位的源代码,一个或多个计算机程序模块的组合以及关联的控制数据,使用和操作程序,以确定它们是否正常运行。

简单的来说,单元测试是使用程序控制的以类或者函数为单元的期望判断。比如,我们需要测试一个计算器中的加法(来自于Apple官方文档):

- (void)testAddition
{
   // obtain the app variables for test access
   app                  = [NSApplication sharedApplication];
   calcViewController   = (CalcViewController*)[[NSApplication sharedApplication] delegate];
   calcView             = calcViewController.view;
 
   // perform two addition tests
   [calcViewController press:[calcView viewWithTag: 6]];  // 6
   [calcViewController press:[calcView viewWithTag:13]];  // +
   [calcViewController press:[calcView viewWithTag: 2]];  // 2
   [calcViewController press:[calcView viewWithTag:12]];  // =
    XCTAssertEqualObjects([calcViewController.displayField stringValue], @"8", @"Part 1 failed.");
}

在这个中,我们的测试目的只有一个,那就是在加法的情况之下,进行6+2的运行,并且期望结果为8,如果期望不满足,那么Xcode就会在该断言上失败。这几乎是最简单的一个单元测试了,但是在真实的世界中,我们所碰到的情况比这复杂的多。比如,我们需要测试的方法是异步的,我们所测试的方法互相依赖,我们需要测试一个方法的性能等等,那么如何在真实的复杂情况之下编写出令人满意的良好测试呢?

命名

按照Apple官方文档,相信你能很快的新建一个项目的测试Target,当你新建一个.swift文件之后你的心中可能会突然一下颤抖,然后发出宇宙终极的三问:我在哪?我是谁?我在干什么?

Swift的世界,如何写好单元测试?

我真的好懵逼.png

是的,你在公司,你是一个死宅码农,你在写单元测试!

可是,你却迟迟不能动手写下一行代码,不是因为你不知道想要测试什么功能,你知道你想测试网络层的Get请求是否正常运作,但是你不知道该怎么样给这个测试取一个名字,就好像一个爸爸看到刚出生的baby一样手足无措。

要不就叫它func testGet()吧!

然而当你敲下方法名的定义之后,你敏锐的工程师思维开始发挥了作用,如果我的Get方法带参数怎么办?如果不带参正常运行,带参失败了怎么办?也就是说我不止需要一个Get的测试方法,那么我的命名应该如何呢?

确实,这样的测试不仅没有能够测试到应该覆盖的测试case,同时也不便于维护,他人很难通过方法命名一眼就看出你测试的意图。

那么良好的测试命名应该是怎么样的呢?

总的来说,良好的测试命名应该有如下的特点:

  • 全局测试内的命名统一。

  • 命名可以清晰的阐明测试意图。

  • 命名可以清晰的阐明测试期望以及副作用(如果有的话)。

1.Plan A

在A方案中,我们单元测试的名称将分为三部分:方法名称(method name)+ 执行测试用例的状态(state under test)+ 预期名为(expected behavior)示例如下:

/// 这是一个除法的测试,在分母为0的情况之下,我们期望抛出异常
func divide_ZeroAs2ndParam_ExceptionThrown()

可以看到,在这样的命名规范之下,他人也可以通过方法名清晰明了的知道该方法在怎样的期望输入或者状态之下会产出什么样的输出或者状态。

更加详细的关于该测试方法名的论述,大家可以看一下Roy Osherove](http://osherove.com/blog/2005/4/3/naming-standards-for-unit-tests.html)的Blog。

Tips:当我们修改了所测试的方法名字之后,原测试方法就已经偏离了命名规范,所以需要我们手动的修改测试方法。但是这样的工作明显是最无效和重复的。因此也可以这样做:我们将原来的方法名称(method name)更改成了抽象的方法名称,而不是将原来的方法名称一字不落的当做测试方法的前缀。

2. Plan B

在B方案中,我们将采用Given-When-Then的方式进行命名组织,该组织方式来源于BDD(Behavior-Driven Development)。具体的命名例子如下:

/// Given: 当前测试所给予的输入或者初始状态
/// Action: 当前测试所要进行的操作
/// Then: 当前测试所期望的输出状态或者输出
func Given_StateUnderTest_When_ActionUnderTest_Then_ExpectedOutcomes()

我们可以看到,在Given-When-Then的命名方式之下,我们满足了所有良好测试命名的特点,与此同时似乎还看起来有一些过于“啰嗦”,但是这也并不是什么大问题,毕竟清晰的意图的优先级总比简短的命名优先级更高。

总的来说,测试的命名并没有刻板的规定,只要满足自身的测试需要,满足公认的测试名称规范就可以。当然还有一些其他的命名方式,但是基本上也都是与上述的两种方法类似或者是变种。最重要的是,我们知道了命名的准则,那么我们也可以制作出属于自己的规范。

关于断言数的争论

在我跟同事关于单元测试的讨论中,同事提出单元测试最好只有一个assert,不然当测试不通过的时候无法知道具体fail在哪里。但是,具体在iOS的XCTest中,我们知道当某一个断言无法满足条件的时候,Xcode会直接卡在那个断言之上,并且告诉你不通过的原因,如下图所示:

Swift的世界,如何写好单元测试?

断言不满足.png

但是我也知道,一个单元测试的用例最好只包含一个assert这样的观点也由来已久,那么到底在编写单元测试的用例的时候该不该使用多个断言呢?

我们先来看看赞成单元测试用例只写一个断言的其他理由:

如果你在一个测试中包含了不只一个断言,则你的测试目的就不只一个。在这种情况下,测试名称变得奇怪不清晰,测试变得太长,反馈也变得不清晰;你永远无法知道哪个断言通过了,哪个断言失败了。假如你依次有三个断言。如果第一个断言失败了,则后面两个永远都不会检查。如果你修改了一些生产代码,那么当代码变化时,后面两个断言就无法发挥作用了。在这种情况下,你就会错误地认为自己的代码有安全保障和回归测试。 ---编写良好的单元测试

其实,我确实同意上述的某些观点的,比如测试的目的应当只有一个,

但是当你只有一个测试目的的时候就代表我们只能有一个断言么?我想这个推论应当是错误的。

我们可以在StackOverFlow里看到相关的讨论,其中第二个回答我深以为然,比如我们要测试所得到数值是否在一个数值区间内,我们的单元测试代码可能是这样的:

public void ValueIsInRange()
{
  int value = GetValueToTest();
  Assert.That(value, Is.GreaterThan(10), "value is too small");
  Assert.That(value, Is.LessThan(100), "value is too large");
}

在这里我们所要测试的确确实实是一个单独的目的,即“该数值是否在某个区间内”,但是很显然我们需要两个断言来分别判断数值的上界和下界。当然我们也可以通过isInRange之类的方便来将两个断言合并成一个,但是这样真的是一个好的测试用例么?当用例的失败的时候,我们只能知道该数值不在指定的范围内,但是我们甚至都不知道它是超过了上界还是下界。

综上所述,“一个单元测试最好只有一个断言”并不十分准确,或许我们应当信奉的应该是“一个单元测试应当只有一个逻辑单元,只有一个测试目的”,本着这样的宗旨,写出只有一个断言的测试应该是自然而然的事情,在需要的时候可以使用多个断言

函数式编程和单元测试

在传统的面向对象编程过程中,我们总是能会和各种各样的状态机进行交互,因为面向对象编程的核心是封装,那么我们就免不了将各种状态封装在对象的内部。然而随着软件规模的不断庞大,各种复杂的状态机也导致了难以维护、难以迭代和难以测试的问题。

那么具体在单元测试当中,状态机又是怎样拖累我们的测试的呢?又为什么说纯函数的方法便于单元测试呢?

首先我们需要搞懂什么是副作用:

In computer science, a function or expression is said to have a side effect if it modifies some state outside its scope or has an observable interaction with its calling functions or the outside world besides returning a value.

在计算机科学中,如果一个函数或表达式修改某个超出其范围的状态,或者除了返回一个值之外还有一个与其调用函数或外部世界的可观察的交互,这个函数或表达式会产生副作用。 -------------- from wikipedia

反过来说,无副作用的函数是指不会对外部作用域产生影响并且函数的作用是恒定不变的。

对于单元测试而言,很明显无副作用的函数更加容易测试,函数式编程的每个单元函数更加符合“单一职责”,而“单一职责”的函数则契合了单元测试里"测试的目的应当只有一个"的准则。

举个例子,如下有一个非纯函数的场景(impure function):

class Person {  
    var friends: [String] = []
    func addFriend(_ name: String) {
        self.friends.append(name)
    }
}
class PersonTest: XCTestCase {
    let me = Person()
    func testAddFriend() {
        me.addFriend("jason")
        XCTAssert(me.friends == ["jason"])
    }
}

我们可以看到,上述代码段的写法是经典的面向对象思想下的写法。我们在测试的过程中创建了一个Person的实例对象me,然后在testAddFriend方法中测试添加朋友的这一个操作是否正确执行。然而这样简单的操作却存在着很大的“副作用”,首先,在执行操作的时候我们并不知道之前是否已经存在friends,如果存在了之前已经存在过friends,那么这里的断言将会失败,其次在addFriends所产生的副作用也会影响之后的单元测试,可能会导致之前好好的单元测试用例发生不可预计的错误。

那么,经过无副作用的函数应该是怎么样的呢?在这里推荐一下onevcat关于单向数据流控制器的文章,在那里会有更加清晰易懂的纯函数式的例子。在本篇文章中,主要为了更加简单的展示“纯函数”对测试的作用,因此也是一些比较简单的改造,大概如下所示:

class Person {
    var state: State = State(friends: [])
    struct State {
        let friends: [String]
        /// other state stuff ...
    }
    enum Action {
        case addFriend(String)
        /// other action stuff ...
    }
    lazy var reducer: (State, Action) -> State = { (state: State, action: Action) in
        var internalState = state
        switch action {
        case .addFriend(let name):
            internalState = State(friends: state.friends + [name])
        }
        return internalState
    }
    func dispatch(_ action: Action) {
        let previousState = state
        let nextState = reducer(state, action)
        state = nextState
    }
}
class PersonTest: XCTestCase {
    let me = Person()
    func testAddFriend() {
        let initState = Person.State(friends: [])
        let newState = me.reducer(initState, .addFriend("jason"))
        /// 在这里的测试没有对外部变量产生任何副作用
        XCTAssert(initState.friends == ["jason"])
    }
    func testOtherMethod() {
        /// 其余的测试可以安全的进行,me不会受到不安全的变动
    }
}

我们可以看到,经过简单的函数式改造之后,测试函数就可以异常的纯粹,测试用例也将清晰明了。所以,当你发先自己的单元测试无法进行下去,各种corner case越来越多,各种状态纷繁杂乱的时候,或许是时候考虑一下减少副作用,使用函数式的方法来改造我们的生产代码,将自己解放出来。

虽然纯函数式的编程有这样那样的好处,但是遗憾的是,在实际的编程开发中,我们总是不可避免的产生副作用。诸如:修改全局变量,修改静态变量,修改inout入参,抛出异常,I/O操作,调用其他的具有副作用的函数等等。那么我们需要做的是,将不可避免的副作用限制在可控的范围之内,如果在程序中,所有的函数都在任意的作用域内随意穿梭,那么代码将陷入维护和迭代的黑洞,永世不得翻身。

Stubs and Mock

假设我们需要测试一个网络层,诚然,我们也可以使用https://httpbin.org/的开放接口进行测试,但是这样的测试有一些问题:

  • 测试返回时间的不确定性,不能够快速测试

  • 测试依赖外部环境,测试数据不稳定

  • 难以模拟一些corener case和错误返回,难以提升测试覆盖率

基于以上几点原因,一个比较好的办法就是Mock数据。在OC的时代,由于OC是动态的语言,所以我们有一个非常强大的库--OC,我们可以依赖runtime轻松的fake出想要的数据来进行单元测试。

当然,来到了Swift时代之后,runtime的方法就行不通了,但是我们依旧可以使用自定义的URLProtocol来实现Mock,比较不错的开源项目比如Mockingjay,使用它我们就可以非常简单的完成网络层的Mock。

Quick Check

如果你学过Haskell,那么你大概率听说过Quick Check。在上一小节中,我们知道在某些时候我们需要通过Mock的技术来伪造数据,但是我们难道就止步于此了么?

One more thing...

例如,当我们需要测试一个除法的时候,我们编写了如下的代码:

func testDivision() {
    XCTAssert(1.divide(a: 1) == 1)
}

嗯,这样的测试用例很简单,我们输入了[(1,1)]作为测试的输入集,当进行单元测试的时候,我们总是能得到成功的测试结果,但是很明显,当分母为0这样的重要的数据边界条件的时候程序就会出现错误。当然,上述的例子还只是一个极其纯粹的单元测试,在真实的软件环境当中,我们将遇到的问题将更加复杂。

在无穷的测试集中找到最小的最高效的测试集几乎是单元测试最难的部分。

有限的人力人脑和无限的测试集将是永恒的矛盾,所以人们便想出了类似Quick Check的这样的随机数据生成器。主要的思想就是,通过你给定的数据范围和类型限定,程序自动为你生成相关的数据来进行测试,当然啦万能的Github已经有人实现过了--typelift/SwiftCheck

具体的相关使用并不想浪费篇幅来讲,其实更让我在意的是它的局限。

确实,我们现在可以根据给定的类型或者是范围来随机生成测试集,我们可以依靠机器的蛮力来进行这样暴力的测试,但是它真的带给了我们有效的测试么?它真的带给了我们高效的测试么?

不可替代的人力

Quick Check确实给了我们解决问题的一个新的视野,但是它也有始终无法突破的局限。例如上述的例子,我们确实可以通过Quick Check随机快速的生成测试数据集,但是“随机”与其说是它的优点,不如说是它的劣势。

“随机”是机器无奈的选择,是程序的妥协。即便你的机器再快,在无限的测试集面前依然无限趋近于0,机器无法思考到分母不能为0,类似这样的策略和思考过程正是人脑所擅长的。

我们总觉得依靠“点点点”的测试人员很“Low”,甚至于我们总是希望这样的测试人员被淘汰出局。但是我们要知道,“点点点”测试人员的价值并不在于灵活的手指,而在于灵活的思考策略。经验丰富的测试人员,总能在无限的测试集中找到最有效高效的子集,从而保证绝大多数的情况之下的软件质量。

当然,如果AI可以解决这样的策略问题当然最好,但目前来看“人工智能”还是蠢得可怕。

结语

“测试是为发现错误而执行程序的过程”。 ---- 《单元测试的艺术》

在资本洪流之下,中国的互联网公司普遍生活在恐慌之下,唯恐被市场淘他们加班加点,或小步或大步的跑着。面对这产品飘忽不定的需求,技术人是否还能保持一颗匠人之心。

十几年前,我们在雨后的泥地里玩着泥巴,乐此不疲。母亲气呼呼的过来把我拖走,“还在玩?还不去做作业,玩这个有什么用!”。我恋恋不舍的看着我用泥巴建起的王国。

“这很有用”。

感谢参考

编写良好的单元测试

iOS Unit Testing and UI Testing Tutorial

Real World Mocking in Swift

正文到此结束
Loading...