۱۴ - ۴به 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 > xPrelude> :r
[1 of 1] Compiling Addition
Ok, modules loaded: Addition.
Prelude> runQc
*** Failed! Falsifiable (after 1 test):
0یکی از خوبیهای QuickCheck اینه که اون مقداری که باعث خطا شده رو هم میگه. اگه چند بارِ دیگه تست کنین، متوجه میشین هر دفعه با صفر شکست میخوره. قبلاً هم گفتیم QuickCheck سعی میکنه همیشه حالتهای مرزیِ رایج رو تست کنه. ورودیِ صفر یکی از نقاطِ شکست رایجه، پس QuickCheck هم اکثراً سعی میکنه تست شدنش رو تضمین کنه (با توجه به تایپ و شرایط و غیره).