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,
}
deriving stock (Eq, Show)
Which can be used as follows:
withFiles files act =
withSystemTempDirectory "librarian-tests" $ \d ->
withCurrentDirectory d $ do
mapM_ touch files
act
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:
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.