2022-02-14 20:24:24 +03:00
|
|
|
{-# LANGUAGE DeriveAnyClass #-}
|
|
|
|
|
2021-11-17 22:50:39 +03:00
|
|
|
-- | Helper functions for easily testing features.
|
2022-02-21 20:05:09 +03:00
|
|
|
module Harness.Test.Context
|
2022-02-14 20:24:24 +03:00
|
|
|
( run,
|
|
|
|
runWithLocalState,
|
|
|
|
Context (..),
|
2022-02-21 20:05:09 +03:00
|
|
|
ContextName (..),
|
|
|
|
noLocalState,
|
2022-02-14 20:24:24 +03:00
|
|
|
Options (..),
|
2022-02-18 16:35:32 +03:00
|
|
|
combineOptions,
|
2022-02-14 20:24:24 +03:00
|
|
|
defaultOptions,
|
2021-11-17 22:50:39 +03:00
|
|
|
)
|
|
|
|
where
|
|
|
|
|
2022-02-18 16:35:32 +03:00
|
|
|
import Control.Applicative ((<|>))
|
2022-02-14 20:24:24 +03:00
|
|
|
import Control.Exception.Safe (Exception, SomeException, catch, mask, throwIO)
|
|
|
|
import Data.Foldable (for_)
|
2022-02-18 16:35:32 +03:00
|
|
|
import Data.Maybe (fromMaybe)
|
2021-11-23 21:15:17 +03:00
|
|
|
import Harness.State (State)
|
2022-02-18 16:35:32 +03:00
|
|
|
import Test.Hspec (ActionWith, HasCallStack, SpecWith, aroundAllWith, describe)
|
2022-02-14 20:24:24 +03:00
|
|
|
import Test.Hspec.Core.Spec (Item (..), mapSpecItem)
|
2021-11-17 22:50:39 +03:00
|
|
|
import Prelude
|
|
|
|
|
2022-02-14 20:24:24 +03:00
|
|
|
--------------------------------------------------------------------------------
|
2022-02-21 20:05:09 +03:00
|
|
|
-- Context
|
2022-02-14 20:24:24 +03:00
|
|
|
|
|
|
|
-- | Runs the given tests, for each provided 'Context'@ ()@.
|
|
|
|
--
|
|
|
|
-- Each 'Context' provides distinct setup and teardown functions; 'run'
|
|
|
|
-- guarantees that the associated 'teardown' function is always called after a
|
|
|
|
-- setup, even if the tests fail.
|
|
|
|
--
|
|
|
|
-- See 'Context' for details.
|
|
|
|
--
|
|
|
|
-- This function restricts the local state parameter for 'Context' to be '()',
|
|
|
|
-- indicating that there should be _no_ local state.
|
|
|
|
--
|
|
|
|
-- For a more general version that can run tests for any 'Context'@ a@, see
|
|
|
|
-- 'runWithLocalState'.
|
|
|
|
run :: [Context ()] -> (Options -> SpecWith State) -> SpecWith State
|
|
|
|
run contexts tests = do
|
|
|
|
let mappedTests opts =
|
|
|
|
mapSpecItem
|
|
|
|
actionWithStateMapping
|
|
|
|
(mapItemAction actionWithStateMapping)
|
|
|
|
(tests opts)
|
|
|
|
runWithLocalState contexts mappedTests
|
2021-11-17 22:50:39 +03:00
|
|
|
|
2022-02-14 20:24:24 +03:00
|
|
|
-- | Observe that there is a direct correspondance (i.e. an isomorphism) from
|
|
|
|
-- @State@ to @(State, ())@ within 'ActionWith'.
|
|
|
|
--
|
|
|
|
-- NOTE: 'ActionWith'@ a@ is a type alias for @a -> IO ()@; thus, the fully
|
|
|
|
-- expanded type signature here is @(State -> IO ()) -> (State, ()) -> IO ()@.
|
|
|
|
--
|
|
|
|
-- NOTE: This can possibly be generalized to a @Control.Lens.Iso@.
|
|
|
|
--
|
|
|
|
-- NOTE: This should probably be extracted to some common helper module (e.g.
|
|
|
|
-- @Harness.State@).
|
|
|
|
actionWithStateMapping :: ActionWith State -> ActionWith (State, ())
|
|
|
|
actionWithStateMapping actionWith (state, _) = actionWith state
|
|
|
|
|
|
|
|
-- | Modify an 'Item'@ a@ by way of mapping its 'ActionWith'@ a@ function to
|
|
|
|
-- some 'ActionWith'@ b@, producing an 'Item'@ b@.
|
|
|
|
--
|
|
|
|
-- This can be useful when one wants to modify the state parameter in a
|
|
|
|
-- 'SpecWith' test tree, without having to resolve the type mismatch using some
|
|
|
|
-- combination of type families and helper type classes.
|
|
|
|
--
|
|
|
|
-- NOTE: This should go in some sort of @Test.Hspec.Core.Spec.Extended@ module.
|
|
|
|
mapItemAction :: (ActionWith a -> ActionWith b) -> Item a -> Item b
|
|
|
|
mapItemAction mapActionWith item@Item {itemExample} =
|
|
|
|
let mappedExample params next callback =
|
|
|
|
itemExample
|
|
|
|
params
|
|
|
|
(next . mapActionWith)
|
|
|
|
callback
|
|
|
|
in item {itemExample = mappedExample}
|
|
|
|
|
|
|
|
-- | Runs the given tests, for each provided 'Context'@ a@.
|
|
|
|
--
|
|
|
|
-- Each 'Context' provides distinct setup and teardown functions; 'feature'
|
|
|
|
-- guarantees that the associated 'teardown' function is always called after a
|
|
|
|
-- setup, even if the tests fail.
|
|
|
|
--
|
|
|
|
-- 'Context's are parameterized by the type of local state that needs to be
|
|
|
|
-- carried throughout the tests.
|
|
|
|
--
|
|
|
|
-- See 'Context' for details.
|
|
|
|
runWithLocalState ::
|
|
|
|
forall a.
|
|
|
|
[Context a] ->
|
|
|
|
(Options -> SpecWith (State, a)) ->
|
|
|
|
SpecWith State
|
|
|
|
runWithLocalState contexts tests =
|
2022-02-18 16:35:32 +03:00
|
|
|
for_ contexts \context@Context {name, customOptions} -> do
|
|
|
|
let options = fromMaybe defaultOptions customOptions
|
2022-02-21 20:05:09 +03:00
|
|
|
describe (show name) $ aroundAllWith (contextBracket context) (tests options)
|
2022-02-14 20:24:24 +03:00
|
|
|
where
|
|
|
|
-- We want to be able to report exceptions happening both during the tests
|
|
|
|
-- and at teardown, which is why we use a custom re-implementation of
|
|
|
|
-- @bracket@.
|
|
|
|
contextBracket ::
|
|
|
|
forall b.
|
|
|
|
Context a ->
|
|
|
|
((State, a) -> IO b) ->
|
|
|
|
State ->
|
|
|
|
IO ()
|
2022-02-21 20:05:09 +03:00
|
|
|
contextBracket Context {mkLocalState, setup, teardown} actionWith globalState =
|
2022-02-14 20:24:24 +03:00
|
|
|
mask \restore -> do
|
2022-02-21 20:05:09 +03:00
|
|
|
localState <- mkLocalState globalState
|
2022-02-14 20:24:24 +03:00
|
|
|
let state = (globalState, localState)
|
|
|
|
|
2022-02-21 20:05:09 +03:00
|
|
|
catch
|
|
|
|
-- Setup for a test
|
|
|
|
(setup state)
|
|
|
|
( \setupEx ->
|
|
|
|
catch
|
|
|
|
-- On setup error, attempt to run `teardown` and then throw the setup error
|
|
|
|
(teardown state *> throwIO setupEx)
|
|
|
|
-- On teardown error as well, throw both exceptions
|
|
|
|
(throwIO . Exceptions setupEx)
|
|
|
|
)
|
|
|
|
|
2022-02-14 20:24:24 +03:00
|
|
|
-- Run tests.
|
|
|
|
_ <-
|
|
|
|
catch
|
|
|
|
(restore $ actionWith state)
|
|
|
|
( \restoreEx -> do
|
|
|
|
-- On test error, attempt to run `teardown`...
|
|
|
|
teardown state
|
|
|
|
`catch`
|
|
|
|
-- ...if it fails as well, bundle both exceptions together and
|
|
|
|
-- rethrow them...
|
|
|
|
(throwIO . Exceptions restoreEx)
|
|
|
|
-- ...otherwise rethrow the original exception.
|
|
|
|
throwIO restoreEx
|
|
|
|
)
|
|
|
|
-- If no exception occurred, run the normal teardown function.
|
|
|
|
teardown state
|
|
|
|
|
|
|
|
--------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
-- | A context in which a set of tests should be executed; this could be an
|
|
|
|
-- individual backend, or a setup of several of them to test relationships.
|
|
|
|
--
|
|
|
|
-- The @a@ parameter defines the local state, in addition to the global state.
|
|
|
|
--
|
|
|
|
-- A test that doesn't require additional local state can indicate this with
|
|
|
|
-- '()'.
|
|
|
|
--
|
|
|
|
-- For example a value of type @Context ()@ will have the following record
|
|
|
|
-- fields:
|
|
|
|
--
|
|
|
|
-- @
|
|
|
|
-- setup :: State -> IO ()
|
|
|
|
-- teardown :: (State, ()) -> IO ()
|
|
|
|
-- tests :: SpecWith (State, ())
|
|
|
|
-- @
|
|
|
|
--
|
|
|
|
-- However, if a test needs some custom state it must be passed in as a tuple.
|
|
|
|
--
|
|
|
|
-- For example a value of type @Context Server@ will have the following record
|
|
|
|
-- fields:
|
|
|
|
--
|
|
|
|
-- @
|
|
|
|
-- setup :: State -> IO ()
|
|
|
|
-- teardown :: (State, Server) -> IO ()
|
|
|
|
-- tests :: SpecWith (State, Server)
|
|
|
|
-- @
|
|
|
|
data Context a = Context
|
2022-02-21 20:05:09 +03:00
|
|
|
{ -- | A name describing the given context.
|
|
|
|
--
|
|
|
|
-- e.g. @Postgre@ or @MySQL@
|
|
|
|
name :: ContextName,
|
|
|
|
-- | Setup actions associated with creating a local state for this 'Context'; for example:
|
|
|
|
-- * starting remote servers
|
2022-02-14 20:24:24 +03:00
|
|
|
--
|
2022-02-21 20:05:09 +03:00
|
|
|
-- If any of those resources need to be threaded throughout the tests
|
|
|
|
-- themselves they should be returned here. Otherwise, a ()
|
|
|
|
mkLocalState :: State -> IO a,
|
2022-02-14 20:24:24 +03:00
|
|
|
-- | Setup actions associated with this 'Context'; for example:
|
|
|
|
-- * running SQL commands
|
|
|
|
-- * sending metadata commands
|
|
|
|
--
|
2022-02-21 20:05:09 +03:00
|
|
|
-- Takes the global 'State' and any local state (i.e. @a@) as arguments.
|
|
|
|
setup :: (State, a) -> IO (),
|
2022-02-14 20:24:24 +03:00
|
|
|
-- | Cleanup actions associated with this 'Context'.
|
|
|
|
--
|
|
|
|
-- This function /must/ return any resources created or modified as part of
|
|
|
|
-- 'setup' to their /original state/ (whatever that may be).
|
|
|
|
--
|
|
|
|
-- Takes the global 'State' and any local state (i.e. @a@) as arguments.
|
|
|
|
teardown :: (State, a) -> IO (),
|
2022-02-18 16:35:32 +03:00
|
|
|
-- | Options which modify the behavior of a given testing 'Context'; when
|
|
|
|
-- this field is 'Nothing', tests are given the 'defaultOptions'.
|
|
|
|
customOptions :: Maybe Options
|
2021-11-17 22:50:39 +03:00
|
|
|
}
|
|
|
|
|
2022-02-21 20:05:09 +03:00
|
|
|
-- | A name describing the given context.
|
|
|
|
data ContextName
|
|
|
|
= Postgres
|
|
|
|
| MySQL
|
|
|
|
| SQLServer
|
|
|
|
| BigQuery
|
|
|
|
| Citus
|
|
|
|
| Combine ContextName ContextName
|
2022-02-22 16:54:11 +03:00
|
|
|
|
|
|
|
instance Show ContextName where
|
|
|
|
show Postgres = "Postgres"
|
|
|
|
show MySQL = "MySQL"
|
|
|
|
show SQLServer = "SQLServer"
|
|
|
|
show BigQuery = "BigQuery"
|
|
|
|
show Citus = "Citus"
|
|
|
|
show (Combine name1 name2) = show name1 ++ "-" ++ show name2
|
2022-02-21 20:05:09 +03:00
|
|
|
|
|
|
|
-- | Default function for 'mkLocalState' when there's no local state.
|
|
|
|
noLocalState :: State -> IO ()
|
|
|
|
noLocalState _ = pure ()
|
|
|
|
|
2022-02-14 20:24:24 +03:00
|
|
|
data Options = Options
|
|
|
|
{ -- | Whether a given testing 'Context' should treat numeric values as
|
|
|
|
-- strings.
|
|
|
|
--
|
|
|
|
-- This is primarily a workaround for tests which run BigQuery.
|
2022-02-09 18:26:14 +03:00
|
|
|
stringifyNumbers :: Bool
|
|
|
|
}
|
|
|
|
|
2022-02-18 16:35:32 +03:00
|
|
|
-- | This function can be used to combine two sets of 'Option's when creating
|
|
|
|
-- custom composite 'Context's.
|
|
|
|
--
|
|
|
|
-- NOTE: This function throws an impure exception if the options are
|
|
|
|
-- irreconcilable.
|
|
|
|
combineOptions :: HasCallStack => Maybe Options -> Maybe Options -> Maybe Options
|
|
|
|
combineOptions (Just lhs) (Just rhs) =
|
|
|
|
let -- 'stringifyNumbers' can only be unified if both sides have the same value.
|
|
|
|
stringifyNumbers =
|
|
|
|
if lhsStringify == rhsStringify
|
|
|
|
then lhsStringify
|
|
|
|
else reportInconsistency "stringifyNumbers" lhsStringify rhsStringify
|
|
|
|
in Just Options {..}
|
|
|
|
where
|
|
|
|
reportInconsistency fieldName lhsValue rhsValue =
|
|
|
|
error $ "Could not reconcile '" <> fieldName <> "'\n lhs value: " <> show lhsValue <> "\n rhs value: " <> show rhsValue
|
|
|
|
Options {stringifyNumbers = lhsStringify} = lhs
|
|
|
|
Options {stringifyNumbers = rhsStringify} = rhs
|
|
|
|
combineOptions mLhs mRhs = mLhs <|> mRhs
|
|
|
|
|
2022-02-14 20:24:24 +03:00
|
|
|
defaultOptions :: Options
|
|
|
|
defaultOptions =
|
|
|
|
Options
|
|
|
|
{ stringifyNumbers = False
|
|
|
|
}
|
2022-01-26 15:17:17 +03:00
|
|
|
|
2022-02-14 20:24:24 +03:00
|
|
|
--------------------------------------------------------------------------------
|
|
|
|
-- Local helpers
|
2022-01-26 15:17:17 +03:00
|
|
|
|
2022-02-14 20:24:24 +03:00
|
|
|
-- | Two exceptions, bundled as one.
|
2022-01-26 15:17:17 +03:00
|
|
|
data Exceptions
|
|
|
|
= Exceptions SomeException SomeException
|
2022-02-14 20:24:24 +03:00
|
|
|
deriving anyclass (Exception)
|
2022-01-26 15:17:17 +03:00
|
|
|
|
|
|
|
instance Show Exceptions where
|
|
|
|
show (Exceptions e1 e2) =
|
|
|
|
unlines
|
|
|
|
[ "1. " <> show e1,
|
|
|
|
"",
|
|
|
|
"2. " <> show e2
|
|
|
|
]
|