packages feed

sydtest-0.19.0.0: src/Test/Syd/SpecDef.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DeriveTraversable #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE IncoherentInstances #-}
{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}

-- | This module defines all the functions you will use to define your test suite.
module Test.Syd.SpecDef where

import Control.Monad
import Control.Monad.Random
import Data.DList (DList)
import qualified Data.DList as DList
import Data.Foldable (find)
import Data.Kind
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.List.NonEmpty as NE
import Data.Maybe
import Data.Text (Text)
import qualified Data.Text as T
import Data.Word
import GHC.Generics (Generic)
import GHC.Stack
import System.Random.Shuffle
import Test.QuickCheck.IO ()
import Test.Syd.HList
import Test.Syd.OptParse
import Test.Syd.Run
import Test.Syd.SpecForest

data TDef value = TDef {testDefVal :: value, testDefCallStack :: CallStack}
  deriving (Functor, Foldable, Traversable)

type TestForest outers inner = SpecDefForest outers inner ()

type TestTree outers inner = SpecDefTree outers inner ()

type SpecDefForest (outers :: [Type]) inner extra = [SpecDefTree outers inner extra]

-- | A tree of tests
--
-- This type has three parameters:
--
-- * @outers@: A type-level list of the outer resources. These are resources that are prived once, around a group of tests. (This is the type of the results of `aroundAll`.)
-- * @inner@: The inner resource. This is a resource that is set up around every test, and even every example of a property test. (This is the type of the result of `around`.)
-- * @result@: The result ('TestDefM' is a monad.)
--
-- In practice, all of these three parameters should be '()' at the top level.
--
-- When you're just using sydtest and not writing a library for sydtest, you probably don't even want to concern yourself with this type.
data SpecDefTree (outers :: [Type]) inner extra where
  -- | Define a test
  DefSpecifyNode ::
    -- | The description of the test
    Text ->
    -- | How the test can be run given a function that provides the resources
    TDef (ProgressReporter -> ((HList outers -> inner -> IO ()) -> IO ()) -> IO TestRunResult) ->
    extra ->
    SpecDefTree outers inner extra
  -- | Define a pending test
  DefPendingNode ::
    -- | The description of the test
    Text ->
    -- | The reason why the test is pending
    Maybe Text ->
    SpecDefTree outers inner extra
  -- | Group tests using a description
  DefDescribeNode ::
    -- | The description
    Text ->
    SpecDefForest outers inner extra ->
    SpecDefTree outers inner extra
  DefSetupNode ::
    -- | The function that runs before the test
    IO () ->
    SpecDefForest outers inner extra ->
    SpecDefTree outers inner extra
  DefBeforeAllNode ::
    -- | The function to run (once), beforehand, to produce the outer resource.
    IO outer ->
    SpecDefForest (outer ': otherOuters) inner extra ->
    SpecDefTree otherOuters inner extra
  DefBeforeAllWithNode ::
    -- | The function to run (once), beforehand, to produce the outer resource.
    (oldOuter -> IO newOuter) ->
    SpecDefForest (newOuter ': oldOuter ': otherOuters) inner extra ->
    SpecDefTree (oldOuter ': otherOuters) inner extra
  DefWrapNode ::
    -- | The function that wraps running the tests.
    (IO () -> IO ()) ->
    SpecDefForest outers inner extra ->
    SpecDefTree outers inner extra
  DefAroundAllNode ::
    -- | The function that provides the outer resource (once), around the tests.
    ((outer -> IO ()) -> IO ()) ->
    SpecDefForest (outer ': otherOuters) inner extra ->
    SpecDefTree otherOuters inner extra
  DefAroundAllWithNode ::
    -- | The function that provides the new outer resource (once), using the old outer resource.
    ((newOuter -> IO ()) -> (oldOuter -> IO ())) ->
    SpecDefForest (newOuter ': oldOuter ': otherOuters) inner extra ->
    SpecDefTree (oldOuter ': otherOuters) inner extra
  DefAfterAllNode ::
    -- | The function to run (once), afterwards, using all outer resources.
    (HList outers -> IO ()) ->
    SpecDefForest outers inner extra ->
    SpecDefTree outers inner extra
  -- | Control the level of parallelism for a given group of tests
  DefParallelismNode ::
    -- | The level of parallelism
    Parallelism ->
    SpecDefForest outers inner extra ->
    SpecDefTree outers inner extra
  -- | Control the execution order randomisation for a given group of tests
  DefRandomisationNode ::
    -- | The execution order randomisation
    ExecutionOrderRandomisation ->
    SpecDefForest outers inner extra ->
    SpecDefTree outers inner extra
  DefTimeoutNode ::
    -- | Modify the timeout setting
    (Timeout -> Timeout) ->
    SpecDefForest outers inner extra ->
    SpecDefTree outers inner extra
  DefRetriesNode ::
    -- | Modify the number of retries
    (Word -> Word) ->
    SpecDefForest outers inner extra ->
    SpecDefTree outers inner extra
  DefFlakinessNode ::
    -- | Whether to allow flakiness
    FlakinessMode ->
    SpecDefForest outers inner extra ->
    SpecDefTree outers inner extra
  DefExpectationNode ::
    -- | Whether to expect passing or failing
    ExpectationMode ->
    SpecDefForest outers inner extra ->
    SpecDefTree outers inner extra

instance Functor (SpecDefTree a c) where
  fmap :: forall e f. (e -> f) -> SpecDefTree a c e -> SpecDefTree a c f
  fmap f =
    let goF :: forall x y. SpecDefForest x y e -> SpecDefForest x y f
        goF = map (fmap f)
     in \case
          DefDescribeNode t sdf -> DefDescribeNode t $ goF sdf
          DefPendingNode t mr -> DefPendingNode t mr
          DefSpecifyNode t td e -> DefSpecifyNode t td (f e)
          DefSetupNode func sdf -> DefSetupNode func $ goF sdf
          DefBeforeAllNode func sdf -> DefBeforeAllNode func $ goF sdf
          DefBeforeAllWithNode func sdf -> DefBeforeAllWithNode func $ goF sdf
          DefWrapNode func sdf -> DefWrapNode func $ goF sdf
          DefAroundAllNode func sdf -> DefAroundAllNode func $ goF sdf
          DefAroundAllWithNode func sdf -> DefAroundAllWithNode func $ goF sdf
          DefAfterAllNode func sdf -> DefAfterAllNode func $ goF sdf
          DefParallelismNode p sdf -> DefParallelismNode p $ goF sdf
          DefRandomisationNode p sdf -> DefRandomisationNode p $ goF sdf
          DefTimeoutNode p sdf -> DefTimeoutNode p $ goF sdf
          DefRetriesNode p sdf -> DefRetriesNode p $ goF sdf
          DefFlakinessNode p sdf -> DefFlakinessNode p $ goF sdf
          DefExpectationNode p sdf -> DefExpectationNode p $ goF sdf

instance Foldable (SpecDefTree a c) where
  foldMap :: forall e m. (Monoid m) => (e -> m) -> SpecDefTree a c e -> m
  foldMap f =
    let goF :: forall x y. SpecDefForest x y e -> m
        goF = foldMap (foldMap f)
     in \case
          DefDescribeNode _ sdf -> goF sdf
          DefPendingNode _ _ -> mempty
          DefSpecifyNode _ _ e -> f e
          DefSetupNode _ sdf -> goF sdf
          DefBeforeAllNode _ sdf -> goF sdf
          DefBeforeAllWithNode _ sdf -> goF sdf
          DefWrapNode _ sdf -> goF sdf
          DefAroundAllNode _ sdf -> goF sdf
          DefAroundAllWithNode _ sdf -> goF sdf
          DefAfterAllNode _ sdf -> goF sdf
          DefParallelismNode _ sdf -> goF sdf
          DefRandomisationNode _ sdf -> goF sdf
          DefTimeoutNode _ sdf -> goF sdf
          DefRetriesNode _ sdf -> goF sdf
          DefFlakinessNode _ sdf -> goF sdf
          DefExpectationNode _ sdf -> goF sdf

instance Traversable (SpecDefTree a c) where
  traverse :: forall u w f. (Applicative f) => (u -> f w) -> SpecDefTree a c u -> f (SpecDefTree a c w)
  traverse f =
    let goF :: forall x y. SpecDefForest x y u -> f (SpecDefForest x y w)
        goF = traverse (traverse f)
     in \case
          DefDescribeNode t sdf -> DefDescribeNode t <$> goF sdf
          DefPendingNode t mr -> pure $ DefPendingNode t mr
          DefSpecifyNode t td e -> DefSpecifyNode t td <$> f e
          DefSetupNode func sdf -> DefSetupNode func <$> goF sdf
          DefBeforeAllNode func sdf -> DefBeforeAllNode func <$> goF sdf
          DefBeforeAllWithNode func sdf -> DefBeforeAllWithNode func <$> goF sdf
          DefWrapNode func sdf -> DefWrapNode func <$> goF sdf
          DefAroundAllNode func sdf -> DefAroundAllNode func <$> goF sdf
          DefAroundAllWithNode func sdf -> DefAroundAllWithNode func <$> goF sdf
          DefAfterAllNode func sdf -> DefAfterAllNode func <$> goF sdf
          DefParallelismNode p sdf -> DefParallelismNode p <$> goF sdf
          DefRandomisationNode p sdf -> DefRandomisationNode p <$> goF sdf
          DefTimeoutNode p sdf -> DefTimeoutNode p <$> goF sdf
          DefRetriesNode p sdf -> DefRetriesNode p <$> goF sdf
          DefFlakinessNode p sdf -> DefFlakinessNode p <$> goF sdf
          DefExpectationNode p sdf -> DefExpectationNode p <$> goF sdf

filterTestForest :: [Text] -> SpecDefForest outers inner result -> SpecDefForest outers inner result
filterTestForest fs = fromMaybe [] . goForest DList.empty
  where
    goForest :: DList Text -> SpecDefForest a b c -> Maybe (SpecDefForest a b c)
    goForest ts sdf = do
      let sdf' = mapMaybe (goTree ts) sdf
      guard $ not $ null sdf'
      pure sdf'

    filterGuard :: DList Text -> Bool
    filterGuard dl =
      null fs
        || any (\f -> f `T.isInfixOf` T.intercalate "." (DList.toList dl)) fs

    goTree :: DList Text -> SpecDefTree a b c -> Maybe (SpecDefTree a b c)
    goTree dl = \case
      DefSpecifyNode t td e -> do
        let tl = DList.snoc dl t
        guard $ filterGuard tl
        pure $ DefSpecifyNode t td e
      DefPendingNode t mr -> do
        let tl = DList.snoc dl t
        guard $ filterGuard tl
        pure $ DefPendingNode t mr
      DefDescribeNode t sdf -> DefDescribeNode t <$> goForest (DList.snoc dl t) sdf
      DefSetupNode func sdf -> DefSetupNode func <$> goForest dl sdf
      DefBeforeAllNode func sdf -> DefBeforeAllNode func <$> goForest dl sdf
      DefBeforeAllWithNode func sdf -> DefBeforeAllWithNode func <$> goForest dl sdf
      DefWrapNode func sdf -> DefWrapNode func <$> goForest dl sdf
      DefAroundAllNode func sdf -> DefAroundAllNode func <$> goForest dl sdf
      DefAroundAllWithNode func sdf -> DefAroundAllWithNode func <$> goForest dl sdf
      DefAfterAllNode func sdf -> DefAfterAllNode func <$> goForest dl sdf
      DefParallelismNode func sdf -> DefParallelismNode func <$> goForest dl sdf
      DefRandomisationNode func sdf -> DefRandomisationNode func <$> goForest dl sdf
      DefTimeoutNode func sdf -> DefTimeoutNode func <$> goForest dl sdf
      DefRetriesNode func sdf -> DefRetriesNode func <$> goForest dl sdf
      DefFlakinessNode func sdf -> DefFlakinessNode func <$> goForest dl sdf
      DefExpectationNode func sdf -> DefExpectationNode func <$> goForest dl sdf

randomiseTestForest :: (MonadRandom m) => SpecDefForest outers inner result -> m (SpecDefForest outers inner result)
randomiseTestForest = goForest
  where
    goForest :: (MonadRandom m) => SpecDefForest a b c -> m (SpecDefForest a b c)
    goForest = traverse goTree >=> shuffleM
    goTree :: (MonadRandom m) => SpecDefTree a b c -> m (SpecDefTree a b c)
    goTree = \case
      DefSpecifyNode t td e -> pure $ DefSpecifyNode t td e
      DefPendingNode t mr -> pure $ DefPendingNode t mr
      DefDescribeNode t sdf -> DefDescribeNode t <$> goForest sdf
      DefSetupNode func sdf -> DefSetupNode func <$> goForest sdf
      DefBeforeAllNode func sdf -> DefBeforeAllNode func <$> goForest sdf
      DefBeforeAllWithNode func sdf -> DefBeforeAllWithNode func <$> goForest sdf
      DefWrapNode func sdf -> DefWrapNode func <$> goForest sdf
      DefAroundAllNode func sdf -> DefAroundAllNode func <$> goForest sdf
      DefAroundAllWithNode func sdf -> DefAroundAllWithNode func <$> goForest sdf
      DefAfterAllNode func sdf -> DefAfterAllNode func <$> goForest sdf
      DefParallelismNode func sdf -> DefParallelismNode func <$> goForest sdf
      DefTimeoutNode i sdf -> DefTimeoutNode i <$> goForest sdf
      DefRetriesNode i sdf -> DefRetriesNode i <$> goForest sdf
      DefFlakinessNode i sdf -> DefFlakinessNode i <$> goForest sdf
      DefExpectationNode i sdf -> DefExpectationNode i <$> goForest sdf
      DefRandomisationNode eor sdf ->
        DefRandomisationNode eor <$> case eor of
          RandomiseExecutionOrder -> goForest sdf
          DoNotRandomiseExecutionOrder -> pure sdf

markSpecForestAsPending :: Maybe Text -> SpecDefForest outers inner result -> SpecDefForest outers inner result
markSpecForestAsPending mMessage = goForest
  where
    goForest :: SpecDefForest a b c -> SpecDefForest a b c
    goForest = map goTree

    goTree :: SpecDefTree a b c -> SpecDefTree a b c
    goTree = \case
      DefSpecifyNode t _ _ -> DefPendingNode t mMessage
      DefPendingNode t mr -> DefPendingNode t mr
      DefDescribeNode t sdf -> DefDescribeNode t $ goForest sdf
      DefSetupNode func sdf -> DefSetupNode func $ goForest sdf
      DefBeforeAllNode func sdf -> DefBeforeAllNode func $ goForest sdf
      DefBeforeAllWithNode func sdf -> DefBeforeAllWithNode func $ goForest sdf
      DefWrapNode func sdf -> DefWrapNode func $ goForest sdf
      DefAroundAllNode func sdf -> DefAroundAllNode func $ goForest sdf
      DefAroundAllWithNode func sdf -> DefAroundAllWithNode func $ goForest sdf
      DefAfterAllNode func sdf -> DefAfterAllNode func $ goForest sdf
      DefParallelismNode func sdf -> DefParallelismNode func $ goForest sdf
      DefTimeoutNode i sdf -> DefTimeoutNode i $ goForest sdf
      DefRetriesNode i sdf -> DefRetriesNode i $ goForest sdf
      DefFlakinessNode i sdf -> DefFlakinessNode i $ goForest sdf
      DefRandomisationNode eor sdf -> DefRandomisationNode eor (goForest sdf)
      DefExpectationNode i sdf -> DefExpectationNode i $ goForest sdf

data Parallelism
  = Parallel
  | Sequential
  deriving (Show, Eq, Generic)

data ExecutionOrderRandomisation
  = RandomiseExecutionOrder
  | DoNotRandomiseExecutionOrder
  deriving (Show, Eq, Generic)

data FlakinessMode
  = MayNotBeFlaky
  | MayBeFlaky !(Maybe String) -- A message to show whenever the test is flaky.
  deriving (Show, Eq, Generic)

data ExpectationMode
  = ExpectPassing
  | ExpectFailing
  deriving (Show, Eq, Generic)

type ResultForest = SpecForest (TDef (Timed TestRunReport))

type ResultTree = SpecTree (TDef (Timed TestRunReport))

computeTestSuiteStats :: Settings -> ResultForest -> TestSuiteStats
computeTestSuiteStats settings = goF []
  where
    goF :: [Text] -> ResultForest -> TestSuiteStats
    goF ts = foldMap (goT ts)
    goT :: [Text] -> ResultTree -> TestSuiteStats
    goT ts = \case
      SpecifyNode _ (TDef timed@Timed {..} _) ->
        let status = testRunReportStatus settings timedValue
         in TestSuiteStats
              { testSuiteStatSuccesses = case status of
                  TestPassed -> 1
                  TestFailed -> 0,
                testSuiteStatExamples =
                  testRunReportExamples timedValue,
                testSuiteStatFailures = case status of
                  TestPassed -> 0
                  TestFailed -> 1,
                testSuiteStatFlakyTests =
                  if testRunReportWasFlaky timedValue
                    then 1
                    else 0,
                testSuiteStatPending = 0,
                testSuiteStatSumTime = timedTime timed
              }
      PendingNode _ _ ->
        TestSuiteStats
          { testSuiteStatSuccesses = 0,
            testSuiteStatExamples = 0,
            testSuiteStatFailures = 0,
            testSuiteStatFlakyTests = 0,
            testSuiteStatPending = 1,
            testSuiteStatSumTime = 0
          }
      DescribeNode t sf -> goF (t : ts) sf
      SubForestNode sf -> goF ts sf

data TestSuiteStats = TestSuiteStats
  { testSuiteStatSuccesses :: !Word,
    testSuiteStatExamples :: !Word,
    testSuiteStatFailures :: !Word,
    testSuiteStatFlakyTests :: !Word,
    testSuiteStatPending :: !Word,
    testSuiteStatSumTime :: !Word64
  }
  deriving (Show, Eq)

instance Semigroup TestSuiteStats where
  (<>) tss1 tss2 =
    TestSuiteStats
      { testSuiteStatSuccesses = testSuiteStatSuccesses tss1 + testSuiteStatSuccesses tss2,
        testSuiteStatExamples = testSuiteStatExamples tss1 + testSuiteStatExamples tss2,
        testSuiteStatFailures = testSuiteStatFailures tss1 + testSuiteStatFailures tss2,
        testSuiteStatFlakyTests = testSuiteStatFlakyTests tss1 + testSuiteStatFlakyTests tss2,
        testSuiteStatPending = testSuiteStatPending tss1 + testSuiteStatPending tss2,
        testSuiteStatSumTime = testSuiteStatSumTime tss1 + testSuiteStatSumTime tss2
      }

instance Monoid TestSuiteStats where
  mappend = (<>)
  mempty =
    TestSuiteStats
      { testSuiteStatSuccesses = 0,
        testSuiteStatExamples = 0,
        testSuiteStatFailures = 0,
        testSuiteStatFlakyTests = 0,
        testSuiteStatPending = 0,
        testSuiteStatSumTime = 0
      }

shouldExitFail :: Settings -> ResultForest -> Bool
shouldExitFail settings = any (any (testRunReportFailed settings . timedValue . testDefVal))

data TestRunReport = TestRunReport
  { testRunReportExpectationMode :: !ExpectationMode,
    -- | Raw results, including retries, in order
    testRunReportRawResults :: !(NonEmpty TestRunResult),
    testRunReportFlakinessMode :: !FlakinessMode
  }
  deriving (Show, Generic)

testRunReportReportedRun :: TestRunReport -> TestRunResult
testRunReportReportedRun TestRunReport {..} =
  -- We always want to report the last failure if there are any failures.
  -- This is because a passed test does not give us any information, and we
  -- only want to do that if there are no failures.
  let reversed = NE.reverse testRunReportRawResults
   in case find ((== TestFailed) . testRunResultStatus) testRunReportRawResults of
        Nothing -> NE.head reversed
        Just trr -> trr

testRunReportFailed :: Settings -> TestRunReport -> Bool
testRunReportFailed settings testRunReport =
  testRunReportStatus settings testRunReport /= TestPassed

testRunReportStatus :: Settings -> TestRunReport -> TestStatus
testRunReportStatus Settings {..} testRunReport@TestRunReport {..} =
  let wasFlaky = testRunReportWasFlaky testRunReport
      lastResult = NE.last testRunReportRawResults
      actualStatus = case testRunReportFlakinessMode of
        MayNotBeFlaky ->
          if wasFlaky
            then TestFailed
            else testRunResultStatus lastResult
        MayBeFlaky _ ->
          if settingFailOnFlaky && wasFlaky
            then TestFailed
            else
              if any ((== TestPassed) . testRunResultStatus) testRunReportRawResults
                then TestPassed
                else TestFailed
      consideredStatus =
        if testStatusMatchesExpectationMode actualStatus testRunReportExpectationMode
          then TestPassed
          else TestFailed
   in consideredStatus

testStatusMatchesExpectationMode :: TestStatus -> ExpectationMode -> Bool
testStatusMatchesExpectationMode actualStatus expectationMode = case (actualStatus, expectationMode) of
  (TestPassed, ExpectPassing) -> True
  (TestFailed, ExpectFailing) -> True
  _ -> False

testRunReportExamples :: TestRunReport -> Word
testRunReportExamples = sum . NE.map testRunResultExamples . testRunReportRawResults

testRunResultExamples :: TestRunResult -> Word
testRunResultExamples TestRunResult {..} =
  fromMaybe 1 testRunResultNumTests + fromMaybe 0 testRunResultNumShrinks

testRunReportWasFlaky :: TestRunReport -> Bool
testRunReportWasFlaky =
  (> 1)
    . length
    . NE.group
    . NE.map testRunResultStatus
    . testRunReportRawResults

testRunReportRetries :: TestRunReport -> Maybe Word
testRunReportRetries TestRunReport {..} = case NE.length testRunReportRawResults of
  1 -> Nothing
  l -> Just $ fromIntegral l