第十一章:測(cè)試和質(zhì)量保障

2018-02-24 15:49 更新

第十一章:測(cè)試和質(zhì)量保障

構(gòu)建真實(shí)系統(tǒng)意味著我們要關(guān)心系統(tǒng)的質(zhì)量控制,健壯性和正確性。有了正確的質(zhì)量保障機(jī)制,良好編寫(xiě)的代碼才能像一架精確的機(jī)器一樣,所有模塊都完成它們預(yù)期的任務(wù),并且不會(huì)有模棱兩可的邊界情況。最后我們得到的將是不言自明,正確無(wú)疑的代碼——這樣的代碼往往能激發(fā)自信。

Haskell 有幾個(gè)工具用來(lái)構(gòu)建這樣精確的系統(tǒng)。最明顯的一個(gè),也是語(yǔ)言本身就內(nèi)置的,是具有強(qiáng)大表達(dá)力的類(lèi)型系統(tǒng)。它使得一些復(fù)雜的不變量(invariants)得到了靜態(tài)保證——絕無(wú)可能寫(xiě)出違反這些約束條件的代碼。另外,純度和多態(tài)也促進(jìn)了模塊化,易重構(gòu),易測(cè)試的代碼風(fēng)格。這種類(lèi)型的代碼通常不會(huì)出錯(cuò)。

測(cè)試在保證代碼的正確性上起到了關(guān)鍵作用。Haskell 主要的測(cè)試機(jī)制是傳統(tǒng)的單元測(cè)試(通過(guò) HUnit 庫(kù))和由它衍生而來(lái)的更強(qiáng)機(jī)制:使用 Haskell 開(kāi)源測(cè)試框架 QuickCheck 進(jìn)行的基于類(lèi)型的“性質(zhì)”測(cè)試。基于性質(zhì)的測(cè)試是一種層次較高的方法,它抽象出一些函數(shù)應(yīng)該普遍滿(mǎn)足的不變量,真正的測(cè)試數(shù)據(jù)由測(cè)試庫(kù)為程序員產(chǎn)生。通過(guò)這種方法,我們可以用成百上千的測(cè)試來(lái)檢驗(yàn)代碼,從而發(fā)現(xiàn)一些用其他方法無(wú)法發(fā)現(xiàn)的微妙的邊角情形(corner cases),而這對(duì)于手寫(xiě)來(lái)說(shuō)是不可能的。

在這章里,我們將會(huì)學(xué)習(xí)如何使用 QuickCheck 來(lái)建立不變量,然后重新審視之前章節(jié)開(kāi)發(fā)的美觀(guān)打印器,并用 QuickCheck 對(duì)它進(jìn)行測(cè)試。我們也會(huì)學(xué)習(xí)如何用 GHC 的內(nèi)置代碼覆蓋工具 HPC 來(lái)指導(dǎo)測(cè)試過(guò)程。

QuickCheck: 基于類(lèi)型的測(cè)試

為了大概了解基于性質(zhì)的測(cè)試是如何工作的,我們從一個(gè)簡(jiǎn)單的情形開(kāi)始:你寫(xiě)了一個(gè)排序算法,需要測(cè)試它的行為。

首先我們載入 QuickCheck 庫(kù)和其它依賴(lài)模塊:

-- file: ch11/QC-basics.hs
import Test.QuickCheck
import Data.List

然后是我們想要測(cè)試的函數(shù)——一個(gè)自定義的排序過(guò)程:

-- file: ch11/QC-basics.hs
qsort :: Ord a => [a] -> [a]
qsort []     = []
qsort (x:xs) = qsort lhs ++ [x] ++ qsort rhs
    where lhs = filter  (< x) xs
          rhs = filter (>= x) xs

這是一個(gè)經(jīng)典的 Haskell 排序?qū)崿F(xiàn):它可能不夠高效(因?yàn)椴皇窃嘏判颍?,但它至少展示了函?shù)式編程的優(yōu)雅。現(xiàn)在,我們來(lái)檢查這個(gè)函數(shù)是否符合一個(gè)好排序算法應(yīng)該符合的基本規(guī)則。很多純函數(shù)式代碼都有的一個(gè)很有用的不變量是冪等(idempotency)——應(yīng)用一個(gè)函數(shù)兩次和一次效果應(yīng)該相同。對(duì)于我們的排序過(guò)程,一個(gè)穩(wěn)定的排序算法,這當(dāng)然應(yīng)該滿(mǎn)足,否則就真的出大錯(cuò)了!這個(gè)不變量可以簡(jiǎn)單地表示為如下性質(zhì):

-- file: ch11/QC-basics.hs
prop_idempotent xs = qsort (qsort xs) == qsort xs

依照 QuickCheck 的慣例,我們給測(cè)試性質(zhì)加上 prop_ 前綴以和普通代碼區(qū)分。冪等性質(zhì)可以簡(jiǎn)單地用一個(gè) Haskell 函數(shù)表示:對(duì)于任何已排序輸入,再次應(yīng)用 qsort 結(jié)果必須相同。我們可以手動(dòng)寫(xiě)幾個(gè)例子來(lái)確保沒(méi)什么問(wèn)題:

[譯注,運(yùn)行之前需要確保自己安裝了 QuickCheck 包,譯者使用的版本是2.8.1。]

ghci> prop_idempotent []
True
ghci> prop_idempotent [1,1,1,1]
True
ghci> prop_idempotent [1..100]
True
ghci> prop_idempotent [1,5,2,1,2,0,9]
True

看起來(lái)不錯(cuò)。但是,用手寫(xiě)輸入數(shù)據(jù)非常無(wú)趣,并且違反了一個(gè)高效函數(shù)式程序員的道德法則:讓機(jī)器干活!為了使這個(gè)過(guò)程自動(dòng)化,QuickCheck 內(nèi)置了一組數(shù)據(jù)生成器用來(lái)生成 Haskell 所有的基本數(shù)據(jù)類(lèi)型。QuickCheck 使用 Arbitrary 類(lèi)型類(lèi)來(lái)給(偽)隨機(jī)數(shù)據(jù)生成過(guò)程提供了一個(gè)統(tǒng)一接口,類(lèi)型系統(tǒng)會(huì)具體決定使用哪個(gè)生成器。QuickCheck 通常會(huì)把數(shù)據(jù)生成過(guò)程隱藏起來(lái),但我們可以手動(dòng)運(yùn)行生成器來(lái)看看 QuickCheck 生成的數(shù)據(jù)呈什么分布。例如,隨機(jī)生成一組布爾值:

[譯注:本例子根據(jù)最新版本的 QuickCheck 庫(kù)做了改動(dòng)。]

Prelude Test.QuickCheck.Gen Test.QuickCheck.Arbitrary> sample' arbitrary :: IO [Bool]
[False,False,False,True,False,False,True,True,True,True,True]

QuickCheck 用這種方法產(chǎn)生測(cè)試數(shù)據(jù),然后通過(guò) quickCheck 函數(shù)把數(shù)據(jù)傳給我們要測(cè)試的性質(zhì)。性質(zhì)本身的類(lèi)型決定了它使用哪個(gè)數(shù)據(jù)生成器。quickCheck 確保對(duì)于所有產(chǎn)生的測(cè)試數(shù)據(jù),性質(zhì)仍然成立。由于冪等測(cè)試對(duì)于列表元素類(lèi)型是多態(tài)的,我們需要選擇一個(gè)特定的類(lèi)型來(lái)產(chǎn)生測(cè)試數(shù)據(jù),我們把它作為一個(gè)類(lèi)型約束寫(xiě)在性質(zhì)上。運(yùn)行測(cè)試的時(shí)候,只需調(diào)用 quickCheck 函數(shù),并指定我們性質(zhì)函數(shù)的類(lèi)型即可(否則的話(huà),列表值將會(huì)是沒(méi)什么意思的 () 類(lèi)型):

*Main Test.QuickCheck> :type quickCheck
quickCheck :: Testable prop => prop -> IO ()
*Main Test.QuickCheck> quickCheck (prop_idempotent :: [Integer] -> Bool)
+++ OK, passed 100 tests.

對(duì)于產(chǎn)生的100個(gè)不同列表,我們的性質(zhì)都成立——太棒了!編寫(xiě)測(cè)試的時(shí)候,查看為每個(gè)測(cè)試生成的實(shí)際數(shù)據(jù)常常會(huì)很有用。我們可以把 quickCheck 替換為它的兄弟函數(shù) verboseCheck 來(lái)查看每個(gè)測(cè)試的(完整)輸出。現(xiàn)在,來(lái)看看我們的函數(shù)還可能滿(mǎn)足什么更復(fù)雜的性質(zhì)。

性質(zhì)測(cè)試

好的庫(kù)通常都會(huì)包含一組彼此正交而又關(guān)聯(lián)的基本函數(shù)。我們可以使用 QuickCheck 來(lái)指定我們代碼中函數(shù)之間的關(guān)系,從而通過(guò)一組通過(guò)有用性質(zhì)相互關(guān)聯(lián)的函數(shù)來(lái)提供一個(gè)好的庫(kù)接口。從這個(gè)角度來(lái)說(shuō),QuickCheck 扮演了 API “l(fā)int” 工具的角色:它確保我們的庫(kù) API 能說(shuō)的通。

列表排序函數(shù)的一些有趣性質(zhì)把它和其它列表操作關(guān)聯(lián)起來(lái)。例如:已排序列表的第一個(gè)元素應(yīng)該是輸入列表的最小元素。我們可以使用 List 庫(kù)的 minimum 函數(shù)來(lái)指出這個(gè)性質(zhì):

-- file: ch11/QC-basics.hs
import Data.List
prop_minimum xs         = head (qsort xs) == minimum xs

測(cè)試的時(shí)候出錯(cuò)了:

*Main Test.QuickCheck> quickCheck (prop_minimum :: [Integer] -> Bool)
*** Failed! Exception: 'Prelude.head: empty list' (after 1 test):
[]

當(dāng)對(duì)一個(gè)空列表排序時(shí)性質(zhì)不滿(mǎn)足了:對(duì)于空列表而言,head 和 minimum 沒(méi)有定義,正如它們的定義所示:

-- file: ch11/minimum.hs
head       :: [a] -> a
head (x:_) = x
head []    = error "Prelude.head: empty list"

minimum    :: (Ord a) => [a] -> a
minimum [] =  error "Prelude.minimum: empty list"
minimum xs =  foldl1 min xs

因此這個(gè)性質(zhì)只在非空列表上滿(mǎn)足。幸運(yùn)的是,QuickCheck 內(nèi)置了一套完整的性質(zhì)編寫(xiě)語(yǔ)言,使我們可以更精確地表述我們的不變量,排除那些我們不予考慮的值。對(duì)于空列表這個(gè)例子,我們可以這么說(shuō):如果列表非空,那么被排序列表的第一個(gè)元素是最小值。這是通過(guò) (==>) 函數(shù)來(lái)實(shí)現(xiàn)的,它在測(cè)試性質(zhì)之前將無(wú)效數(shù)據(jù)排除在外:

-- file: ch11/QC-basics.hs
prop_minimum' xs         = not (null xs) ==> head (qsort xs) == minimum xs

結(jié)果非常清楚。通過(guò)把空列表排除在外,我們可以確定指定性質(zhì)是成立的。

*Main Test.QuickCheck> quickCheck (prop_minimum' :: [Integer] -> Property)
+++ OK, passed 100 tests.

注意到我們把性質(zhì)的類(lèi)型從 Bool 改成了更一般的 Property 類(lèi)型(property 函數(shù)會(huì)在測(cè)試之前過(guò)濾出非空列表,而不僅是簡(jiǎn)單地返回一個(gè)布爾常量了)。

再加上其它一些應(yīng)該滿(mǎn)足的不變量,我們就可以完成排序函數(shù)的基本性質(zhì)集了:輸出應(yīng)該有序(每個(gè)元素應(yīng)該小于等于它的后繼元素);輸出是輸入的排列(我們通過(guò)列表差異函數(shù) (\) 來(lái)檢測(cè));被排序列表的最后一個(gè)元素應(yīng)該是最大值;對(duì)于兩個(gè)不同列表的最小值,如果我們把兩個(gè)列表拼接并排序,這個(gè)值應(yīng)該是第一個(gè)元素。這些性質(zhì)可以表述如下:

-- file: ch11/QC-basics.hs
prop_ordered xs = ordered (qsort xs)
    where ordered []       = True
          ordered [x]      = True
          ordered (x:y:xs) = x <= y && ordered (y:xs)

prop_permutation xs = permutation xs (qsort xs)
    where permutation xs ys = null (xs \\ ys) && null (ys \\ xs)

prop_maximum xs         =
    not (null xs) ==>
        last (qsort xs) == maximum xs

prop_append xs ys       =
    not (null xs) ==>
    not (null ys) ==>
        head (qsort (xs ++ ys)) == min (minimum xs) (minimum ys)

利用模型進(jìn)行測(cè)試

另一種增加代碼可信度的技術(shù)是利用模型實(shí)現(xiàn)進(jìn)行測(cè)試。我們可以把我們的列表排序函數(shù)跟標(biāo)準(zhǔn)列表庫(kù)中的排序?qū)崿F(xiàn)進(jìn)行對(duì)比。如果它們行為相同,我們會(huì)有更多信心我們的代碼的正確的。

-- file: ch11/QC-basics.hs
prop_sort_model xs      = sort xs == qsort xs

這種基于模型的測(cè)試非常強(qiáng)大。開(kāi)發(fā)人員經(jīng)常會(huì)有一些正確但低效的參考實(shí)現(xiàn)或原型。他們可以保留這部分代碼來(lái)確保優(yōu)化之后的生產(chǎn)代碼仍具有相同行為。通過(guò)構(gòu)建大量這樣的測(cè)試并定期運(yùn)行(例如每次提交),我們可以很容易地確保代碼仍然正確。大型的 Haskell 項(xiàng)目通常包含了跟項(xiàng)目本身大小可比的性質(zhì)測(cè)試集,每次代碼改變都會(huì)進(jìn)行成千上萬(wàn)項(xiàng)不變量測(cè)試,保證了代碼行為跟預(yù)期一致。

測(cè)試案例學(xué)習(xí):美觀(guān)打印器

測(cè)試單個(gè)函數(shù)的自然性質(zhì)是開(kāi)發(fā)大型 Haskell 系統(tǒng)的基石。我們現(xiàn)在來(lái)看一個(gè)更復(fù)雜的案例:為第五章開(kāi)發(fā)的美觀(guān)打印器編寫(xiě)測(cè)試集。

生成測(cè)試數(shù)據(jù)

美觀(guān)打印器是圍繞 Doc 而建的,它是一個(gè)代數(shù)數(shù)據(jù)類(lèi)型,表示格式良好的文檔。

-- file: ch11/Prettify2.hs

data Doc = Empty
         | Char Char
         | Text String
         | Line
         | Concat Doc Doc
         | Union Doc Doc
         deriving (Show,Eq)

這個(gè)庫(kù)本身是由一組函數(shù)構(gòu)成的,這些函數(shù)負(fù)責(zé)構(gòu)建和變換 Doc 類(lèi)型的值,最后再把它們轉(zhuǎn)換成字符串。

QuickCheck 鼓勵(lì)這樣一種測(cè)試方式:開(kāi)發(fā)人員指定一些不變量,它們對(duì)于任何代碼接受的輸入都成立。為了測(cè)試美觀(guān)打印庫(kù),我們首先需要一個(gè)輸入數(shù)據(jù)源。我們可以利用 QuickCheck 通過(guò) Arbitrary 類(lèi)型類(lèi)提供的一套用來(lái)生成隨機(jī)數(shù)據(jù)的組合子集。Arbitrary 類(lèi)型類(lèi)提供了 arbitrary 函數(shù)來(lái)給每種類(lèi)型生成數(shù)據(jù),我們可以利用它來(lái)給自定義數(shù)據(jù)類(lèi)型寫(xiě)數(shù)據(jù)生成器。

-- file: ch11/Arbitrary.hs
import Test.QuickCheck.Arbitrary
import Test.QuickCheck.Gen
class Arbitrary a where
    arbitrary   :: Gen a

有一點(diǎn)需要注意,函數(shù)的類(lèi)型簽名表明生成器運(yùn)行在 Gen 環(huán)境中。它是一個(gè)簡(jiǎn)單的狀態(tài)傳遞 monad,用來(lái)隱藏貫穿于代碼中的隨機(jī)數(shù)字生成器的狀態(tài)。稍后的章節(jié)會(huì)更加細(xì)致地研究 monads,現(xiàn)在只要知道,由于 Gen 被定義為一個(gè) monad,我們可以使用 do 語(yǔ)法來(lái)定義新生成器來(lái)訪(fǎng)問(wèn)隱式的隨機(jī)數(shù)字源。Arbitrary 類(lèi)型類(lèi)提供了一組可以生成隨機(jī)值的函數(shù),我們可以把它們組合起來(lái)構(gòu)建出我們所關(guān)心的類(lèi)型的數(shù)據(jù)結(jié)構(gòu),以便給我們的自定義類(lèi)型寫(xiě)生成器。一些關(guān)鍵函數(shù)的類(lèi)型如下:

-- file: ch11/Arbitrary.hs
    elements :: [a] -> Gen a
    choose   :: Random a => (a, a) -> Gen a
    oneof    :: [Gen a] -> Gen a

elements 函數(shù)接受一個(gè)列表,返回這個(gè)列表的隨機(jī)值生成器。我們稍后再用 choose 和 oneof。有了 elements,我們就可以開(kāi)始給一些簡(jiǎn)單的數(shù)據(jù)類(lèi)型寫(xiě)生成器了。例如,如果我們給三元邏輯定義了一個(gè)新數(shù)據(jù)類(lèi)型:

-- file: ch11/Arbitrary.hs
data Ternary
    = Yes
    | No
    | Unknown
    deriving (Eq,Show)

我們可以給 Ternary 類(lèi)型實(shí)現(xiàn) Arbitrary 實(shí)例:只要實(shí)現(xiàn) arbitrary 即可,它從所有可能的 Ternary 類(lèi)型值中隨機(jī)選出一些來(lái):

-- file: ch11/Arbitrary.hs
instance Arbitrary Ternary where
    arbitrary     = elements [Yes, No, Unknown]

另一種生成數(shù)據(jù)的方案是生成 Haskell 基本類(lèi)型數(shù)據(jù),然后把它們映射成我們感興趣的類(lèi)型。在寫(xiě) Ternary 實(shí)例的時(shí)候,我們可以用 choose 生成0到2的整數(shù)值,然后把它們映射為 Ternary 值。

-- file: ch11/Arbitrary2.hs
instance Arbitrary Ternary where
    arbitrary     = do
        n <- choose (0, 2) :: Gen Int
        return $ case n of
                      0 -> Yes
                      1 -> No
                      _ -> Unknown

對(duì)于簡(jiǎn)單的類(lèi)型,這種方法非常奏效,因?yàn)檎麛?shù)可以很好地映射到數(shù)據(jù)類(lèi)型的構(gòu)造器上。對(duì)于類(lèi)型(如結(jié)構(gòu)體和元組),我們首先得把積的不同部分分別生成(對(duì)于嵌套類(lèi)型遞歸地生成),然后再把他們組合起來(lái)。例如,生成隨機(jī)序?qū)Γ?/p>

-- file: ch11/Arbitrary.hs
instance (Arbitrary a, Arbitrary b) => Arbitrary (a, b) where
    arbitrary = do
        x <- arbitrary
        y <- arbitrary
        return (x, y)

現(xiàn)在我們寫(xiě)個(gè)生成器來(lái)生成 Doc 類(lèi)型所有不同的變種。我們把問(wèn)題分解,首先先隨機(jī)生成一個(gè)構(gòu)造器,然后根據(jù)結(jié)果再隨機(jī)生成參數(shù)。最復(fù)雜的是 union 和 concatenation 這兩種情形。

[譯注,作者在此處解釋并實(shí)現(xiàn)了 Char 的 Arbitrary 實(shí)例。但由于最新 QuickCheck 已經(jīng)包含此實(shí)例,故此處略去相關(guān)內(nèi)容。]

現(xiàn)在我們可以開(kāi)始給 Doc 寫(xiě)實(shí)例了。只要枚舉構(gòu)造器,再把參數(shù)填進(jìn)去即可。我們用一個(gè)隨機(jī)整數(shù)來(lái)表示生成哪種形式的 Doc,然后再根據(jù)結(jié)果分派。生成 concat 和 union 的 Doc 值時(shí),我們只需要遞歸調(diào)用 arbitrary 即可,類(lèi)型推導(dǎo)會(huì)決定使用哪個(gè) Arbitrary 實(shí)例:

-- file: ch11/QC.hs
instance Arbitrary Doc where
    arbitrary = do
        n <- choose (1,6) :: Gen Int
        case n of
             1 -> return Empty

             2 -> do x <- arbitrary
                     return (Char x)

             3 -> do x <- arbitrary
                     return (Text x)

             4 -> return Line

             5 -> do x <- arbitrary
                     y <- arbitrary
                     return (Concat x y)

             6 -> do x <- arbitrary
                     y <- arbitrary
                     return (Union x y)

看起來(lái)很直觀(guān)。我們可以用 oneof 函數(shù)來(lái)化簡(jiǎn)它。我們之前見(jiàn)到過(guò) oneof 的類(lèi)型,它從列表中選擇一個(gè)生成器(我們也可以用 monadic 組合子 liftM 來(lái)避免命名中間結(jié)果):

-- file: ch11/QC.hs
instance Arbitrary Doc where
    arbitrary =
        oneof [ return Empty
              , liftM  Char   arbitrary
              , liftM  Text   arbitrary
              , return Line
              , liftM2 Concat arbitrary arbitrary
              , liftM2 Union  arbitrary arbitrary ]

后者更簡(jiǎn)潔。我們可以試著生成一些隨機(jī)文檔,確保沒(méi)什么問(wèn)題。

*QC Test.QuickCheck> sample' (arbitrary::Gen Doc)
[Text "",Concat (Char '\157') Line,Char '\NAK',Concat (Text "A\b") Empty,
Union Empty (Text "4\146~\210"),Line,Union Line Line,
Concat Empty (Text "|m  \DC4-\DLE*3\DC3\186"),Char '-',
Union (Union Line (Text "T\141\167\&3\233\163\&5\STX\164\145zI")) (Char '~'),Line]

從輸出的結(jié)果里,我們既看到了簡(jiǎn)單,基本的文檔,也看到了相對(duì)復(fù)雜的嵌套文檔。每次測(cè)試時(shí)我們都會(huì)隨機(jī)生成成百上千的隨機(jī)文檔,他們應(yīng)該可以很好地覆蓋各種情形?,F(xiàn)在我們可以開(kāi)始給我們的文檔函數(shù)寫(xiě)一些通用性質(zhì)了。

測(cè)試文檔構(gòu)建

文檔有兩個(gè)基本函數(shù):一個(gè)是空文檔常量 Empty,另一個(gè)是拼接函數(shù)。它們的類(lèi)型是:

-- file: ch11/Prettify2.hs
empty :: Doc
(<>)  :: Doc -> Doc -> Doc

兩個(gè)函數(shù)合起來(lái)有一個(gè)不錯(cuò)的性質(zhì):將空列表拼接在(無(wú)論是左拼接還是右拼接)另一個(gè)列表上,這個(gè)列表保持不變。我們可以將這個(gè)不變量表述為如下性質(zhì):

-- file: ch11/QC.hs
prop_empty_id x =
    empty <> x == x
  &&
    x <> empty == x

運(yùn)行測(cè)試,確保性質(zhì)成立:

*QC Test.QuickCheck> quickCheck prop_empty_id
+++ OK, passed 100 tests.

可以把 quickCheck 替換成 verboseCheck 來(lái)看看實(shí)際測(cè)試時(shí)用的是哪些文檔。從輸出可以看到,簡(jiǎn)單和復(fù)雜的情形都覆蓋到了。如果需要的話(huà),我們還可以進(jìn)一步優(yōu)化數(shù)據(jù)生成器來(lái)控制不同類(lèi)型數(shù)據(jù)的比例。

其它 API 函數(shù)也很簡(jiǎn)單,可以用性質(zhì)來(lái)完全描述它們的行為。這樣做使得我們可以對(duì)函數(shù)的行為維護(hù)一個(gè)外部的,可檢查的描述以確保之后的修改不會(huì)破壞這些基本不變量:

-- file: ch11/QC.hs

prop_char c   = char c   == Char c

prop_text s   = text s   == if null s then Empty else Text s

prop_line     = line     == Line

prop_double d = double d == text (show d)

這些性質(zhì)足以測(cè)試基本的文檔結(jié)構(gòu)了。測(cè)試庫(kù)的剩余部分還要更多工作。

以列表為模型

高階函數(shù)是可復(fù)用編程的基本膠水,我們的美觀(guān)打印庫(kù)也不例外——我們自定義了 fold 函數(shù),用來(lái)在內(nèi)部實(shí)現(xiàn)文檔拼接和在文檔塊之間加分隔符。fold 函數(shù)接受一個(gè)文檔列表,并借助一個(gè)合并方程(combining function)把它們粘合在一起。

-- file: ch11/Prettify2.hs
fold :: (Doc -> Doc -> Doc) -> [Doc] -> Doc
fold f = foldr f empty

我們可以很容易地給某個(gè)特定 fold 實(shí)例寫(xiě)測(cè)試。例如,橫向拼接(Horizontal concatenation)就可以簡(jiǎn)單地利用列表中的參考實(shí)現(xiàn)來(lái)測(cè)試。

-- file: ch11/QC.hs

prop_hcat xs = hcat xs == glue xs
    where
        glue []     = empty
        glue (d:ds) = d <> glue ds

punctuate 也類(lèi)似,插入標(biāo)點(diǎn)類(lèi)似于列表的 interspersion 操作(intersperse 這個(gè)函數(shù)來(lái)自于 Data.List,它把一個(gè)元素插在列表元素之間):

-- file: ch11/QC.hs

prop_punctuate s xs = punctuate s xs == intersperse s xs

看起來(lái)不錯(cuò),運(yùn)行起來(lái)卻出了問(wèn)題:

*QC Test.QuickCheck> quickCheck prop_punctuate
*** Failed! Falsifiable (after 5 tests and 1 shrink):
Empty
[Text "",Text "E"]

美觀(guān)打印庫(kù)優(yōu)化了冗余的空文檔,然而模型實(shí)現(xiàn)卻沒(méi)有,所以我們得讓模型匹配實(shí)際情況。首先,我們可以把分隔符插入文檔,然后再用一個(gè)循環(huán)去掉當(dāng)中的 Empty 文檔,就像這樣:

-- file: ch11/QC.hs
prop_punctuate' s xs = punctuate s xs == combine (intersperse s xs)
    where
        combine []           = []
        combine [x]          = [x]

        combine (x:Empty:ys) = x : combine ys
        combine (Empty:y:ys) = y : combine ys
        combine (x:y:ys)     = x `Concat` y : combine ys

ghci 里運(yùn)行,確保結(jié)果是正確的。測(cè)試框架發(fā)現(xiàn)代碼中的錯(cuò)誤讓人感到欣慰——因?yàn)檫@正是我們追求的。

*QC Test.QuickCheck> quickCheck prop_punctuate'
+++ OK, passed 100 tests.

完成測(cè)試框架

[譯注:為了匹配最新版本的 QuickCheck,本節(jié)在原文基礎(chǔ)上做了較大改動(dòng)。讀者可自行參考原文,對(duì)比閱讀。]

我們可以把這些測(cè)試單獨(dú)放在一個(gè)文件中,然后用 QuickCheck 的驅(qū)動(dòng)函數(shù)運(yùn)行它們。這樣的函數(shù)有很多,包括一些復(fù)雜的并行驅(qū)動(dòng)函數(shù)。我們?cè)谶@里使用 quickCheckWithResult 函數(shù)。我們只需提供一些測(cè)試參數(shù),然后列出我們想要測(cè)試的函數(shù)即可:

-- file: ch11/Run.hs
module Main where
import QC
import Test.QuickCheck

anal :: Args
anal = Args
    { replay = Nothing
    , maxSuccess = 1000
    , maxDiscardRatio = 1
    , maxSize = 1000
    , chatty = True
    }

minimal :: Args
minimal = Args
    { replay = Nothing
    , maxSuccess = 200
    , maxDiscardRatio = 1
    , maxSize = 200
    , chatty = True
    }

runTests :: Args -> IO ()
runTests args = do
    f prop_empty_id "empty_id ok?"
    f prop_char "char ok?"
    f prop_text "text ok?"
    f prop_line "line ok?"
    f prop_double "double ok?"
    f prop_hcat "hcat ok?"
    f prop_punctuate' "punctuate ok?"
    where
        f prop str = do
            putStrLn str
            quickCheckWithResult args prop
            return ()

main :: IO ()
main = do
    putStrLn "Choose test depth"
    putStrLn "1. Anal"
    putStrLn "2. Minimal"
    depth <- readLn
    if depth == 1
        then runTests anal
    else runTests minimal

[譯注:此代碼出處為原文下Charlie Harvey的評(píng)論。]

我們把這些代碼放在一個(gè)單獨(dú)的腳本中,聲明的實(shí)例和性質(zhì)也有自己?jiǎn)为?dú)的文件,它們庫(kù)的源文件完全分開(kāi)。這在庫(kù)項(xiàng)目中非常常見(jiàn),通常在這些項(xiàng)目中測(cè)試都會(huì)和庫(kù)本身分開(kāi),測(cè)試通過(guò)模塊系統(tǒng)載入庫(kù)。

這時(shí)候可以編譯并運(yùn)行測(cè)試腳本了:

$ ghc --make Run.hs
[1 of 3] Compiling Prettify2        ( Prettify2.hs, Prettify2.o )
[2 of 3] Compiling QC               ( QC.hs, QC.o )
[3 of 3] Compiling Main             ( Run.hs, Run.o )
Linking Run ...
$ ./Run
Choose test depth
1. Anal
2. Minimal
2
empty_id ok?
+++ OK, passed 200 tests.
char ok?
+++ OK, passed 200 tests.
text ok?
+++ OK, passed 200 tests.
line ok?
+++ OK, passed 1 tests.
double ok?
+++ OK, passed 200 tests.
hcat ok?
+++ OK, passed 200 tests.
punctuate ok?
+++ OK, passed 200 tests.

一共產(chǎn)生了1201個(gè)測(cè)試,很不錯(cuò)。增加測(cè)試深度很容易,但為了了解代碼究竟被測(cè)試的怎樣,我們應(yīng)該使用內(nèi)置的代碼覆蓋率工具 HPC,它可以精確地告訴我們發(fā)生了什么。

用 HPC 衡量測(cè)試覆蓋率

HPC(Haskell Program Coverage) 是一個(gè)編譯器擴(kuò)展,用來(lái)觀(guān)察程序運(yùn)行時(shí)哪一部分的代碼被真正執(zhí)行了。這在測(cè)試時(shí)非常有用,它讓我們精確地觀(guān)察哪些函數(shù),分支以及表達(dá)式被求值了。我們可以輕易得到被測(cè)試代碼的百分比。HPC 的內(nèi)置工具可以產(chǎn)生關(guān)于程序覆蓋率的圖表,方便我們找到測(cè)試集的缺陷。

在編譯測(cè)試代碼時(shí),我們只需在命令行加上 -fhpc 選項(xiàng),即可得到測(cè)試覆蓋率數(shù)據(jù)。

$ ghc -fhpc Run.hs --make

正常運(yùn)行測(cè)試:

$ ./Run

測(cè)試運(yùn)行時(shí),程序運(yùn)行的細(xì)節(jié)被寫(xiě)入當(dāng)前目錄下的 .tix 和 .mix 文件。之后,命令行工具 hpc 用這些文件來(lái)展示各種統(tǒng)計(jì)數(shù)據(jù),解釋發(fā)生了什么。最基本的交互是通過(guò)文字。首先,我們可以在 hpc 命令中加上 report 選項(xiàng)來(lái)得到一個(gè)測(cè)試覆蓋率的摘要。我們會(huì)把測(cè)試程序排除在外(使用 --exclude 選項(xiàng)),這樣就能把注意力集中在美觀(guān)打印庫(kù)上了。在命令行中輸入以下命令:

$ hpc report Run --exclude=Main --exclude=QC
 93% expressions used (30/32)
100% boolean coverage (0/0)
    100% guards (0/0)
    100% 'if' conditions (0/0)
    100% qualifiers (0/0)
100% alternatives used (8/8)
100% local declarations used (0/0)
 66% top-level declarations used (10/15)

[譯注:報(bào)告結(jié)果可能因人而異。]

在最后一行我們看到,測(cè)試時(shí)有66%的頂層定義被求值。對(duì)于第一次嘗試來(lái)說(shuō),已經(jīng)是很不錯(cuò)的結(jié)果了。隨著被測(cè)試函數(shù)的增加,這個(gè)數(shù)字還會(huì)提升。對(duì)于快速了解結(jié)果來(lái)說(shuō)文字版本的結(jié)果還不錯(cuò),但為了真正了解發(fā)生了什么,最好還是看看被標(biāo)記后的結(jié)果(marked up output)。用 markup 選項(xiàng)可以生成:

$hpc markup Run --exclude=Main --exclude=QC

它會(huì)對(duì)每一個(gè) Haskell 源文件產(chǎn)生一個(gè) html 文件,再加上一些索引文件。在瀏覽器中打開(kāi) hpc_index.html,我們可以看到一些非常漂亮的代碼覆蓋率圖表:

以上內(nèi)容是否對(duì)您有幫助:
在線(xiàn)筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)