packages feed

tasty-flaky-0.1.0.0: src/Test/Tasty/Flaky.hs

{-# OPTIONS_GHC -Wno-redundant-constraints #-}
{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE ScopedTypeVariables #-}

-----------------------------------------------------------------------------
-- |
-- Module      :  $module
-- Copyright   :  (c) Powerweave Inc.
-- License     :  BSD-3-Clause
-- Maintainer  :  Laurent René de Cotret
-- Portability :  portable
--
-- This module defines a single function, 'flakyTest', to declare a test
-- which intermittently fails. Flaky tests can be retries using retry policies
-- provided by the "Control.Retry" module (from the @retry@ package).
--
--
-- For example, you can retry test cases from @tasty-hunit@ like so:
--
-- @
-- import Test.Tasty.HUnit ( testCase ) -- from tasty-hunit
-- 
-- myFlakyTest :: TestTree
-- myFlakyTest = 'flakyTest' ('limitRetries' 5 <> 'constantDelay' 1000) $ testCase "some test case" $ do ... 
-- @
--
-- In the example above, the test will be retried up to 5 times, with a delay of 1000 microseconds between tries,
-- if a failure occurs.
--
module Test.Tasty.Flaky (
    -- * Test wrapper
    flakyTest

    -- * Re-exports
    -- 
    -- | The following functions allow to construct 'RetryPolicyM IO' 
    -- from the "Control.Retry" module.
    , constantDelay
    , exponentialBackoff
    , fullJitterBackoff
    , fibonacciBackoff
    , limitRetries

    -- * Policy Transformers
    , limitRetriesByDelay
    , limitRetriesByCumulativeDelay
    , capDelay

) where

import Control.Retry hiding (RetryPolicy)
import Data.Functor ( (<&>) )
import Data.Tagged (Tagged, retag )
import Test.Tasty.Providers ( IsTest(..), Progress, Result, TestTree )
import Test.Tasty.Runners ( TestTree(..), Result(..), Progress(..), emptyProgress, resultSuccessful )
import Test.Tasty.Options ( OptionDescription, OptionSet )


-- | A test tree of type @t@, with an associated retry policy
data FlakyTest t
    = MkFlakyTest (RetryPolicyM IO) t


-- | Mark any test as flaky.
--
-- If this test is not successful, it will be retried according to the supplied @'RetryPolicyM' 'IO'@. 
-- See "Control.Retry" for documentation on how to specify a @'RetryPolicyM' 'IO'@.
--
-- For example, you can retry test cases from @tasty-hunit@ like so:
--
-- @
-- import Test.Tasty.HUnit ( testCase ) -- from tasty-hunit
-- 
-- myFlakyTest :: TestTree
-- myFlakyTest = 'flakyTest' ('limitRetries' 5 <> 'constantDelay' 1000) $ testCase "some test case" $ do ... 
-- @
--
flakyTest :: (RetryPolicyM IO) -> TestTree -> TestTree
flakyTest policy (SingleTest name t)           = SingleTest name (MkFlakyTest policy t)
flakyTest policy (TestGroup name subtree)      = TestGroup name (map (flakyTest policy) subtree)
flakyTest policy (PlusTestOptions modOption t) = PlusTestOptions modOption (flakyTest policy t)
flakyTest policy (WithResource spec f)         = WithResource spec (f <&> flakyTest policy)
flakyTest policy (AskOptions f)                = AskOptions $ \optionSet -> flakyTest policy (f optionSet)
flakyTest policy (After depType expr t)        = After depType expr (flakyTest policy t)


instance IsTest t => IsTest (FlakyTest t) where
    run :: IsTest t => OptionSet -> FlakyTest t -> (Progress -> IO ()) -> IO Result
    run opts (MkFlakyTest policy test) progressCallback = go defaultRetryStatus
        where
            -- The logic below mimics the `retry` package's Control.Retry.retrying
            -- with one major difference: we annotate the final result
            -- to report how many retries have been performed, regardless of
            -- the final result.
            go :: RetryStatus -> IO Result
            go status = do
                result <- run opts test progressCallback
                let consultPolicy policy' = do
                        rs <- applyAndDelay policy' status
                        case rs of
                            -- We are done: no more retries
                            Nothing -> pure $ annotateResult status result
                            -- At least one more retry
                            Just rs' -> do 
                                progressCallback (annotateProgress status)
                                go $! rs'

                if resultSuccessful result
                    then pure $ annotateResult status result
                    else consultPolicy policy
            
            annotateProgress :: RetryStatus -> Progress
            annotateProgress status 
                -- Recall that `rsIterNumber` starts at 0, so the first attempt is rsIterNumber + 1
                = emptyProgress{progressText=mconcat ["Attempt #", show (rsIterNumber status + 1), " failed"]}
            
            annotateResult :: RetryStatus -> Result -> Result
            annotateResult status result 
                = result { resultDescription = resultDescription result <> annotate status }
                where
                    annotate :: RetryStatus -> String
                    annotate (RetryStatus iternum cumdelay _) 
                        | iternum == 0 = ""
                        | otherwise    = mconcat [" [", show iternum, " retries, ", show cumdelay, " μs delay]"]


    testOptions :: Tagged (FlakyTest t) [OptionDescription]
    testOptions = retag (testOptions :: Tagged t [OptionDescription])