【函式式 Swift】QuickCheck

養樂多發表於2017-04-23

QuickCheck 是一個用於隨機測試的 Haskell 工具庫,本文將基於原書中的案例以及函數語言程式設計方法討論如何構建 Swift 版本的 QuickCheck 庫。

注:在學習本章內容以前,筆者沒有學習過 Haskell,也沒有使用過 QuickCheck,本文是通過原書及一些網路資料學習後的心得,如有錯誤或遺漏,歡迎批評指正。


QuickCheck 概述

QuickCheck 專案始於 1999 年,作者 Koen Claessen 和 John Hughes 在其論文《QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs》中對測試工具應該具備的特性進行了討論,主要有以下兩點:

  1. "A testing tool must be able to determine whether a test is passed or failed; the human tester must supply an automatically checkable criterion of doing so."
  2. "A testing tool must also be able to generate test cases automatically."

這兩點還是比較容易理解的,首先,測試人員應該提供一個能夠讓測試工具自動化判斷用例是否成功的標準,然後,測試工具應該能夠基於該標準自動化生成測試用例,以便於對應用程式進行隨機測試。

此外,作者還提到 QuickCheck 的一個重要設計思想:"An important design goal was that QuickCheck should be lightweight."

下面來具體瞭解一下 QuickCheck,維基百科中描述如下:

QuickCheck is a combinator library originally written in Haskell, designed to assist in software testing by generating test cases for test suites. It is compatible with the GHC compiler and the Hugs interpreter.

In QuickCheck the programmer writes assertions about logical properties that a function should fulfill. Then QuickCheck attempts to generate a test case that falsifies these assertions. Once such a test case is found, QuickCheck tries to reduce it to a minimal failing subset by removing or simplifying input data that are not needed to make the test fail.

QuickCheck 最初是基於 Haskell 實現的一個庫,主要目標是通過生成用例來輔助軟體測試。其主要功能邏輯是:

  1. 首先,程式設計師使用 QuickCheck 編寫斷言,用於驗證某個函式是否滿足其邏輯特性;
  2. 然後,QuickCheck 將隨機生成測試用例來使上述斷言失敗;
  3. 接著,一旦發現失敗的用例,QuickCheck 會盡可能減少失敗用例的輸入值,以便於快速定位問題所在;
  4. 最後,輸出測試結果:成功,或是會導致測試失敗的最小用例集合。

由此可見,一個 QuickCheck 應該包含以下 4 個組成部分:

  1. 隨機數生成;
  2. 用例成功/失敗驗證標準;
  3. 用例範圍最小化;
  4. 測試結果輸出。

構建 Swift 版本 QuickCheck

構建 Swift 版本的 QuickCheck,我們需要做的也就是構建以上 4 個組成部分。

隨機數生成

這裡的隨機數並不特指數值型別,而是應該支援諸如字元、字串等“各種各樣”的型別,為此,我們可以定義一個生成隨機數的協議:

protocol Arbitrary {
    static func arbitrary() -> Self
}複製程式碼

這樣,想要生成哪種型別的隨機數,只需要遵循該協議,並實現對應 arbitrary() 即可。以 Int 為例:

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

print(Int.arbitrary()) // "3212540033"複製程式碼

用例成功/失敗驗證標準

用例成功/失敗驗證標準,即一個函式應該滿足的邏輯屬性(property),也就是 QuickCheck 作者所說的 "determine whether a test is passed or failed"。因此,property 的定義應該形如:

typealias property = A: Arbitrary -> Bool // 語法錯誤,這裡僅做示意複製程式碼

使用 property 來對輸入的隨機數進行驗證,成功返回 true,失敗返回 false,QuickCheck 通過重複生成隨機數並驗證,來尋找某一個使驗證失敗的用例,為此我們還需要一個 check 函式:

let numberOfIterations = 100

func check<A: Arbitrary>(_ property: (A) -> Bool) -> Void {
    for _ in 0 ..< numberOfIterations {
        let value = A.arbitrary()
        guard property(value) else {
            print("Failed Case: \(value).")
            return
        }
    }
    print("All cases passed!")
}複製程式碼

check 函式的主要功能有:

  1. 控制迴圈次數(for _ in 0 ..< numberOfIterations);
  2. 建立隨機輸入(let value = A.arbitrary());
  3. 驗證用例是否成功(guard property(value))。

用例範圍最小化

完成了前兩步工作之後,我們還需要將失敗用例的範圍儘量縮小,以便我們更容易的定位問題程式碼。因此我們希望輸入的隨機數能夠縮減,並重新執行驗證過程。

為此,我們可以定義一個 Smaller 協議來對輸入進行縮減處理(原書做法),同樣的,我們還可以擴充套件隨機數協議(protocol Arbitrary),為其新增縮減方法。這裡我們採用後一種:

protocol Arbitrary {
    static func arbitrary() -> Self
    static func shrink(_ : Self) -> Self?
}複製程式碼

shrink 函式能夠對輸入的隨機數進行縮減並返回,不過,返回值我們使用了可選型別,也就是說,一些輸入是無法再被縮減的,例如空陣列,這時我們需要返回 nil

下面,我們修改以上 Int 擴充套件,為其新增 shrink 函式:

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

    static func shrink(_ input: Int) -> Int? {
        return input == 0 ? nil : input / 2
    }
}

print(Int.shrink(100)) // Optional(50)複製程式碼

在上述例子中,對於整數,我們嘗試使用除以 2 的方式來進行縮減,直到等於零。

事實上,用例縮減是一個反覆的過程,甚至可能是一個“無限”的過程,因此,我們將這個“無限”縮減的過程使用函式來代替:

func iterateWhile<A: Arbitrary>(condition: (A) -> Bool, initial: A, next: (A) -> A?) -> A {
    if let x = next(initial), condition(x) {
        return iterateWhile(condition: condition, initial: x, next: next)
    }
    return initial
}複製程式碼

我們可以在發現失敗用例時,通過呼叫 iterateWhile 函式來縮減輸入用例,這樣,我們就可以進一步改造 check 函式了:

func check_2<A: Arbitrary>(_ property: (A) -> Bool) -> Void {
    for _ in 0 ..< numberOfIterations {
        let value = A.arbitrary()
        guard property(value) else {
            // 縮減用例
            let smallerValue = iterateWhile({ !property($0) }, initial: value) {
                A.shrink($0)
            }
            print("Failed Case: \(smallerValue).")
            return
        }
    }
    print("All cases passed!")
}複製程式碼

測試結果輸出

在測試結果輸出這一步,我們沒有做更多的事情,只是簡單的輸出結果,這裡不再贅述。

總結

QuickCheck 能夠幫助我們快速對函式功能進行測試,並通過用例縮減方式協助定位程式碼中的問題,使用 QuickCheck 測試驅動開發,還能夠迫使我們思考函式所承擔的職責以及需要滿足的抽象特性,幫助我們設計、開發出模組化、低耦合的程式。

在理解了 QuickCheck 的思想之後,我們構建了簡單的 Swift 版本 QuickCheck,其中融入了函式式思想,我們將整個問題分解為 4 個部分,並分別編寫了隨機數生成函式、用例驗證函式、用例縮減函式以及將這幾部分組合起來的 check 函式,從而完成了 QuickCheck 功能。不過距離能夠投入使用還有很大的差距。

目前,已經有開發者完成了一套較為完善的 Swift 版 QuickCheck,名為 SwiftCheck,需要實際應用或是進一步學習可以查閱。

參考資料

  1. Github: objcio/functional-swift
  2. Wikipedia: Haskell
  3. Wikipedia: QuickCheck
  4. Introduction to QuickCheck1
  5. QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs
  6. Github: typelift/SwiftCheck

本文屬於《函式式 Swift》讀書筆記系列,同步更新於 huizhao.win,歡迎關注!

相關文章