packages feed

tinycheck-0.1.0.0: src/Test/Tasty/TinyCheck.hs

{- | Tasty integration for tinycheck.

Usage:

>>> import Test.Tasty
>>> import Test.Tasty.TinyCheck
>>> import Data.List (nub, reverse)
>>> :{
defaultMain $ testGroup "My suite"
[ testProperty "reverse . reverse == id" $
    \(xs :: [Int]) -> reverse (reverse xs) == xs
, testProperty "length . nub <= length" $
    \(xs :: [Int]) -> length (nub xs) <= length xs
]
:}
-}
module Test.Tasty.TinyCheck (
  -- * Defining properties
  Property,
  property,
  (==>),

  -- * Checking properties
  Testable (..),

  -- * Creating test trees
  testProperty,
  testPropertyWith,
  expectFailureWith,
  expectFailureWithN,

  -- * Options
  TinyCheckTests (..),
)
where

-- base
import Control.Monad (void)
import Data.List (isInfixOf)
import Data.Proxy (Proxy (..))

-- tagged
import Data.Tagged (Tagged (..))

-- tasty
import Test.Tasty (localOption)
import Test.Tasty.Options (IsOption (..), OptionDescription (..), lookupOption, safeRead)
import Test.Tasty.Providers (IsTest (..), TestName, TestTree, singleTest, testFailed, testPassed)
import Test.Tasty.Runners (Outcome (..), Result (..))

-- tinycheck
import Data.TestCases (Arbitrary, TestCases (..), arbitrary)

-- * Property

{- | A testable property: a 'TestCases' of outcomes, each being either
'Right ()' for a pass or 'Left msg' for a failure with a message.

Build one with 'property' (or its operator alias '==>') or simply
use a function @a -> Bool@ / @a -> Property@ directly via 'testProperty'.
-}
newtype Property = Property (TestCases (Either String ()))

-- | Promote a 'Bool' to a 'Property'.  Failures carry no extra message.
property :: Bool -> Property
property True = Property $ TestCases [Right ()]
property False = Property $ TestCases [Left "falsified"]

{- | Conditional property: if the precondition is 'False' the test case is
/discarded/ (treated as a pass), just like QuickCheck's @==>@.
-}
(==>) :: Bool -> Property -> Property
True ==> p = p
False ==> _ = Property $ TestCases [Right ()]

infixr 0 ==>

-- * Testable class

-- | Types that can be converted to a 'Property'.
class Testable a where
  toProperty :: a -> Property

instance Testable Bool where
  toProperty = property

instance Testable Property where
  toProperty = id

instance (Arbitrary a, Show a, Testable prop) => Testable (a -> prop) where
  toProperty f = Property $ do
    a <- arbitrary
    let Property outcomes = toProperty (f a)
    -- Annotate failures with the offending input.
    fmap (void . prependInput a) outcomes
    where
      prependInput a (Left msg) = Left $ show a <> "\n" <> msg
      prependInput _ r = r

-- * Option: number of test cases

{- | How many test cases tinycheck should check per property.
Defaults to 10 000.  Can be set via @--tinycheck-tests N@ on the command line.
-}
newtype TinyCheckTests = TinyCheckTests Int
  deriving stock (Show, Eq, Ord)

instance IsOption TinyCheckTests where
  defaultValue = TinyCheckTests 10_000
  parseValue s = TinyCheckTests <$> safeRead s
  optionName = Tagged "tinycheck-tests"
  optionHelp = Tagged "Number of test cases for TinyCheck properties (default: 10000)"

-- * IsTest instance

-- | A named property ready to be run as a tasty test.
data TinyCheckTest = forall a. (Testable a) => TinyCheckTest a

instance IsTest TinyCheckTest where
  testOptions = Tagged [Option (Proxy @TinyCheckTests)]

  run opts (TinyCheckTest prop) _progress = do
    let TinyCheckTests n = lookupOption opts
        Property cases = toProperty prop
        results = take n (getTestCases cases)
    case sequence_ results of
      Right () -> pure $ testPassed $ "OK, checked " <> show (length results) <> " cases"
      Left msg -> pure $ testFailed msg

-- * Public API

{- | Create a 'TestTree' leaf from a 'Testable' property.

The property can be a plain 'Bool', a 'Property', or a function
@a -> Bool@ / @a -> Property@ for any @'Arbitrary' a@.
-}
testProperty :: (Testable a) => TestName -> a -> TestTree
testProperty name prop = singleTest name (TinyCheckTest prop)

-- | Like 'testProperty', but run exactly @n@ test cases instead of the suite-wide default.
testPropertyWith :: (Testable a) => Int -> TestName -> a -> TestTree
testPropertyWith n name prop = localOption (TinyCheckTests n) $ testProperty name prop

{- | Assert that a property /fails/ and that the failure message contains
the given substring.  The test passes iff the property fails with a
matching message; it fails if the property passes, or if it fails with
an unexpected message.
-}
expectFailureWith :: (Testable a) => String -> TestName -> a -> TestTree
expectFailureWith needle name prop = singleTest name (ExpectFailure needle (TinyCheckTest prop))

-- | Like 'expectFailureWith', but check exactly @n@ test cases instead of the suite-wide default.
expectFailureWithN :: (Testable a) => Int -> String -> TestName -> a -> TestTree
expectFailureWithN n needle name prop = localOption (TinyCheckTests n) $ expectFailureWith needle name prop

-- | Internal wrapper used by 'expectFailureWith'.
data ExpectFailure = ExpectFailure String TinyCheckTest

instance IsTest ExpectFailure where
  testOptions = Tagged [Option (Proxy @TinyCheckTests)]

  run opts (ExpectFailure needle inner) progress = do
    result <- run opts inner progress
    pure $ case resultOutcome result of
      Failure _ ->
        let msg = resultDescription result
         in if needle `isInfixOf` msg
              then testPassed $ "Got expected failure containing " <> show needle
              else
                testFailed $
                  "Property failed as expected, but message\n"
                    <> show msg
                    <> "\ndoes not contain "
                    <> show needle
      Success ->
        testFailed "Expected the property to fail, but it passed"