۱۴ - ۴به 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‎‏ هم اکثراً سعی می‌کنه تست شدن‌ش رو تضمین کنه (با توجه به تایپ و شرایط و غیره).