۱۴ - ۴به QuickCheck خوشامد بگین
hspec
تست شناسهای رو خوب انجام میده، ولی ما کاربرای هسکلیم – هیچ وقت راضی نمیشیم!! hspec
فقط میتونه یه چیزی از مقادیرِ بخصوص رو ثابت کنه. آیا امکان داره تستهای قویتری که به اثبات نزدیکتر هستن داشته باشیم؟ بله، میشه.
QuickCheck
اولین کتابخونهای بود که تست مشخصهای رو معرفی کرد. hspec
بیشتر شبیهِ تست واحدی ِه – تستِ تکبهتکِ واحدهای کُد – اما در مقابل، تست مشخصهای با اعلامِ قوانین یا مشخصات کار میکنه.
اول از همه باید QuickCheck
رو به build-depends
ِمون اضافه کنیم. فایلِ .cabal
ِتون رو باز و اضافهش کنین. حواستون باشه که حرف اولش بزرگ باشه، QuickCheck
(برعکسِ hspec
که با h کوچیک شروع میشد). احتمالاً خودش نصب شده، چون یکی از وابستگیهای hspec
همین QuickCheck
ِه؛ باز اگه لازم بود، میتونین دوباره نصبش کنین (stack build
). بعد یه جلسه ِ جدید با stack ghci
شروع کنین.
hspec
بدونِ نیاز به هیچ تغییراتی، با QuickCheck
سازگاری داره، پس بعد از انجام این کارها، کُدِ زیر رو به ماژول ِتون اضافه کنید:
-- در کنار بقیهی واردات
import Test.QuickCheck
-- که بقیه رو نوشتین describe داخل همون بلوک
it "x + 1 is always greater than x" $ do
property $ \x -> x + 1 > (x :: Int)
اگه تایپِ x
رو در اون تستِ مشخصه اعلام نکرده بودیم، کامپایلر نمیدونست از کدوم تایپِ معیّن استفاده کنه، و پیغامی مشابهِ پیغامهای زیر میداد:
No instance for (Show a0)
arising from a use of ‘property’
The type variable ‘a0’ is ambiguous
...
No instance for (Num a0)
arising from a use of ‘+’
The type variable ‘a0’ is ambiguous
...
No instance for (Ord a0)
arising from a use of ‘>’
The type variable ‘a0’ is ambiguous
...
با اعلامِ یه تایپِ معیّن (مثل x :: Int
) در مشخصه از این مشکل دوری کنین.
اگه همه چیز درست باشه، چیزی مثل زیر میبینیم:
Prelude> main
Addition
1 + 1 is greater than 1
2 + 2 is equal to 4
x + 1 is always great than x
Finished in 0.0067 seconds
3 examples, 0 failures
اینجا QuickCheck
در واقع مقادیرِ خیلی زیادی رو به طورِ تصادفی برمبنای تایپی که تعیین کردیم تست کرده (به خاطرِ hspec
ممکنه به چشم نمیاد). پس انقدر مقدارِ تصادفی ِ Int
به تابع میده تا ببینه آیا مشخصه False
میشه یا نه. تعداد تستهایی که QuickCheck
انجام میده، به طورِ پیشفرض ۱۰۰ تاست.
نمونههای Arbitrary
QuickCheck
با اتکا به یه تایپکلاس به اسمِ Arbitrary
و یه newtype
به اسمِ Gen
دادههای تصادفیش رو ایجاد میکنه.
arbitrary
یه مقدار با تایپِ Gen
هست:
Prelude> :t arbitrary
arbitrary :: Arbitrary a => Gen a
به کمکِ این مقدار، میشه ژنراتور (م. یا ایجادکنندهی مقادیرِ تصادفی) ِ پیشفرض برای یه تایپ تعیین کرد. با توجه به اینکه تایپها و نمونههای تایپکلاسها جفتهای یکتا تشکیل میدن، موقعِ استفاده از مقدارِ arbitrary
باید تایپش رو تعیین کنیم تا از نمونه ِ تایپکلاسیای که مَدِ نَظَرِمون هست استفاده بشه. اما این فقط یک مقداره. چطور یه لیست از مقادیرِ تایپ مورد نظر بگیریم؟
میشه از sample
و sample'
استفاده کنیم:
-- این تابع هر مقدار رو
-- در یه خط جدید مینویسه
Prelude> :t sample
sample :: Show a => Gen a -> IO ()
-- این یکی یه لیست برمیگردونه
Prelude> :t sample'
sample' :: Gen a -> IO [a]
IO
بودن واجبه، چون این تابعها از یه منبعِ سراسری از مقادیرِ تصادفی برای ایجاد ِ دادهها استفاده میکنن. یکی از راههای رایج برای ایجاد ِ مقادیرِ شبه-تصادفی، استفاده از یه تابعیه که با یه مقدار ورودیِ دانهای، یک مقدار و یه مقدار دانهای ِ دیگه (برای ایجاد ِ یه مقدارِ دیگه) برمیگردونه. بعد این دو عمل رو میشه به هم بایند کرد (که در فصل قبل اشاره کردیم) و هر بار تابع رو با یه مقدارِ دانهای ِ جدید صدا بزنیم و همینطور دادهها ِ به ظاهر تصادفی ایجاد کنیم. البته اینجا این کار رو نکردیم. اینجا از IO
استفاده کردیم تا تابعمون از یه منبع سراسری از مقادیرِ تصادفی، هر بار یه جوابِ متفاوت بده (کاری که توابعِ خالص حق ندارن انجام بدن). اگه اینها خیلی براتون مفهوم نیستن، بعد از توضیحِ موندها روشنتر میشن؛ بعد از IO
هم خیلی بیشتر.
ما از تایپکلاسِ Arbitrary
برای آرگومانِ sample
استفاده میکنیم. تایپکلاسِ خیلی با قاعدهای نیست، اما محبوبیت داره و برای این کار مناسبه. به این خاطر میگیم بیقاعده چون قانونی نداره و کار مشخصی هم نداره که انجام بده. با این تایپکلاس خیلی راحت میشه از هیچ چیز، یه ژنراتور ِ استاندارد برای Gen a
داشته باشیم، بدون اینکه بدونیم از کجا اومده. اگه به نظر "جادو جمبل" میاد، اشکالی نداره. یه کم هست، الان هم ارزش نداره روی طرز کارِ Arbitrary
وقت بذاریم.
جوابی که از تابعهای sample
میگیریم اینطور میشن. در مثالهای زیر از مقدارِ arbitrary
استفاده کردیم، اما تایپش رو تعیین کردیم تا یه لیست از مقادیرِ تصادفی از همون تایپ بگیریم:
Prelude> sample (arbitrary :: Gen Int)
0
-2
-1
4
-3
4
2
4
-3
2
-4
Prelude> sample (arbitrary :: Gen Double)
0.0
0.13712502861905426
2.9801894108743605
-8.960645064542609
4.494161946149201
7.903662448338119
-5.221729489254451
31.64874305324701
77.43118278366954
-539.7148886375935
26.87468214215407
اگه تو GHCi بنویسین sample arbitrary
بدون اینکه تایپی براش تعیین کنین، از تایپِ پیشفرض ِ ()
استفاده میکنه و یه لیستِ خوشگل از توپلهای خالی بهتون میده. اما اگه sample arbitrary
ِ بدون تایپِ معیّن رو بخواین از یه فایل بارگذاری کنین، GHC با یه پیغام عاشقانه از مبهم بودنِ تایپتون خطا میگیره. اگه دوست دارین امتحان کنین. قواعد GHCi با GHC در تایپهای پیشفرض یه کم متفاوته.
با داده ِ خودتون هم میتونین مقادیرِ Gen
ایجاد کنین. در این مثال یه تابعِ پیشوپاافتاده که همیشه ۱ از تایپِ Int
برمیگردونه تعریف میکنیم:
-- ژنراتور یا ایجادکنندهی
-- پیشوپاافتاده از مقادیر
trivialInt :: Gen Int
trivialInt = return 1
ممکنه return
رو از فصل قبل یادتون باشه. اونجا گفتیم به غیر از گذاشتنِ یه مقدار داخلِ یه موند، کار زیادی انجام نمیده؛ فقط هم برای IO
ازش استفاده کردیم. اما برای بقیهی موندها هم کاربرد داره:
return :: Monad m => a -> m a
-- باشه Gen معادل m وقتی
return :: a -> Gen a
وقتی ۱ رو میذاریم داخلِ یه مونَد ِ Gen
، یه ژنراتوری درست میکنه که همیشه همون مقدارِ ۱ رو برمیگردونه.
خوب، حالا اگه از این trivialInt
چندتا داده ِ نمونه بگیریم چی میشه؟
Prelude> sample' trivialInt
[1,1,1,1,1,1,1,1,1,1,1]
دقت کنین از مقدارِ arbitrary
استفاده نکردیم، از مقدارِ trivialInt
که بالاتر تعریف کردیم نمونه گرفتیم. این ژنراتور همیشه ۱ برمیگردونه، پس sample'
هم فقط میتونه یه لیست از ۱ بِده.
چندتا راهِ دیگه برای ایجاد ِ مقادیر هم ببینیم:
oneThroughThree :: Gen Int
oneThroughThree = elements [1, 2, 3]
این رو از ماژول ِ Addition
که داشتین بارگذاری کنین و یه لیستِ نمونه (با sample'
) از مقادیرِ تصادفی ِ oneThroughThree
بگیرین:
*Addition> sample' oneThroughThree
[2,3,3,2,2,1,2,1,1,3,3]
فقط از همون مجموعه ِ محدود، چندتا مقدارِ تصادفی داد. تو این مثال هر کدوم از مقادیرِ اون مجموعه، احتمالِ مساوی برای انتخاب شدن داشتن. میشه این تعادلِ احتمالات رو با تکرارِ المانها تغییر داد:
oneThroughThree :: Gen Int
oneThroughThree =
elements [1, 2, 2, 2, 2, 3]
دوباره sample'
رو با این مجموعه اجرا کنین و ببینین متوجه تغییری میشین یا نه. احتمالاً نمیشین، بر طبق قوانین احتمال، هنوز کمی احتمالِ انتخاب نشدنِ ۲ وجود داره.
حالا از choose
و elements
(از کتابخونه ِ QuickCheck
) برای ایجاد ِ مقادیر استفاده میکنیم:
-- choose :: System.Random.Random a
-- => (a, a) -> Gen a
-- elements :: [a] -> Gen a
genBool :: Gen Bool
genBool = choose (False, True)
genBool' :: Gen Bool
genBool' = elements [False, True]
genOrdering :: Gen Ordering
genOrdernig = elements [LT, EQ, GT]
genChar :: Gen Ordering
genChar = elements ['a'..'z']
همهی اینها رو تو ماژول ِ Addition
ِتون بنویسین و تو REPL از هرکدوم چندتا لیستِ نمونه ِ مقادیرشون بگیرین.
مثالهای بعدی یه کم پیچیدهتراند:
genTuple :: (Arbitrary a, Arbitrary b)
=> Gen (a, b)
genTuple = do
a <- arbitrary
b <- arbitrary
return (a, b)
genThreeple :: (Arbitrary a, Arbitrary b,
Arbitrary c)
=> Gen (a, b, c)
genThreeple = do
a <- arbitrary
b <- arbitrary
c <- arbitrary
return (a, b, c)
تو این مثالها ژنراتورهایی درست کردیم که بیشتر از یک آرگومانِ تایپیِ پلیمورفیک میگیرن. یادتون باشه که اگه تایپها رو مشخص نکنین، GHCi خودبهخود تایپِ پیشفرض ِ ()
رو براتون انتخاب میکنه. چنین کاری خارج از GHCi خطای مبهم بودنِ تایپ میده – قبلتر که تایپکلاسها رو گفتیم این رو هم یه کم توضیح دادیم:
Prelude> sample genTuple
((),())
((),())
((),())
اینجا ()
رو بطورِ پیشفرض برای a
و b
انتخاب کرده. دقت کنین که همیشه برای مقادیرِ عددی، اول 0
و 0.0
رو انتخاب میکنه:
Prelude> sample (genTuple :: Gen (Int, Float))
(0,0.0)
(-1,0.2516606)
(3,0.7800742)
(5,-61.62875)
میشه انواعِ لیستها و کاراکترها، یا هر چیزی که یه نمونه از تایپکلاسِ
Arbitrary داره رو بهش بدیم:
Prelude> sample (genTuple :: Gen ([()], Char))
([], '\STX')
([()],'X')
([],'?')
([],'\137')
([(),()], '\DC1')
([(),()], 'z')
با دستورِ :info Arbitrary
میتونین همهی نمونههای موجود از تایپکلاسِ Arbitrary
رو ببینین.
حتی میشه مقادیرِ تصادفی ِ Maybe
و Either
هم ایجاد کنیم:
genEither :: (Arbitrary a, Arbitrary b)
=> Gen (Either a b)
genEither = do
a <- arbitrary
b <- arbitrary
elements [Left a, Right b]
-- احتمال مساوی
genMaybe :: Arbitrary a => Gen (Maybe a)
genMaybe = do
a <- arbitrary
elements [Nothing, Just a]
-- انجام میده تا QuickCheck این کاریه که
-- احتمال بیشتری داشته باشن Just مقادیر
genMaybe' :: Arbitrary a => Gen (Maybe a)
genMaybe' = do
a <- arbitrary
frequency [ (1, return Nothing)
, (3, return (Just a))]
-- frequency :: [(Int, Gen a)] -> Gen a
فعلاً یه کم با اینها تو REPL بازی کنین؛ بعداً به کار میان.
استفاده از QuickCheck
بدونِ Hspec
از QuickCheck
بدون Hspec
هم میشه استفاده کرد. در این مورد دیگه لازم نیست تایپِ x
رو داخل بیانیهمون تعیین کنیم، چون تایپِ prop_additionGreater
مشخصش کرده. پس مثال قبلیمون رو اینطور بازنویسی میکنیم:
prop_additionGreater :: Int -> Bool
prop_additionGreater x = x + 1 > x
runQc :: IO ()
runQc = quickCheck prop_additionGreater
فعلاً لازم نیست طرز کارِ runQc
رو بدونیم. یه تابعِ کلی مثل main
ِه که میگه الان وقتِ انجامِ کاره. و اینجا میگه وقتِ پیادهسازی تستهای QuickCheck
ِه.
وقتی تو REPL این تابعِ runQc
رو اجرا میکنیم (برخلافِ hspec
که main
رو اجرا میکردیم)، از طریقِ QuickCheck
، مشخصهای که تعریف کردیم رو تست میکنه. حالا که مستقیماً QuickCheck
رو اجرا میکنیم، گزارش میده چندتا تست رو بررسی کرد (که میشه گفت اون موقع زیرِ hspec
مخفی میشد):
Prelude> runQc
+++ OK, passed 100 tests.
اگه بهش کذب بدیم چی میشه؟
prop_additionGreater x = x + 0 > x
Prelude> :r
[1 of 1] Compiling Addition
Ok, modules loaded: Addition.
Prelude> runQc
*** Failed! Falsifiable (after 1 test):
0
یکی از خوبیهای QuickCheck
اینه که اون مقداری که باعث خطا شده رو هم میگه. اگه چند بارِ دیگه تست کنین، متوجه میشین هر دفعه با صفر شکست میخوره. قبلاً هم گفتیم QuickCheck
سعی میکنه همیشه حالتهای مرزیِ رایج رو تست کنه. ورودیِ صفر یکی از نقاطِ شکست رایجه، پس QuickCheck
هم اکثراً سعی میکنه تست شدنش رو تضمین کنه (با توجه به تایپ و شرایط و غیره).