Testing Failure with Either Instead of Exception

Testing the failure cases of code is often as important as testing the successful paths. The Pandoc filter we worked on in the previous episode returns its result, and throws exceptions with formatted strings, in the IO type. This makes testing the failure cases much harder. In this episode we will introduce a function exposing the errors using the Either type, and rewrite the test suite to match the new behavior.

Show Notes

In the last episode we introduced the InclusionMode data type, making valid modes of execution explicit.

data InclusionMode = SnippetMode Text | RangeMode Range | EntireFileMode
  deriving (Show, Eq)

Our test suite still needs some changes though, to reflect how you cannot run the filter in multiple modes simultaneously. Switching over to the test runner, we see that two cases fail.

/home/owi/haskell-at-work/pandoc-include-code/test/Driver.hs
  includeCode
    includes snippet:                                                  OK
    includes snippet within start and end line:                        FAIL
      IOException of type UserError (user error (Conflicting inclusion modes: [RangeMode (Range {rangeStart = 1, rangeEnd = 3}),SnippetMode "foo"]))
    includes snippet by exact name, not prefix:                        OK
    excludes snippet outside start and end line:                       FAIL
      IOException of type UserError (user error (Conflicting inclusion modes: [RangeMode (Range {rangeStart = 2, rangeEnd = 3}),SnippetMode "foo"]))
    includes only lines between start and end line:                    OK
    dedents lines as much as specified:                                OK
    dedents lines as much as possible without removing non-whitespace: OK

2 out of 7 tests failed (0.00s)

These cases were previously valid, but now we want to instead assert that they fail. Looking at the test suite, we see that the test cases use a function includeCode that returns an IO action of a code block.

includeCode :: String -> [(String, String)] -> IO Block
includeCode fixtureName attrs = Filter.includeCode
  (Just (Format "html5"))
  ( CodeBlock
    ("", [], mconcat [[("include", "test/fixtures/" ++ fixtureName)], attrs])
    ""
  )

spec_includeCode = do
  it "includes snippet" $
    includeCode "foo-snippet.txt" [("snippet", "foo")]
    `shouldReturn` codeBlock "foo\n"
  ...

Failures in includeCode are thrown as exceptions in the IO action, with formatted strings for error messages.

includeCode :: Maybe Format -> Block -> IO Block

Testing directly against the formatted messages would be brittle, so we’ll instead provide an alternative function to includeCode that returns IO (Either InclusionError Block). The Maybe Format passed by Pandoc’s filter mechanism to includeCode is not used, so we won’t pass it to our new function.

includeCode' :: Block -> IO (Either InclusionError Block)

The existing function will use includeCode' and convert the Either value to an IO action, throwing exceptions just as before. The Kleisli arrow (>=>) is used to compose two monadic actions, analogous to the (.) operator for function composition.

-- | A Pandoc filter that includes code snippets from external files.
includeCode :: Maybe Format -> Block -> IO Block
includeCode _ = includeCode' >=> either printAndFail return

Now, let’s get those exceptions out of includeCode'. When we have a valid block to return, we do that using the Right constructor. Errors are returned by wrapping them in a Left.

includeCode' cb@(CodeBlock (id', classes, attrs) _) =
    ...
    Right Nothing -> return (Right cb)
    Left  err     -> return (Left err)
includeCode' x = return (Right x)

The case where we actually include code in the block will no longer use runInclusion', as we want the ExceptT from the regular runInclusion function. Thus, we remove runInclusion', and use runExceptT directly. We’ll also have to use runReaderT with the InclusionSpec value, and bind the resulting contents.

includeCode' cb@(CodeBlock (id', classes, attrs) _) =
  case parseInclusion (HM.fromList attrs) of
    Right (Just spec) -> runExceptT $ do
      contents <- runReaderT (runInclusion allSteps) spec
      return
        ( CodeBlock (id', classes, filterAttributes attrs)
                    (Text.unpack contents)
        )
    ...

I like using ExceptT in this local style, not exposing it to the caller, but making error handling inside the function body easier to manage.

Now that we have a new function and want to test for specific errors using their constructors, we need to extend the exports list. We add the new function, the InclusionError data type, the MissingRangePart data type, and the InclusionMode data type.

module Text.Pandoc.Filter.IncludeCode
  ( InclusionError (..)
  , MissingRangePart (..)
  , InclusionMode (..)
  , includeCode'
  , includeCode
  ) where

It’s time to fix the test suite. We’ll remove the qualified import of IncludeCode, and import everything but the old includeCode function.

import           Text.Pandoc.Filter.IncludeCode hiding (includeCode)

Our test helper will use includeCode' instead of includeCode, and return an action of type IO (Either InclusionError Block).

includeCode :: String -> [(String, String)] -> IO (Either InclusionError Block)
includeCode fixtureName attrs = includeCode'
  ( CodeBlock
    ("", [], mconcat [[("include", "test/fixtures/" ++ fixtureName)], attrs])
    ""
  )

All assertions now need to check for Left or Right results. We begin by expecting all cases to pass with Right values. A macro in Neovim makes this process a little less painful.

  it "includes snippet" $
    includeCode "foo-snippet.txt" [("snippet", "foo")]
    `shouldReturn` Right (codeBlock "foo\n")

  it "includes snippet within start and end line" $
    includeCode "foo-snippet.txt"
                  [("snippet", "foo"), ("startLine", "1"), ("endLine", "3")]
    `shouldReturn` Right (codeBlock "foo\n")

  it "includes snippet by exact name, not prefix" $
    includeCode "foo-snippets.txt" [("snippet", "foo")]
    `shouldReturn` Right (codeBlock "foo\n")

  it "excludes snippet outside start and end line" $
    includeCode "foo-snippet.txt"
                  [("snippet", "foo"), ("startLine", "2"), ("endLine", "3")]
    `shouldReturn` Right (codeBlock "")

  it "includes only lines between start and end line" $
    includeCode "some-text.txt" [("startLine", "2"), ("endLine", "3")]
    `shouldReturn` Right (codeBlock "world\nthis\n")

  it "dedents lines as much as specified" $
    includeCode "indents.txt" [("dedent", "1")]
    `shouldReturn` Right (codeBlock "zero\n two\none\n  three\n       eight\n")

  it "dedents lines as much as possible without removing non-whitespace" $
    includeCode "indents.txt" [("dedent", "8")]
    `shouldReturn` Right (codeBlock "zero\ntwo\none\nthree\neight\n")

Checking the test runner output, the two cases failing before are now showing the difference between the expected Right, and the actual Left.

/home/owi/haskell-at-work/pandoc-include-code/test/Driver.hs
  includeCode
    includes snippet:                                                  OK
    includes snippet within start and end line:                        FAIL
      expected Right (CodeBlock ("",[],[]) "foo\n"), but got Left (ConflictingModes [RangeMode (Range {rangeStart = 1, rangeEnd = 3}),SnippetMode "foo"])
    includes snippet by exact name, not prefix:                        OK
    excludes snippet outside start and end line:                       FAIL
      expected Right (CodeBlock ("",[],[]) ""), but got Left (ConflictingModes [RangeMode (Range {rangeStart = 1, rangeEnd = 3}),SnippetMode "foo"])
    includes only lines between start and end line:                    OK
    dedents lines as much as specified:                                OK
    dedents lines as much as possible without removing non-whitespace: OK

2 out of 7 tests failed (0.00s)

These cases should return Left values, so let’s change the first failing test and its description.

it "rejects snippet mode and range mode" $
  includeCode "foo-snippet.txt"
                [("snippet", "foo"), ("startLine", "1"), ("endLine", "3")]
  `shouldReturn` Left (ConflictingModes [RangeMode (range 1 3), SnippetMode "foo"])

We’ll define the range helper function for this test suite, to give us a Range value given a line and a column. Note that this function is partial, but we can live with that in our test code.

range l c = fromJust (mkRange l c)

We need to import mkRange and fromJust.

import           Data.Maybe                     (fromJust)
import           Text.Pandoc.Filter.Range       (mkRange)

We also need to enable OverloadedStrings to construct the Text value for the SnippetMode constructor.

{-# LANGUAGE OverloadedStrings #-}

Now we have only one failing test case left. Converting it to expect an error would result in the same test we just wrote, so we’ll just remove it.

it "excludes snippet outside start and end line" $
  includeCode "foo-snippet.txt"
                [("snippet", "foo"), ("startLine", "2"), ("endLine", "3")]
  `shouldReturn` Right (codeBlock "")

All tests pass, and we’re done!

/home/owi/haskell-at-work/pandoc-include-code/test/Driver.hs
  includeCode
    includes snippet:                                                  OK
    rejects snippet mode and range mode:                               OK
    includes snippet by exact name, not prefix:                        OK
    includes only lines between start and end line:                    OK
    dedents lines as much as specified:                                OK
    dedents lines as much as possible without removing non-whitespace: OK

All 6 tests passed (0.00s)

Remaining to be implemented in this Pandoc filter series is the .numberLines feature. Keep an eye out for the next video!

Scroll to Top