快速检查(QuickCheck)(译)

source: http://www.jianshu.com/p/bb93972bac53


快速检查(QuickCheck)(译)

2016.02.01 00:26* 字数 4778 阅读 239 评论 0

本文译自 objc.io出品的书籍《Functional Programming in swift》第六章,objc.io 由 Chris Eidhof, Daniel Eggert Florian Kugler 成立于柏林。成 objc.io 的目的是针对深入的、跟所有 iOS 和 OS X 开发者相关的技术话题创造一个正式的平台。objc .io 出过24期的期刊,每一期都针对特定的主题提出解决方案, 可以到objc中国查看这些文章的中文版本。本书延续了 objc.io 一贯的风格,讲解得很深入,可惜译者水平有限,无法将书中的精彩之处忠实的表达出来。 想购买正版书籍请到
https://www.objc.io/books/functional-swift/

近年来,测试在Object-C中越来越普遍。许多流行的库都可以使用持续化集成工具自动进行测试。编写单元测试的标准框架是 XCTest。另外,还可以使用许多第三方框架(例如Specta,Kiwi和FBSnapshotTestCase。几个针对swift的测试框架现在也在开发中。

所有这些框架都遵循类似的模式:测试由一些代码片段以及一个期望的值组成。执行该代码片段,将执行结果和预期的值相比较。不同的库测试的级别也不同----一些测试单独的函数,一些测试类,还有一些执行集成测试(运行整个程序)。在本章中,我们为swift函数创建了一个小型的基于属性的测试库。

编写单元测试时,输入数据是静态的,由程序员进行定义。例如,使用单元测试测试addition方法,我么可能编写测试来核实 1+1是否与2相等。如果更改之后的addition函数运行的结果不为2,测试就会失败。还可以更进一步,我们可以测试加法运算的交换律---也就是说, 测试 a+b 和b+a相等。要测试这一点,我们需要编写一个测试case验证 42+7 等于7+42

QuickCheck是用于随机测试的Haskell库。QuickCheck不用编写独立的单元测试代码(单元测试是测试一个函数对于一些输入是否能输出正确的结果),而是允许你描述函数的抽象属性并生成测试改变这些属性。在本章中,我们建立QuickCheck的swift版本。

最好通过例子进行讲解。假设我们想验证加法符合交换律。要验证这一点,对于整数x, y,我们编写一个程序检查x+y 是否等于 y+x。

func plusIsCommutative(x: Int, y: Int) -> Bool { 
    return x + y == y + x
}

使用QuickCheck验证交换律和调用check函数一样简单

check("Plus should be commutative", plusIsCommutative) 

> "Plus should be commutative" passed 100 tests.
> ()

check函数通过一次次的使用两个随机的整数调用plusIsCommutative来完成功能。如果结果不为true,它会打印出引起测试失败的两个输入参数。这里的关键是我们能够使用一个返回布尔型的函数(如plusIsCommutative)来描述代码中的抽象属性(如交换律)。check函数使用这个属性生成单元测试,比你自己手写的单元测试代码拥有更好的覆盖率。

当然,并不是所有的测试都能通过。例如,我们编写一条语句判断减法是否符合交换律:

func minusIsCommutative(x: Int, y: Int) -> Bool { 
    return x - y == y - x
}

运行QuickCheck作用于这个函数,测试会失败:

check("Minus should be commutative", minusIsCommutative)

> "Minus should be commutative" doesn't hold: (0, 1)
> ()

使用swift尾随闭包的语法,我们也可以直接编写测试,而不用再定义属性。

check("Additive identity") { (x: Int) in x + 0 == x } 

> "Additive identity" passed 100 tests.
> ()

当然,还有其他相似的标准算术运算的属性可以测试,我们会介绍更有趣的测试和属性。在这之前,我们将介绍QuickCheck是怎样实现的

创建QuickCheck

要实现QuickCheck的swift版本,需要完成以下的事情

  • 首先,需要一种方法生成不同类型的随机值
  • 使用随机值生成器,我们需要实现check函数,并使用合适的方式将生成的随机数传递改改函数
  • 如果测试失败,我们需要使测试失败的输入尽可能的小。比如,如果使用含有100个元素的数组测试时失败了,我们需要试一下更小的数组会不会导致失败
  • 最后,我们需要做一些额外的工作确保check函数可以作用在通用类型上。

生成随机数

首先,定义一个协议用于产生随机值。这个协议只包含一个函数,arbitrary,返回类型的Self的值,比如,实现Arbitrary协议的类或者结构体:

protocol Arbitrary {
    class func arbitrary() -> Self
}

我们编写Int类型的一个实现。我们使用标准库的arc4random函数并将其返回值强转为Int型。这一这只能生成正整数。真正的实现需要产生负数和整数,但是本章我们让事情尽可能简单。

extension Int: Arbitrary {
    static func arbitrary() -> Int {
        return Int(arc4random()) 
    }
}

现在就可以产生随机整数了:

Int.arbitrary()
> 2158783973

要生成随机的字符串,我们需要做更多的工作。我们首先产生随机的字符:

extension Character: Arbitrary {
    static func arbitrary() -> Character {
        return Character(UnicodeScalar(random(from: 65, to: 90))) 
    }

    func smaller() -> Character? { return nil } 
}

然后,我们生成长度为0 ~ 40 ----x-----的随机字符串----使用下面定义的random函数。然后我们生成x个随机的字符,然后将这些字符reduce为一个字符串。注意我们只生成了大写的字符串。在产品性质的库中,需要产生更长的随机字符串。

func tabulate<A>(times: Int, f: Int -> A) -> [A] { 
    return Array(0..<times).map(f)
}

func random(#from: Int, #to: Int) -> Int {
    return from + (Int(arc4random()) % (to-from))
}

extension String: Arbitrary {
    static func arbitrary() -> String {
        let randomLength = random(from: 0, to: 40)
        let randomCharacters = tabulate(randomLength) { _ in
            Character.arbitrary() 
        }
        return reduce(randomCharacters, "") { $0 + String($1) } 
    }
}

tabulate函数首先使用 0 到times-1 填充一个数组,然后使用map函数得出一个f(0), f(1), ..., f(times-1)填充的数组。String的arbitrary扩展使用tabulate函数生成一个随机的字符串数组。
和生成Int随机数的调用方式相同:

String.arbitrary()
> XMVDXQEIRYNRJTWELHESXHIGPSPOFETEEX

实现check函数

现在准备实现check函数的第一个版本。check1函数包含一个循环,这个循环的每一个迭代均产生随机的输入,用于作为参数属性。如果发现了反例,该随机数被打印出来,循环结束,函数返回。如果没有发现反例,check1函数货报告通过了测试(注意我们将函数命名为check1,因为我们稍后就要编写该函数的最终版本)

func check1<A: Arbitrary>(message: String, prop: A -> Bool) -> () { 
    for _ in 0..<numberOfIterations {
        let value = A.arbitrary() 
        if !prop(value) {
            println("\"\(message)\" doesn't hold: \(value)")
            return
        } 
    }
    println("\"\(message)\" passed \(numberOfIterations) tests.") 
}

我们本来还可以使用reduce 或者 map这种更函数式的方式编写这个函数,而不是使用for循环。在这个例子中,使用for 循环十分合适:我们想要执行一个操作固定的次数,并且在找到反例时退出循环----使用for循环简直完美。
下面演示怎样使用这个函数来测试属性:

func area(size: CGSize) -> CGFloat { 
    return size.width * size.height
}

check1("Area should be at least 0") { size in area(size) >= 0 }

> "Area should be at least 0" doesn't hold: (-459.570969794777,4403.85297392585) 
> ()

这个例子很好的说明了QuickCheck的有用之处---它可以帮助我们发现边界条件。如果size的width和height其中一个为负数,那么area函数酒会返回负数。当作为CGRec的一部分时, CGSize可以为负数。如果编写传统的单元测试代码,很容易漏掉这种情况,因为size通常都是正数。

让值变的更小

当使用check1检测字符串时,有可能会返回很长的错误消息:

check1("Every string starts with Hello") { (s: String) in 
    s.hasPrefix("Hello")
}

> "Every string starts with Hello" doesn't hold: MAEYXBOKFDUALXOLSQTEWJEQNAP 
> ()

理想状态下,失败的输入越短越好。通常,反例越小,越容易发现问题出自哪儿。在这个例子中,这个反例仍然很易于理解---但并不总是这样。想想一个测试数组或者字典的复杂的例子---如果测试失败,可能很难定位问题----如果作为输入参数的数组或者字典简短一些,问题会更易于跟踪。通常,用户可以试着裁剪触发测试失败的输入并尝试重新运行测试---然而,我们可以让用户省下这些麻烦,我们自动进行这个过程。

要做到这一点,需要实现另一个协议,称为Smaller。这个协议只做一件事情--它会视图缩短反例

protocol Smaller {
    func smaller() -> Self?
}

注意smaller的返回值时可选类型。当不知道如何更进一步的缩短反例时,返回nil。例如,没有办法缩短一个空的数组,在这种情况下,返回nil

在我们的例子中,例如整数,我们会将该值处以2直到0为止。

extension Int: Smaller { 
    func smaller() -> Int? {
        return self == 0 ? nil : self / 2 
    }
}

现在可以测试了:

100.smaller()
> Optional(50)

对于字符串,我们丢弃第一个字符(除非是空字符串):

extension String: Smaller { 
    func smaller() -> String? {
        return self.isEmpty ? nil : dropFirst(self)
    } 
}

要在check函数中使用check函数,我们需要能够缩短check产生的任意一个测试数据。我们重新定义Arbitrary,让它继承自Smaller协议:

protocol Arbitrary: Smaller { 
    class func arbitrary() -> Self
}

重复缩短(Repeatedly Shrinking)

现在我们重新定义check函数使得它能够缩短触发测试失败的输入数据。我们使用iterateWhile函数,该函数以返回布尔型的函数(condition) 和初始值为参数,并重复执行这个函数,直到条件为假。

func iterateWhile<A>(condition: A -> Bool, initialValue: A,
        next: A -> A?) -> A {
    if let x = next(initialValue) { 
        if condition(x) {
            return iterateWhile(condition, x, next) 
        }
    }
    return initialValue 
}

使用iterateWhile,我们以重复缩短反例的长度知道测试通过

ffunc check2<A: Arbitrary>(message: String, prop: A -> Bool) -> () { 
    for _ in 0..<numberOfIterations {
        let value = A.arbitrary() 
        if !prop(value) {
            let smallerValue = iterateWhile({ !prop($0) }, value) { 
                $0.smaller()
            }
            println("\"\(message)\" doesn't hold: \(smallerValue)") 
            return
        } 
    }
    println("\"\(message)\" passed \(numberOfIterations) tests.") 
}

这段代码多了许多的工作:产生随机输入值,检查它们是否满足property条件,如果发现反例,就重复的缩短这个反例。之所以定义iterateWhile函数,而不是直接使用while循环,是为了使控制流程尽可能简单。

随机数组

到现在为止,check2函数只支持Int和String。我们可以定义其它类型的扩展,比如布尔型。生成随机数组的工作变得复杂起来。让我们写一个实用版本的例子用来激励大家:

func qsort(var array: [Int]) -> [Int] {
    if array.isEmpty { return [] }
    let pivot = array.removeAtIndex(0)
    let lesser = array.filter { $0 < pivot }
    let greater = array.filter { $0 >= pivot } 
    return qsort(lesser) + [pivot] + qsort(greater)
}

同时编写一个属性检查我们编写的qsort版本和编译器内建的sort函数是否具有相同的功能:

check2("qsort should behave like sort") { (x: [Int]) inreturn qsort(x) == x.sorted(<)
}

然而,编译器会警告[Int]没有实现Arbitrary协议。在实现Arbitrary协议之前,首先实现Smaller。作为第一步,我们提供一个简化版本:去掉数组的第一个元素:

extension Array: Smaller { 
    func smaller() -> [T]? {
        if !self.isEmpty {
            return Array(dropFirst(self)) 
        }
        return nil
    } 
}

我们还可以编写一个生成随机长度数组(数组中的元素需要遵循Arbitrary协议,每个元素都是随机的)的函数:

func arbitraryArray<X: Arbitrary>() -> [X] {
    let randomLength = Int(arc4random() % 50)
    return tabulate(randomLength) { _ in return X.arbitrary() }
}

下面定义数组的扩展。这个扩展使用arbitraryArray函数生成数组的一个Arbitrary实例。然而,要要定义数组的Arbitrary实例,我们需要确保数组的每一个元素也是Arbitrary实例。例如,要产生随机数的数组,首先需要确保能够产生随机数。一般的,需要编写类似如下的函数,确保数组的元素也遵循arbitrary协议

extension Array<T: Arbitrary>: Arbitrary { 
    static func arbitrary() -> [T] {
        ... 
    }
}

不幸的是,不可能将这种限定作为一种类型约束表达出来,这使得无法编写扩展 (extension)是的数组遵循Arbitrary协议。因此,我们需要修改check2函数。

check2<A>函数的问题是它要求类型A必须实现Arbitrary协议。我们将要去掉这个约束,取而代之的,需要两个函数,smaller和arbitrary,作为参数传递给该函数。

首先定义一个辅助的结构体,该结构体包含两个我们需要的函数:

struct ArbitraryI<T> {
    let arbitrary: () -> T 
    let smaller: T -> T?
}

然后编写一个辅助型的函数,以ArbitraryI型的结构体作为参数。checkHelper函数的定义和check2函数类似,唯一的区别是arbitrary函数和smaller函数定义的位置不同。在check2函数中,是通过对通用类型进行约束(通用类型必须实现某个协议)。而checkHelper函数则是显式的使用传递进来的ArbitraryI结构体提供的函数。

func checkHelper<A>(arbitraryInstance: ArbitraryI<A>,
                prop: A -> Bool, message: String) -> () {
    for _ in 0..<numberOfIterations {
        let value = arbitraryInstance.arbitrary() 
        if !prop(value) {
            let smallerValue = iterateWhile({ !prop($0) }, value, arbitraryInstance.smaller)
            println("\"\(message)\" doesn't hold: \(smallerValue)")
            return
        } 
    }
    println("\"\(message)\" passed \(numberOfIterations) tests.") 
}

这是一种标准的技术:我们使用参数传递需要的信息,而不是通过协议来实现。这样做更加灵活,我们不在使用swift推断需要的信息,而是全盘掌握这些信息。

现在可以使用checkHelper函数重新定义check2函数了。我们可以将Arbitrary协议中的定义封装到ArbitraryI结构体中并调用checkHelper:

func check<X: Arbitrary>(message: String,
                    prop: X -> Bool) -> () {
    let instance = ArbitraryI(arbitrary: { X.arbitrary() }, 
                    smaller: { $0.smaller() })
    checkHelper(instance, prop, message) 
}

如果有一个类型,我们无法定义需要的Arbitrary实例,数组就是这样的一个例子。我们可以重载check函数并构造需要的ArbitraryI结构体:

func check<X: Arbitrary>(message: String,
        prop: [X] -> Bool) -> () {
    let instance = ArbitraryI(arbitrary: arbitraryArray,
                        smaller: { (x: [X]) in x.smaller() })
    checkHelper(instance, prop, message) 
}

现在,我们可以运行check来验证QuickSort的实现。回生成大量随机的数组并传递给我们的测试:

check("qsort should behave like sort") { (x: [Int]) in 
    return qsort(x) == x.sorted(<)
}

> "qsort should behave like sort" passed 100 tests. 
> ()

使用QuickCheck

有点违反直觉,但是有许多证据表明测试技术可以影响代码的设计。依赖于测试驱动设计(test-driven design)的工程师不仅适用测试验证它们的代码是否正确,而且会要求你使用测试驱动的方式 编写代码,代码的设计也变得简单。这很容易理解-----如果一个类不需要复杂的设置过程,很容易就可以写出测试代码,也意味着该类具有很弱的耦合性。

对QuickCheck来说,也是同样的道理。对已有的代码进行QuickCheck测试是很困难的,特别是那些已经存在的对其他类具有严重依赖的或者使用多种状态的面向对象的架构。然而,如果你使用QuickCheck进行测试驱动开发,你会发现它对代码设计的影响很大。QuickCheck强迫你考虑函数必须满足的抽象属性并允许你给出一个高层次的说明。单元测试可用验证3 + 0是否等于 0 + 3,QuickCheck可以测试出交换律更通用的情况。通过首先考虑高层的QuickCheck说明,你的代码会强调模块性以及引用透明( referential transparency)。QuickCheck很难应用于基于状态的函数或者API。结果,预先使用QuickCheck编写测试代码会让你的代码保持清洁。

下一步

这个库远没有完成,但是已经非常实用了。也就是是,还有许多需要提高的地方:

  • 缩短函数太简单了。例如,在数组的例子中,我们仅仅是去掉数组的第一个元素。然而,我们本来还可以选择移除一个不同元素,或者让数组中的元素更小一些(或者两个都做)。目前的实现返回一个可选的缩短值,然而我们也许希望生成一组值。在后面的章节中,我们将会演示如何生成一组结果,我们就可以在这里使用相同的技术了。
  • Arbitrary实例太简单。对于不同的数据类型,我们希望拥有更复杂的arbitrary实例。比如,当生成随机的枚举类型的值时,我们可以以不同的频率生成特定case的值。我们还可以生成特定约束的值,比如排好序的数组或者非空的数组。当编写多个Arbitrary实例时,定义几个辅助函数以帮助我们编写这些实例时可能的。
  • 对产生的测试数据进行归类。如果我们产生了很多长度为1 的数组,我们可以将其归类为琐碎的测试用例。Haskell库支持分类,因此它关于分类的主意可以直接拿来用
  • 我们可能需要控制产生的随机值的大小,在Haskell版本的QuickCheck中,Arbitrary协议增加了一个size参数,以限定生成的随机输入的大小。check函数首先测试下小的值,与小并快的测试对应。当越来越多的测试通过时,check函数会增加size以便尝试大的,更加复杂的反例。
  • 我们还可以以显式的种子初始化随机生成器,这样就可以重播测试用例。这可以更容易生成失败的用例。

显然,这不是所有可以改进的地方,要将其改进成一个完善的类库,还有许多大大小小的事情可以改进。

函数式编程(Swift)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值