QuickCheck

Haskellプロパティベーステスト自動テスト生成関数型プログラミング型クラス

QuickCheck

概要

QuickCheckは、Haskell用のプロパティベーステストの元祖として知られるライブラリです。1999年にKoen ClaessenとJohn Hughesによって開発され、プログラムのプロパティ(性質)をランダムに生成されたテストケースで検証します。30以上の言語に移植されているにも関わらず、Haskellの型システムと一貫したロジックにより、オリジナル実装が今でも最高峰とされています。関数が満たすべき抽象的な不変条件を記述し、数千のテストケースを自動生成・実行する革新的なテスト手法を提供します。

詳細

主な特徴

  • プロパティベーステスト: 関数が普遍的に満たすべき性質の記述と検証
  • 自動テストデータ生成: Arbitrary型クラスによる型ベースの自動データ生成
  • 縮小化(Shrinking): 失敗時の最小反例自動発見機能
  • 高レベルアプローチ: 具体的なテストケースではなく抽象的不変条件に焦点
  • 型安全性: Haskellの強力な型システムとの完全統合
  • モナディックテスト: 状態変更やI/Oを伴う関数のテストサポート
  • カスタムジェネレーター: 独自のテストデータ生成ロジック作成機能

アーキテクチャ

QuickCheckは以下の核心コンポーネントで構成されています:

  • Arbitrary型クラス: 型ごとのテストデータ生成器定義
  • Gen型: モナディックなランダム値生成器
  • Property型: テスト可能なプロパティの表現
  • Test Runner: プロパティ実行と結果レポート
  • Shrinking Engine: 失敗ケースの最小化機能

ジェネレーターシステム

ジェネレーターは以下の特徴を持ちます:

  • 小さな値から始まり、段階的に大きな値を生成
  • 型情報に基づく自動生成
  • カスタム分布とフィルタリングのサポート

メリット・デメリット

メリット

  • 包括的テスト: 手作業では不可能な大量テストケースの自動実行
  • エッジケース発見: 人間が見逃しやすい微妙な境界条件の検出
  • 型安全性: Haskellの型システムによる強力な安全保証
  • 高レベル記述: 具体例ではなく抽象的性質の記述による保守性向上
  • 効率的デバッグ: 縮小化による最小反例の自動発見
  • 関数型統合: 純粋関数型プログラミングとの自然な統合

デメリット

  • 学習コスト: プロパティベーステストの概念習得が必要
  • Haskell依存: 言語固有の機能で他言語への移植が困難
  • プロパティ設計: 良いプロパティの設計には経験と洞察が必要
  • 非決定性: ランダム生成による再現性の課題
  • 実行時間: 大量のテストケース生成・実行による時間コスト

参考ページ

書き方の例

基本的なプロパティテスト

import Test.QuickCheck

-- リストの長さに関するプロパティ
prop_lengthAppend :: [Int] -> [Int] -> Bool
prop_lengthAppend xs ys = length (xs ++ ys) == length xs + length ys

-- リバースの冪等性
prop_reverseInvolution :: [Int] -> Bool
prop_reverseInvolution xs = reverse (reverse xs) == xs

-- 加算の交換法則
prop_addCommutative :: Int -> Int -> Bool
prop_addCommutative x y = x + y == y + x

-- テスト実行
main = do
    quickCheck prop_lengthAppend
    quickCheck prop_reverseInvolution
    quickCheck prop_addCommutative

カスタムデータ型とArbitraryインスタンス

import Test.QuickCheck
import Control.Monad (liftM2)

-- カスタムデータ型
data Tree a = Leaf a | Node (Tree a) (Tree a)
    deriving (Show, Eq)

-- Arbitraryインスタンスの定義
instance Arbitrary a => Arbitrary (Tree a) where
    arbitrary = sized tree'
      where
        tree' 0 = liftM Leaf arbitrary
        tree' n | n > 0 = 
            oneof [ liftM Leaf arbitrary
                  , liftM2 Node subtree subtree
                  ]
          where 
            subtree = tree' (n `div` 2)

    shrink (Leaf x) = [Leaf x' | x' <- shrink x]
    shrink (Node l r) = [l, r] ++ 
                        [Node l' r | l' <- shrink l] ++
                        [Node l r' | r' <- shrink r]

-- ツリーのプロパティ
treeSize :: Tree a -> Int
treeSize (Leaf _) = 1
treeSize (Node l r) = treeSize l + treeSize r

flattenTree :: Tree a -> [a]
flattenTree (Leaf x) = [x]
flattenTree (Node l r) = flattenTree l ++ flattenTree r

-- ツリーサイズとフラット化の関係
prop_treeSizeConsistent :: Tree Int -> Bool
prop_treeSizeConsistent t = treeSize t == length (flattenTree t)

ジェネレーターの組み合わせ

import Test.QuickCheck

-- 特定範囲の整数ジェネレーター
smallInt :: Gen Int
smallInt = choose (-100, 100)

-- 偶数のジェネレーター
evenInt :: Gen Int
evenInt = (*2) <$> arbitrary

-- 正の整数のジェネレーター
positiveInt :: Gen Int
positiveInt = abs <$> arbitrary `suchThat` (> 0)

-- 非空リストのジェネレーター
nonEmptyList :: Arbitrary a => Gen [a]
nonEmptyList = listOf1 arbitrary

-- 文字列のジェネレーター
identifierString :: Gen String
identifierString = do
    first <- elements ['a'..'z']
    rest <- listOf (elements (['a'..'z'] ++ ['A'..'Z'] ++ ['0'..'9']))
    return (first : rest)

-- カスタムジェネレーターを使用したプロパティ
prop_customGenerators :: Property
prop_customGenerators = forAll positiveInt $ \n ->
    n > 0

prop_identifierProperty :: Property
prop_identifierProperty = forAll identifierString $ \s ->
    not (null s) && head s `elem` ['a'..'z']

頻度による分布制御

-- 異なる種類のデータの頻度を制御
data Shape = Circle Double | Rectangle Double Double | Triangle Double Double Double
    deriving (Show, Eq)

instance Arbitrary Shape where
    arbitrary = frequency 
        [ (3, Circle <$> positiveFloat)      -- 30%の確率
        , (5, Rectangle <$> positiveFloat <*> positiveFloat)  -- 50%の確率
        , (2, Triangle <$> positiveFloat <*> positiveFloat <*> positiveFloat)  -- 20%の確率
        ]
      where
        positiveFloat = abs <$> arbitrary `suchThat` (> 0)

-- 面積計算のプロパティ
area :: Shape -> Double
area (Circle r) = pi * r * r
area (Rectangle w h) = w * h
area (Triangle a b c) = 
    let s = (a + b + c) / 2
    in sqrt (s * (s - a) * (s - b) * (s - c))

prop_positiveArea :: Shape -> Bool
prop_positiveArea shape = area shape > 0

条件付きプロパティ

-- 前提条件がある場合のテスト
prop_divisionProperty :: Int -> Int -> Property
prop_divisionProperty x y = y /= 0 ==> (x `div` y) * y + (x `mod` y) == x

-- 複雑な前提条件
prop_sortedListProperty :: [Int] -> Property
prop_sortedListProperty xs = 
    not (null xs) ==> 
    let sorted = sort xs
    in head sorted <= last sorted

-- 複数の前提条件
prop_listIndexing :: [Int] -> Int -> Property
prop_listIndexing xs i = 
    not (null xs) && i >= 0 && i < length xs ==>
    (xs !! i) `elem` xs

モナディックテスト

import Test.QuickCheck
import Test.QuickCheck.Monadic
import qualified Data.Map as Map
import Control.Monad.State

-- 状態を持つ操作のテスト
type Counter = State Int

increment :: Counter ()
increment = modify (+1)

decrement :: Counter ()
decrement = modify (\x -> x - 1)

getCount :: Counter Int
getCount = get

-- モナディックプロパティ
prop_counterProperty :: [Bool] -> Property
prop_counterProperty operations = monadicST $ do
    let actions = map (\b -> if b then increment else decrement) operations
    result <- run $ execState (sequence_ actions) 0
    let expected = length (filter id operations) - length (filter not operations)
    assert (result == expected)

-- IOを伴うテスト
prop_fileOperations :: String -> Property
prop_fileOperations content = monadicIO $ do
    let filename = "test.tmp"
    run $ writeFile filename content
    readContent <- run $ readFile filename
    run $ removeFile filename
    assert (content == readContent)

ラベル付きテストと分類

-- テストケースの分類
prop_listSortProperties :: [Int] -> Property
prop_listSortProperties xs = 
    classify (null xs) "empty list" $
    classify (length xs == 1) "singleton" $
    classify (length xs >= 2 && length xs <= 10) "small list" $
    classify (length xs > 10) "large list" $
    let sorted = sort xs
    in collect (length xs) $
       sorted == sort sorted

-- カバレッジの確認
prop_mathOperations :: Int -> Int -> Property
prop_mathOperations x y =
    label ("x positive: " ++ show (x > 0)) $
    label ("y positive: " ++ show (y > 0)) $
    label ("same sign: " ++ show (signum x == signum y)) $
    x + y - y == x

高度なテスト機能

-- 期待される例外のテスト
prop_divisionByZero :: Int -> Property
prop_divisionByZero x = expectFailure $ 
    within 1000000 $ -- 1秒以内に完了する必要
    return (x `div` 0 == 0)

-- 性能テスト
prop_sortPerformance :: [Int] -> Property
prop_sortPerformance xs = 
    length xs <= 1000 ==> 
    within 1000000 $ -- 1秒以内
    return (length (sort xs) == length xs)

-- カスタムテスト設定
customCheck :: Testable prop => prop -> IO ()
customCheck = quickCheckWith stdArgs 
    { maxSuccess = 1000    -- テストケース数
    , maxSize = 100        -- 最大サイズ
    , chatty = True        -- 詳細出力
    }

-- テンプレートHaskellによる自動発見
{-# LANGUAGE TemplateHaskell #-}
import Test.QuickCheck.All

prop_automaticTest1 :: [Int] -> Bool
prop_automaticTest1 xs = length xs >= 0

prop_automaticTest2 :: Int -> Int -> Bool  
prop_automaticTest2 x y = x + y == y + x

-- すべてのprop_で始まる関数を自動実行
return []
runTests = $quickCheckAll

実世界でのプロパティ例

-- JSON シリアライゼーション/デシリアライゼーション
prop_jsonRoundTrip :: Person -> Bool
prop_jsonRoundTrip person = 
    case decode (encode person) of
        Just decoded -> decoded == person
        Nothing -> False

-- 暗号化/復号化
prop_encryptDecrypt :: String -> String -> Bool
prop_encryptDecrypt key plaintext = 
    decrypt key (encrypt key plaintext) == plaintext

-- データベース操作
prop_databaseConsistency :: UserId -> User -> Property
prop_databaseConsistency uid user = monadicIO $ do
    run $ insertUser uid user
    retrieved <- run $ getUser uid
    run $ deleteUser uid
    assert (retrieved == Just user)