{-# LANGUAGE DeriveFunctor #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} -- | -- A module for producing JUnit style XML reports, for consumption by CI -- platforms like Jenkins. -- Please see the README at . module Text.XML.JUnit ( -- * Writing test reports writeXmlReport, -- * Test report constructors passed, skipped, failed, errored, inSuite, -- * Adding test report details stdout, stderr, time, failureMessage, failureStackTrace, errorMessage, errorStackTrace, -- * Helper types TestReport, TestSuite, ) where import Data.Function ((&)) import qualified Data.List.NonEmpty as NonEmpty import Data.Maybe (fromMaybe) import Data.Maybe (catMaybes) import qualified Data.Text as T import qualified Data.Text.Lazy.IO import GHC.Exts (fromList) import qualified Text.XML as XML -- | This function writes an xml report to the provided path. -- -- @ -- import Data.Function ((&)) -- -- writeXmlReport "report.xml" -- [ 'passed' "A passing test" -- & 'inSuite' "Test suite" -- , 'failed' "A failing test" -- & 'inSuite' "Test suite" -- ] -- @ writeXmlReport :: FilePath -> [TestSuite] -> IO () writeXmlReport out = Data.Text.Lazy.IO.writeFile out . XML.renderText XML.def . encode -- | The report for a single test case. data TestReport outcome where TestReport :: Outcome outcome => { testName' :: T.Text, outcome' :: outcome, stdout' :: Maybe T.Text, stderr' :: Maybe T.Text, time' :: Maybe Double } -> TestReport outcome -- | A test report annotated with the test suite it is part of. data TestSuite = TestSuite { suiteName :: T.Text, testReport :: XML.Element, counts :: Counts } -- | Wrap a test report in a suite, allowing it to be added to the list of -- reports passed to 'writeXmlReports'. -- -- @ -- import Data.Function ((&)) -- -- 'writeXmlReport' "report.xml" -- [ 'passed' "Passed test" -- & inSuite "Some test suite" -- ] -- -- @ inSuite :: T.Text -> TestReport outcome -> TestSuite inSuite name test@TestReport {outcome', time'} = TestSuite { suiteName = name, testReport = encodeTestCase test, counts = (outcomeCounter outcome') {cumTime = fromMaybe 0 time'} } mapTest :: (a -> a) -> TestReport a -> TestReport a mapTest f test = test {outcome' = f (outcome' test)} -- | Add the stdout produced running a test to the report for that test. -- -- @ -- import Data.Function ((&)) -- -- 'writeXmlReport' "report.xml" -- [ 'passed' "A passing test" -- & stdout "Test ran succesfully!" -- & 'inSuite' "Test suite" -- ] -- @ stdout :: T.Text -> TestReport outcome -> TestReport outcome stdout log test = test {stdout' = Just log} -- | Add the stderr produced running a test to the report for that test. -- -- @ -- import Data.Function ((&)) -- -- 'writeXmlReport' "report.xml" -- [ 'failed' "A failing test" -- & stderr "Expected 4, but got 2." -- & 'inSuite' "Test suite" -- ] -- @ stderr :: T.Text -> TestReport outcome -> TestReport outcome stderr log test = test {stderr' = Just log} -- | Add the running time of a test to the report for that test. -- -- @ -- import Data.Function ((&)) -- -- 'writeXmlReport' "report.xml" -- [ 'passed' "A passing test" -- & time 0.003 -- & 'inSuite' "Test suite" -- ] -- @ time :: Double -> TestReport outcome -> TestReport outcome time seconds test = test {time' = Just seconds} -- | Create a report for a passing test. -- -- @ -- import Data.Function ((&)) -- -- 'writeXmlReport' "report.xml" -- [ passed "A passing test" -- & 'stdout' "Test ran succesfully!" -- & 'stderr' "Warning: don't overcook the vegetables!" -- & 'time' 0.003 -- & 'inSuite' "Test suite" -- ] -- @ passed :: T.Text -> TestReport Passed passed name = TestReport { testName' = name, outcome' = Passed, stdout' = Nothing, stderr' = Nothing, time' = Nothing } -- | Create a report for a skipped test. -- -- @ -- import Data.Function ((&)) -- -- 'writeXmlReport' "report.xml" -- [ skipped "A skipped test" -- & 'inSuite' "Test suite" -- ] -- @ skipped :: T.Text -> TestReport Skipped skipped name = TestReport { testName' = name, outcome' = Skipped, stdout' = Nothing, stderr' = Nothing, time' = Nothing } -- | Create a report for a failed test. -- -- @ -- import Data.Function ((&)) -- -- 'writeXmlReport' "report.xml" -- [ failed "A failing test" -- & 'stdout' "Running test..." -- & 'stderr' "Test failed: expected 3 slices of pizza but got one." -- & 'failureMessage' "Not enough pizza" -- & 'failureStackTrace' ["pizza", "pizzeria", "italy"] -- & 'time' 0.08 -- & 'inSuite' "Test suite" -- ] -- @ failed :: T.Text -> TestReport Failed failed name = TestReport { testName' = name, outcome' = Failure Nothing [], stdout' = Nothing, stderr' = Nothing, time' = Nothing } -- | Create a report for a test that threw an error. -- -- @ -- import Data.Function ((&)) -- -- 'writeXmlReport' "report.xml" -- [ errored "A test that threw an error" -- & 'stdout' "Running test..." -- & 'stderr' "Unexpected exception: BedTime" -- & 'errorMessage' "Operation canceled due to BedTimeOut" -- & 'errorStackTrace' ["bed", "sleep", "night"] -- & 'time' 0.08 -- & 'inSuite' "Test suite" -- ] -- @ errored :: T.Text -> TestReport Errored errored name = TestReport { testName' = name, outcome' = Error Nothing [], stdout' = Nothing, stderr' = Nothing, time' = Nothing } class Outcome a where outcomeToXML :: a -> Maybe XML.Element outcomeCounter :: a -> Counts data Passed = Passed instance Outcome Passed where outcomeToXML _ = Nothing outcomeCounter _ = mempty {cumTests = 1} data Skipped = Skipped instance Outcome Skipped where outcomeToXML _ = Just $ XML.Element "skipped" mempty [] outcomeCounter _ = mempty {cumSkipped = 1, cumTests = 1} data Failed = Failure { -- Warning: newlines in the failure message will look like spaces in the -- Jenkins UI! failureMessage' :: Maybe T.Text, failureStackTrace' :: [T.Text] } instance Outcome Failed where outcomeToXML = Just . encodeFailure outcomeCounter _ = mempty {cumFailed = 1, cumTests = 1} -- | Add an error message to the report of a failed test. -- -- @ -- import Data.Function ((&)) -- -- 'writeXmlReport' "report.xml" -- [ failed "A failing test" -- & failureMessage "Laundromat exceeds noise tolerance." -- & 'inSuite' "Test suite" -- ] -- @ failureMessage :: T.Text -> TestReport Failed -> TestReport Failed failureMessage msg test = mapTest (\outcome -> outcome {failureMessage' = Just msg}) test -- | Add a stack trace to the report of a failed test. -- -- @ -- import Data.Function ((&)) -- -- 'writeXmlReport' "report.xml" -- [ failed "A failing test" -- & failureStackTrace ["ankleClass", "legClass", "limbClass"] -- & 'inSuite' "Test suite" -- ] -- @ failureStackTrace :: [T.Text] -> TestReport Failed -> TestReport Failed failureStackTrace trace test = mapTest (\outcome -> outcome {failureStackTrace' = trace}) test data Errored = Error { -- Warning: newlines in the failure message will look like spaces in the -- Jenkins UI! errorMessage' :: Maybe T.Text, errorStackTrace' :: [T.Text] } instance Outcome Errored where outcomeToXML = Just . encodeError outcomeCounter _ = mempty {cumErrored = 1, cumTests = 1} -- | Add an error message to the report for a test that threw an exception. -- -- @ -- import Data.Function ((&)) -- -- 'writeXmlReport' "report.xml" -- [ errored "A test that threw an error" -- & errorMessage "Too much Netflix" -- & 'inSuite' "Test suite" -- ] -- @ errorMessage :: T.Text -> TestReport Errored -> TestReport Errored errorMessage msg test = mapTest (\outcome -> outcome {errorMessage' = Just msg}) test -- | Add a stack trace to a report for a test that threw an exception -- -- @ -- import Data.Function ((&)) -- -- 'writeXmlReport' "report.xml" -- [ errored "A test that threw an error" -- & errorStackTrace ["at closeCurtain line 3", "at goToSleep line 8"] -- & 'inSuite' "Test suite" -- ] -- @ errorStackTrace :: [T.Text] -> TestReport Errored -> TestReport Errored errorStackTrace trace test = mapTest (\outcome -> outcome {errorStackTrace' = trace}) test data Counts = Counts { cumTests :: Int, cumFailed :: Int, cumErrored :: Int, cumSkipped :: Int, cumTime :: Double } instance Semigroup Counts where c1 <> c2 = Counts { cumTests = cumTests c1 + cumTests c2, cumFailed = cumFailed c1 + cumFailed c2, cumErrored = cumErrored c1 + cumErrored c2, cumSkipped = cumSkipped c1 + cumSkipped c2, cumTime = cumTime c1 + cumTime c2 } instance Monoid Counts where mempty = Counts 0 0 0 0 0 encode :: [TestSuite] -> XML.Document encode suites = XML.Document prologue element [] where prologue = XML.Prologue [] Nothing [] (totalCounts, suiteElements) = foldMap (fmap (pure . XML.NodeElement) . encodeSuite) (NonEmpty.groupAllWith suiteName suites) element = XML.Element "testsuites" (fromList (countAttributes totalCounts)) suiteElements encodeSuite :: NonEmpty.NonEmpty TestSuite -> (Counts, XML.Element) encodeSuite suite = (suiteCounts, element) where suiteCounts = foldMap counts suite element = XML.Element "testsuite" (fromList $ ("name", suiteName (NonEmpty.head suite)) : countAttributes suiteCounts) (NonEmpty.toList (XML.NodeElement . testReport <$> suite)) encodeTestCase :: TestReport a -> XML.Element encodeTestCase TestReport {testName', outcome', stdout', stderr', time'} = XML.Element "testcase" attributes children where attributes = fromList $ catMaybes [ Just $ ("name", testName'), (,) "time" . T.pack . show <$> time' ] children = XML.NodeElement <$> catMaybes [ outcomeToXML outcome', XML.Element "system-out" mempty . pure . XML.NodeContent <$> stdout', XML.Element "system-err" mempty . pure . XML.NodeContent <$> stderr' ] encodeFailure :: Failed -> XML.Element encodeFailure failure = XML.Element "failure" (maybe mempty (\v -> fromList [("message", v)]) (failureMessage' failure)) [XML.NodeContent (T.unlines (failureStackTrace' failure))] encodeError :: Errored -> XML.Element encodeError err = XML.Element "error" (maybe mempty (\v -> fromList [("message", v)]) (errorMessage' err)) [XML.NodeContent (T.unlines (errorStackTrace' err))] countAttributes :: Counts -> [(XML.Name, T.Text)] countAttributes counts = [ ("tests", T.pack (show (cumTests counts))), ("failures", T.pack (show (cumFailed counts))), ("errors", T.pack (show (cumErrored counts))), ("skipped", T.pack (show (cumSkipped counts))), ("time", T.pack (show (cumTime counts))) ]