eDSLs for tests

In the previous log, I have introduced librarian, as an example of Type-Driven Development.

Last log ended up with time-related feature.

For reference, we previously had tests like this:

describe "remove" $ do
  let removeAll = fetchRulesOn "." [removeAllTxtRule] >>= runPlan . planActions
  it "Should keep the non-matching file" $
    withFiles ["in/0.txt", "in/sub/0.txt", "in/0.jpg"] removeAll
      `shouldReturn` [ (FsRemove "./in/0.txt", Done),
                       (FsRemove "./in/sub/0.txt", Done)
                     ]

Now, we have to add time-related concepts, I have two options:

  • replace all the occurrences with a bunch of records or functions (forcing us to change all tests)
  • build an Embedded domain specific language (eDSL) which is a way to build a small language dedicated to a problem

The first step is to define a datatype with all we need:

data FileSpec = FileSpec
  { path :: FilePath,
    accessTime :: Maybe TimeSpec,
    modificationTime :: Maybe TimeSpec
  }
  deriving stock (Eq, Show)

Which can be used as follows:

withFiles :: [FileSpec] -> IO a -> IO a
withFiles files act =
  withSystemTempDirectory "librarian-tests" $ \d ->
    withCurrentDirectory d $ do
      mapM_ touch files
      act

touch :: FileSpec -> IO ()
touch target = do
  createDirectoryIfMissing True $ fst $ splitFileName target.path
  writeFile target.path "-"
  let computeTime =
        \case
          HoursAgo d -> addUTCTime (secondsToNominalDiffTime $ (-1) * fromInteger d * 60 * 60) <$> getCurrentTime
          DaysAgo d -> addUTCTime ((-1) * fromInteger d * nominalDay) <$> getCurrentTime
          AbsoluteTime x -> return x
  forM_ target.accessTime $ setAccessTime target.path <=< computeTime
  forM_ target.modificationTime $ setModificationTime target.path <=< computeTime

Then we can simply rely on GHC OverloadedString extension:

instance IsString FileSpec where
  fromString p =
    FileSpec {path = p, accessTime = Nothing, modificationTime = Nothing}

At this point, all our tests are compiled and executed as before.

Finally, we can write our tests:

describe "time-based" $ do
  let timeBased = fetchRulesOn "." [removeAllMidOldTtxt] >>= runPlan . planActions
  it "Should keep older and younger file" $
    withFiles
      [ "in/1.txt" {modificationTime = Just $ DaysAgo 1},
        "in/7.txt" {modificationTime = Just $ DaysAgo 7},
        "in/32.txt" {modificationTime = Just $ DaysAgo 32},
        "in/61.txt" {modificationTime = Just $ DaysAgo 61},
        "in/81.txt" {modificationTime = Just $ DaysAgo 81},
        "in/101.txt" {modificationTime = Just $ DaysAgo 101}
      ]
      (timeBased >> listFiles)
      `shouldReturn` ["./in/1.txt", "./in/101.txt", "./in/32.txt", "./in/7.txt"]

I often hear that functional programming does not scale, and that it only work in the small (and we need object-oriented programming in the large).

The thing is, functional programming does not suffer from stacking components (since types ensure strict boundaries).

eDSLs are the magic ingredient, each component can be seen as the start and the end of systems, abstracting used abstractions while exposing a coherent, not leaking (hopefully) abstractions.