testcontainers 0.2.0.0 → 0.3.0.0
raw patch · 6 files changed
+366/−171 lines, 6 files
Files
- README.md +76/−0
- src/TestContainers.hs +8/−5
- src/TestContainers/Docker.hs +189/−131
- src/TestContainers/Tasty.hs +89/−32
- test/Driver.hs +1/−1
- testcontainers.cabal +3/−2
+ README.md view
@@ -0,0 +1,76 @@+# About++Testcontainers is a Haskell library that provides a friendly API to run Docker+containers. It is designed to create a runtime environment to use during your+integration tests++# Example++``` haskell+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}+module Main where++import qualified Test.Tasty as Tasty+import qualified Test.Tasty.HUnit as Tasty+import qualified TestContainers.Tasty as TC+++data Endpoints = Endpoints+ {+ redisHost :: String+ , redisPort :: Int+ }+++-- | Sets up and runs the containers required for this test suite.+setupContainers :: TC.MonadDocker m => m Endpoints+setupContainers = do++ -- Launch the container based on the redis:5.0.0 image.+ redisContainer <- TC.run $ TC.containerRequest (TC.fromTag "redis:5.0.0")+ -- Expose the port 6379 from within the container. The respective port+ -- on the host machine can be looked up using `containerPort` (see below).+ TC.& TC.setExpose [ 6379 ]+ -- Wait until the container is ready to accept requests. `run` blocks until+ -- readiness can be established.+ TC.& TC.setWaitingFor (TC.waitUntilMappedPortReachable 6379)++ pure $ Endpoints+ {+ redisHost = "0.0.0.0"+ , redisPort =+ -- Look up the corresponding port on the host machine for the exposed+ -- port 6379.+ TC.containerPort redisContainer 6379+ }+++main :: IO ()+main =+ Tasty.defaultMain $+ -- Use `withContainers` to make the containers available in the closed over+ -- tests. Due to how Tasty handles resources `withContainers` passes down+ -- an IO action `start` to actually start up the containers. `start` can be+ -- invoked multiple times, Tasty makes sure to only start up the containrs+ -- once.+ --+ -- `withContainers` ensures the started containers are shut down correctly+ -- once execution leaves its scope.+ TC.withContainers setupContainers $ \start ->+ Tasty.testGroup "Some test group"+ [+ Tasty.testCase "Redis test" $ do+ -- Actually start the containers!!+ Endpoints {..} <- start+ -- ... assert some properties+ pure ()++ , Tasty.testCase "Another Redis test" $ do+ -- Invoking `start` twice gives the same Endpoints!+ Endpoints {..} <- start+ -- ... assert some properties+ pure ()+ ]+```
src/TestContainers.hs view
@@ -80,9 +80,14 @@ , Config(..) , defaultDockerConfig- , dockerForMacConfig , determineConfig + -- *++ , Tracer+ , Trace(..)+ , newTracer+ -- * Exceptions , M.DockerException(..)@@ -91,8 +96,8 @@ -- * Misc. Docker functions - , dockerVersion- , isDockerForDesktop+ , dockerHostOs+ , isDockerOnLinux -- * Predefined Docker images @@ -108,5 +113,3 @@ import TestContainers.Docker as M import TestContainers.Image as M--import Data.Function as M ((&))
src/TestContainers/Docker.hs view
@@ -1,12 +1,17 @@-{-# LANGUAGE BangPatterns #-}-{-# LANGUAGE ConstraintKinds #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE NamedFieldPuns #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE PatternSynonyms #-}-{-# LANGUAGE RankNTypes #-}-{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE ConstraintKinds #-}+{-# LANGUAGE DerivingStrategies #-}+{-# LANGUAGE DuplicateRecordFields #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE NamedFieldPuns #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE PatternSynonyms #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE StandaloneDeriving #-}+{-# LANGUAGE UndecidableInstances #-} module TestContainers.Docker ( MonadDocker@@ -15,9 +20,15 @@ , Config(..) , defaultDockerConfig- , dockerForMacConfig , determineConfig + -- * Exeuction tracing++ , Tracer+ , Trace(..)+ , newTracer+ , withTrace+ -- * Docker image , ImageTag@@ -89,8 +100,8 @@ -- * Misc. Docker functions - , dockerVersion- , isDockerForDesktop+ , dockerHostOs+ , isDockerOnLinux -- * Wait until a socket is reachable , waitUntilMappedPortReachable@@ -102,11 +113,9 @@ , (&) ) where +import Control.Applicative ((<|>)) import Control.Concurrent (threadDelay) import Control.Exception (IOException, throw)-import Optics.Operators ((^?))-import Optics.Optic ((%))-import Optics.Fold (pre) import Control.Monad.Catch (Exception, MonadCatch, MonadMask, MonadThrow, bracket, throwM, try) import Control.Monad.Fix (mfix)@@ -119,16 +128,18 @@ import Data.Aeson (Value, decode') import qualified Data.Aeson.Optics as Optics import qualified Data.ByteString.Lazy.Char8 as LazyByteString+import Data.Foldable (traverse_) import Data.Function ((&)) import Data.List (find)-import Data.Text (Text, isInfixOf, pack, strip,- unpack)+import Data.Text (Text, pack, strip, unpack) import Data.Text.Encoding.Error (lenientDecode) import qualified Data.Text.Lazy as LazyText import qualified Data.Text.Lazy.Encoding as LazyText-import GHC.Stack (HasCallStack,- withFrozenCallStack)+import GHC.Stack (withFrozenCallStack) import qualified Network.Socket as Socket+import Optics.Fold (pre)+import Optics.Operators ((^?))+import Optics.Optic ((%)) import Prelude hiding (error, id) import qualified Prelude import System.Exit (ExitCode (..))@@ -138,24 +149,66 @@ import System.Timeout (timeout) +-- | Type representing various events during testcontainer execution.+data Trace+ -- | The low-level invocation of @docker@ command+ --+ -- @+ -- TraceDockerInvocation args stdin exitcode+ -- @+ = TraceDockerInvocation [Text] Text ExitCode -- docker [args] [stdin]+ -- | Line written to STDOUT by a Docker process.+ | TraceDockerStdout Text+ -- | Line written to STDERR by a Docker process.+ | TraceDockerStderr Text+ -- | Waiting for a container to become ready. Attached with the+ -- timeout to wait (in seconds).+ | TraceWaitUntilReady (Maybe Int)+ -- | Opening socket+ | TraceOpenSocket Text Int (Maybe IOException)+++deriving stock instance Eq Trace+deriving stock instance Show Trace+++-- | Traces execution within testcontainers library.+newtype Tracer = Tracer { unTracer :: Trace -> IO () }+++deriving newtype instance Semigroup Tracer+deriving newtype instance Monoid Tracer+++-- | Construct a new `Tracer` from a tracing function.+newTracer+ :: (Trace -> IO ())+ -> Tracer+newTracer action = Tracer+ {+ unTracer = action+ }+++withTrace :: MonadIO m => Tracer -> Trace -> m ()+withTrace tracer trace =+ liftIO $ unTracer tracer trace+{-# INLINE withTrace #-}++ -- | Configuration for defaulting behavior. ----- Note that IP address returned by `containerIp` is not reachable when using--- Docker For Mac (See https://docs.docker.com/docker-for-mac/networking/#per-container-ip-addressing-is-not-possible).------ If you are targeting Docker For Mac you should use `dockerForMacConfig`, instead--- use `defaultDockerConfig`.--- -- @since 0.2.0.0 -- data Config = Config {- -- | How to retrieve the IP address of a Docker container.- -- There some known limitations around Docker for Mac in that- -- it doesn't support accessing containers by their IP (see `containerIp`).+ -- | The number of seconds to maximally wait for a container to+ -- become ready. Default is `Just 60`. --- -- This configuration let's you define how to access the IP.- configContainerIp :: Container -> Text+ -- @Nothing@ <=> waits indefinitely.+ configDefaultWaitTimeout :: Maybe Int+ -- | Traces execution inside testcontainers library.+ , configTracer :: Tracer } @@ -166,32 +219,18 @@ defaultDockerConfig :: Config defaultDockerConfig = Config {- configContainerIp = internalContainerIp- }----- | A default configuration to use with Docker for Mac installations. It doesn't--- use a Docker container's IP address to access a container, instead it always uses--- the loopback interface @0.0.0.0@.------ @since 0.2.0.0----dockerForMacConfig :: Config-dockerForMacConfig = defaultDockerConfig- {- configContainerIp = \_ -> "0.0.0.0"+ configDefaultWaitTimeout = Just 60+ , configTracer = mempty } -- | Autoselect the default configuration depending on wether you use Docker For--- Mac/Desktop or not.+-- Mac or not. determineConfig :: IO Config-determineConfig = do- -- TODO We used to be clever here but it turned out that it wouldn't let- -- hosts reach containers. Even on linux! Figure out how to do Docker- -- networking properly.- pure dockerForMacConfig+determineConfig =+ pure defaultDockerConfig + -- | Failing to interact with Docker results in this exception -- being thrown. --@@ -388,8 +427,10 @@ [ [ tag ] ] ++ [ command | Just command <- [cmd] ] - stdout <- docker dockerRun+ config@Config { configTracer } <- ask + stdout <- docker configTracer dockerRun+ let id :: ContainerId !id =@@ -398,9 +439,8 @@ -- Careful, this is really meant to be lazy ~inspectOutput = unsafePerformIO $- internalInspect id+ internalInspect configTracer id - config <- ask container <- liftResourceT $ mfix $ \container -> do -- Note: We have to tie the knot as the resulting container -- carries the release key as well.@@ -428,9 +468,9 @@ -- -- @since 0.1.0.0 ---docker :: MonadIO m => [Text] -> m String-docker args =- dockerWithStdin args ""+docker :: MonadIO m => Tracer -> [Text] -> m String+docker tracer args =+ dockerWithStdin tracer args "" -- | Internal function that runs Docker. Takes care of throwing an exception@@ -438,11 +478,17 @@ -- -- @since 0.1.0.0 ---dockerWithStdin :: MonadIO m => [Text] -> Text -> m String-dockerWithStdin args stdin = liftIO $ do+dockerWithStdin :: MonadIO m => Tracer -> [Text] -> Text -> m String+dockerWithStdin tracer args stdin = liftIO $ do (exitCode, stdout, stderr) <- Process.readProcessWithExitCode "docker" (map unpack args) (unpack stdin) + withTrace tracer (TraceDockerInvocation args stdin exitCode)++ -- TODO output these concurrently with the process+ traverse_ (withTrace tracer . TraceDockerStdout . pack) (lines stdout)+ traverse_ (withTrace tracer . TraceDockerStderr . pack) (lines stderr)+ case exitCode of ExitSuccess -> pure stdout _ -> throwM $ DockerException@@ -458,7 +504,8 @@ -- kill :: MonadDocker m => Container -> m () kill Container { id } = do- _ <- docker [ "kill", id ]+ tracer <- askTracer+ _ <- docker tracer [ "kill", id ] return () @@ -468,7 +515,8 @@ -- stop :: MonadDocker m => Container -> m () stop Container { id } = do- _ <- docker [ "stop", id ]+ tracer <- askTracer+ _ <- docker tracer [ "stop", id ] return () @@ -478,7 +526,8 @@ -- rm :: MonadDocker m => Container -> m () rm Container { id } = do- _ <- docker [ "rm", "-f", "-v", id ]+ tracer <- askTracer+ _ <- docker tracer [ "rm", "-f", "-v", id ] return () @@ -563,7 +612,8 @@ -- fromTag :: ImageTag -> ToImage fromTag tag = defaultToImage $ do- output <- docker [ "pull", "--quiet", tag ]+ tracer <- askTracer+ output <- docker tracer [ "pull", "--quiet", tag ] return $ Image { tag = strip (pack output)@@ -586,7 +636,8 @@ [ "build", "-f", pack dockerfile, pack path ] | otherwise = [ "build", pack path ]- output <- docker args+ tracer <- askTracer+ output <- docker tracer args return $ Image { tag = strip (pack output)@@ -601,7 +652,8 @@ :: Text -> ToImage fromDockerfile dockerfile = defaultToImage $ do- output <- dockerWithStdin [ "build", "--quiet", "-" ] dockerfile+ tracer <- askTracer+ output <- dockerWithStdin tracer [ "build", "--quiet", "-" ] dockerfile return $ Image { tag = strip (pack output)@@ -615,33 +667,6 @@ type ContainerId = Text --- | Dumb logger abstraction to allow us to trace container execution.------ @since 0.1.0.0----data Logger = Logger- {- debug :: forall m . (HasCallStack, MonadIO m) => Text -> m ()- , info :: forall m . (HasCallStack, MonadIO m) => Text -> m ()- , warn :: forall m . (HasCallStack, MonadIO m) => Text -> m ()- , error :: forall m . (HasCallStack, MonadIO m) => Text -> m ()- }----- | Logger that doesn't log anything.------ @since 0.1.0.0----silentLogger :: Logger-silentLogger = Logger- {- debug = \_ -> pure ()- , info = \_ -> pure ()- , warn = \_ -> pure ()- , error = \_ -> pure ()- }-- -- | A strategy that describes how to asses readiness of a `Container`. Allows -- Users to plug in their definition of readiness. --@@ -649,7 +674,7 @@ -- newtype WaitUntilReady = WaitUntilReady {- checkContainerReady :: Logger -> Config -> Container -> ResIO ()+ checkContainerReady :: Config -> Container -> (Maybe Int, ResIO ()) } @@ -691,15 +716,15 @@ -- @since 0.1.0.0 -- waitUntilTimeout :: Int -> WaitUntilReady -> WaitUntilReady-waitUntilTimeout seconds wait = WaitUntilReady $ \logger config container@Container{ id } -> do- withRunInIO $ \runInIO -> do- result <- timeout (seconds * 1000000) $- runInIO (checkContainerReady wait logger config container)- case result of- Nothing ->- throwM $ TimeoutException { id }- Just _ ->- pure ()+waitUntilTimeout seconds wait = WaitUntilReady $ \config container ->+ case checkContainerReady wait config container of+ (Nothing, check) ->+ (Just seconds, check)+ (Just innerTimeout, check)+ | innerTimeout > seconds ->+ (Just seconds, check)+ | otherwise ->+ (Just innerTimeout, check) -- | Waits until the port of a container is ready to accept connections.@@ -710,19 +735,23 @@ waitUntilMappedPortReachable :: Int -> WaitUntilReady-waitUntilMappedPortReachable port = WaitUntilReady $ \logger _config container ->- withFrozenCallStack $ do+waitUntilMappedPortReachable port = WaitUntilReady $ \config container ->+ withFrozenCallStack $ let+ Config { configTracer } = config++ -- TODO add a parameterizable function when we will support host+ -- mapping exposure hostIp :: String- hostIp = unpack (containerIp container)+ hostIp = "0.0.0.0" - hostPort :: String- hostPort = show $ containerPort container port+ hostPort :: Int+ hostPort = containerPort container port resolve = do let hints = Socket.defaultHints { Socket.addrSocketType = Socket.Stream }- head <$> Socket.getAddrInfo (Just hints) (Just hostIp) (Just hostPort)+ head <$> Socket.getAddrInfo (Just hints) (Just hostIp) (Just (show hostPort)) open addr = do socket <- Socket.socket@@ -735,21 +764,21 @@ pure socket retry = do- debug logger $ "Trying to open socket to " <> pack hostIp <> ":" <> pack hostPort result <- try (resolve >>= open) case result of Right socket -> do- debug logger $ "Successfully opened socket to " <> pack hostIp <> ":" <> pack hostPort+ withTrace configTracer (TraceOpenSocket (pack hostIp) hostPort Nothing) Socket.close socket pure () Left (exception :: IOException) -> do- debug logger $ "Failed to open socket to " <> pack hostIp <> ":" <>- pack hostPort <> " with " <> pack (show exception)+ withTrace configTracer+ (TraceOpenSocket (pack hostIp) hostPort (Just exception)) threadDelay 500000 retry - liftIO retry+ in+ (Nothing, liftIO retry) -- | A low-level primitive that allows scanning the logs for specific log lines@@ -761,9 +790,12 @@ -- @since 0.1.0.0 -- waitWithLogs :: (Container -> Handle -> Handle -> IO ()) -> WaitUntilReady-waitWithLogs waiter = WaitUntilReady $ \_logger config container -> do- flip runReaderT config $ withLogs container $ \stdout stderr ->- liftIO $ waiter container stdout stderr+waitWithLogs waiter = WaitUntilReady $ \config container ->+ let+ check = flip runReaderT config $ withLogs container $ \stdout stderr ->+ liftIO $ waiter container stdout stderr+ in+ (Nothing, check) -- | A data type indicating which pipe to scan for a specific log line.@@ -818,11 +850,31 @@ -- @since 0.1.0.0 -- waitUntilReady :: MonadDocker m => Container -> WaitUntilReady -> m ()-waitUntilReady container waiter = do- config <- ask- liftResourceT $ checkContainerReady waiter silentLogger config container+waitUntilReady container@Container { id } waiter = do+ config@Config { configDefaultWaitTimeout, configTracer } <- ask+ let+ (timeoutInSeconds, check) =+ checkContainerReady waiter config container + runAction action timeoutSeconds = do+ withTrace configTracer (TraceWaitUntilReady timeoutSeconds)+ action + withTimeout action = case timeoutInSeconds <|> configDefaultWaitTimeout of+ Nothing ->+ runAction action Nothing+ Just seconds ->+ withRunInIO $ \runInIO -> do+ result <- timeout (seconds * 1000000) $ runInIO (runAction action (Just seconds))+ case result of+ Nothing ->+ throwM $ TimeoutException { id }+ Just _ ->+ pure ()++ liftResourceT (withTimeout check)++ -- | Handle to a Docker image. -- -- @since 0.1.0.0@@ -902,8 +954,8 @@ -- @since 0.1.0.0 -- containerIp :: Container -> Text-containerIp container@Container { config = Config { configContainerIp } } =- configContainerIp container+containerIp =+ internalContainerIp -- | Get the IP address of a running Docker container using @docker inspect@.@@ -966,9 +1018,9 @@ -- -- @since 0.1.0.0 ---internalInspect :: (MonadThrow m, MonadIO m) => ContainerId -> m InspectOutput-internalInspect id = do- stdout <- docker [ "inspect", id ]+internalInspect :: (MonadThrow m, MonadIO m) => Tracer -> ContainerId -> m InspectOutput+internalInspect tracer id = do+ stdout <- docker tracer [ "inspect", id ] case decode' (LazyByteString.pack stdout) of Nothing -> throwM $ InspectOutputInvalidJSON { id }@@ -980,13 +1032,19 @@ Prelude.error "Internal: Multiple results where I expected single result" -dockerVersion :: (MonadResource m, MonadMask m, MonadIO m) => m Text-dockerVersion = do- stdout <- docker [ "version", "--format", "{{.Server.KernelVersion}}" ]- return (pack stdout)+askTracer :: MonadReader Config m => m Tracer+askTracer = do+ Config { configTracer } <- ask+ pure configTracer+{-# INLINE askTracer #-} -isDockerForDesktop :: (MonadResource m, MonadMask m, MonadIO m) => m Bool-isDockerForDesktop = do- version <- dockerVersion- pure $ "linuxkit" `isInfixOf` version+dockerHostOs :: MonadDocker m => m Text+dockerHostOs = do+ tracer <- askTracer+ strip . pack <$> docker tracer [ "version", "--format", "{{.Server.Os}}" ]+++isDockerOnLinux :: MonadDocker m => m Bool+isDockerOnLinux =+ ("linux" ==) <$> dockerHostOs
src/TestContainers/Tasty.hs view
@@ -3,66 +3,123 @@ {-# LANGUAGE ScopedTypeVariables #-} module TestContainers.Tasty (+ -- * Tasty Ingredient+ ingredient+ -- * Running containers for tests- withContainers+ , withContainers -- * Re-exports for convenience , module Reexports ) where +import Control.Applicative ((<|>)) import Control.Monad.IO.Class (liftIO) import Control.Monad.Reader (runReaderT) import Control.Monad.Trans.Resource (InternalState,- getInternalState,- runResourceT)+ getInternalState) import Control.Monad.Trans.Resource.Internal (stateAlloc, stateCleanup) import Data.Acquire (ReleaseType (ReleaseNormal))-import Test.Tasty (TestTree, withResource)+import Data.Data (Proxy (Proxy))+import Test.Tasty (TestTree, askOption,+ withResource)+import qualified Test.Tasty as Tasty+import Test.Tasty.Ingredients (Ingredient)+import Test.Tasty.Options (IsOption (..),+ OptionDescription (..),+ mkFlagCLParser,+ safeRead)+import TestContainers as Reexports hiding+ (Trace) -import TestContainers (Container,- ContainerRequest,- MonadDocker, ToImage,- stop)-import TestContainers as Reexports +newtype DefaultTimeout = DefaultTimeout (Maybe Int) --- | Allow `Tasty.TestTree` to depend on Docker containers. Tasty takes care of--- initialization and de-initialization of the containers.++instance IsOption DefaultTimeout where++ defaultValue =+ DefaultTimeout Nothing++ parseValue =+ fmap (DefaultTimeout . Just) . safeRead++ optionName =+ pure "testcontainers-default-timeout"++ optionHelp =+ pure "The max. number of seconds to wait for a container to become ready"+++newtype Trace = Trace Bool+++instance IsOption Trace where++ defaultValue =+ Trace False++ parseValue =+ const Nothing++ optionCLParser =+ mkFlagCLParser mempty (Trace True)++ optionName =+ pure "testcontainers-trace"++ optionHelp =+ pure "Turns on tracing of the underlying Docker operations"+++-- | Tasty `Ingredient` that adds useful options to control defaults within the+-- TetContainers library. -- -- @------ containers :: MonadDocker m => m ()--- containers = do--- _redis <- TestContainers.run $ TestContainers.containerRequest TestContainers.redis--- _kafka <- TestContainers.run $ TestContainers.containerRequest TestContainers.kafka--- pure ()------ example :: TestTree--- example =--- withContainers containers $ \\runContainers -> testGroup "Example tests"--- [--- testCase "first test" $ do--- -- Actually runs the container.--- runContainers--- testCase "second test" $ do--- -- Start containers. Tasty makes sure to only initialize once as--- -- `first test` might already have started them.--- runContainers--- ]+-- main :: IO ()+-- main = `Tasty.defaultMainWithIngredients` (`ingredient` : `Tasty.defaultIngredients`) tests -- @ ----- `withContainers` allows you naturally scope the handling of containers for your tests.+-- @since 0.3.0.0+--+ingredient :: Ingredient+ingredient = Tasty.includingOptions+ [+ Option (Proxy :: Proxy DefaultTimeout)+ , Option (Proxy :: Proxy Trace)+ ]++ withContainers :: forall a . (forall m. MonadDocker m => m a) -> (IO a -> TestTree) -> TestTree withContainers startContainers tests =+ askOption $ \ (DefaultTimeout defaultTimeout) ->+ askOption $ \ (Trace enableTrace) -> let+ tracer :: Tracer+ tracer+ | enableTrace = newTracer $ \message ->+ putStrLn (show message)+ | otherwise =+ mempty+ runC action = do config <- determineConfig- runReaderT (runResourceT action) config++ let+ actualConfig :: Config+ actualConfig = config+ {+ configDefaultWaitTimeout =+ defaultTimeout <|> configDefaultWaitTimeout config+ , configTracer = tracer+ }++ runReaderT (runResourceT action) actualConfig -- Correct resource handling is tricky here: -- Tasty offers a bracket alike in IO. We have
test/Driver.hs view
@@ -1,1 +1,1 @@-{-# OPTIONS_GHC -F -pgmF tasty-discover -optF --tree-display #-}+{-# OPTIONS_GHC -F -pgmF tasty-discover -optF --tree-display -optF --ingredient -optF TestContainers.Tasty.ingredient #-}
testcontainers.cabal view
@@ -1,6 +1,6 @@ cabal-version: >=1.10 name: testcontainers-version: 0.2.0.0+version: 0.3.0.0 synopsis: Docker containers for your integration tests. description: testcontainers is a Haskell library that provides a friendly API to run Docker containers. It is designed to create a runtime environment@@ -13,7 +13,8 @@ copyright: 2020 Alex Biehl category: Development build-type: Simple-extra-source-files: CHANGELOG.md+extra-source-files: CHANGELOG.md,+ README.md source-repository head type: git