QuickCheck
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)