The hspec
testing library includes many useful facilities for writing tests, including a powerful “hooks” capability.
These hooks allow you to provide data and capabilities to your tests.
SpecWith
The typical hspec
test suite looks like this:
main :: IO ()
main =
hspec specs
specs :: Spec
specs = do
describe "math" $ do
it "1 + 1" $ do
1 + 1
`shouldBe`
2
it "3 * 2" $ do
3 * 2
`shouldBe`
6
describe "words" $ do
it "breaks stuff up" $ do
words "asdf asdf asdf"
`shouldBe`
["asdf", "asdf", "asdf"]
Everything is a Spec
, and it’s all nice and cute.
Suddenly, you want to provide a database connection to each item in a spec. You can do this using a plain ol’ function argument, and this works alright.
main :: IO ()
main = do
db <- createDatabase
specs db
specs :: DB -> Spec
specs db = do
describe "SELECT" $ do
it "works" $ do
result <- runDb db "SELECT...."
result
`shouldBe`
[1,2,3]
But - what if we want to have a fresh database connection made, for each test? Well, then it’s a bit more awkward.
specs :: Spec
specs = do
describe "SELECT" $ do
it "works" $ do
db <- createDatabase
result <- runDb db "SELECT...."
result
`shouldBe`
[1,2,3]
it "does other stuff" $ do
db <- createDatabase
result <- runDb db "OTHER STUFF..."
result
`shouldBe`
[4,3,2]
That’s not much fun!
hspec
gives us a function before
that can be used to provide a fresh value for each item in a spec.
specs :: Spec
specs = before createDatabase $ do
describe "SELECT" $ do
it "works" $ \db -> do
result <- runDb db "SELECT...."
result
`shouldBe`
[1,2,3]
it "does other stuff" $ \db -> do
result <- runDb db "OTHER STUFF..."
result
`shouldBe`
[4,3,2]
Let’s look at the type of before
.
before :: IO a -> SpecWith a -> Spec
This raises some questions.
What is a SpecWith
?
All of the describe
stuff functions in a SpecM
monad, which constructs the Spec
tree and allows for filtering, focusing, and mapping of spec items.
That link shows that type Spec = SpecWith ()
.
Expanding our type, we get this:
before :: IO a -> SpecWith a -> SpecWith ()
A SpecWith
is a test that expects some additional context.
before
So, before
is used to provide a fresh thing to every test item.
What if you want to create a single thing and have it be shared among every spec item?
We can use beforeAll
to accomplish that.
It has the same signature.
The only difference is that the creation action is run once, and then shared among every test.
What if you don’t want to pass anything to the tests, but you want to run some action?
before_ :: IO () -> SpecWith a -> SpecWith a
You may be wondering: “What if I want to run an action once before all the items in the test go, but don’t provide a value?”
hspec
has you covered - beforeAll_
works exactly like that.
There’s one more tricky thing here - beforeWith
.
Note that in before
, the result is a Spec
- a test without extra context.
How do we call before
on something that has already had before
called on it?
beforeWith
comes to the rescue.
beforeWith :: (b -> IO a) -> SpecWith a -> SpecWith b
If you understand Contravariant
functors, then that intuition will carry you a decent way.
If you don’t, that’s cool - let’s dig into it.
Let’s say we have some group of tests that want to run a set of migrations against the database, and also provide some information along with the database connection.
We’ll insert a fake User
and make the Id
available to the resulting tests.
spec :: Spec
spec = before createDatabase $ do
describe "SELECT" $ do
it "has a database" $ \db -> do
...
beforeWith createUser $ describe "With User" $ do
it "has a db and a user" $ \(db, userId) -> do
...
createUser :: DB -> IO (DB, UserId)
createUser db = do
userId <- runDb $ insert User { name = "asdf" }
pure (db, userId)
Now, beforeWith
means that each item gets run before each spec.
So each test item in the database will have a different User
created for the test.
Naturally, there’s beforeAllWith
, which would only be run once, and would provide the same UserId
to each test item.
You may wonder: “Is there a beforeAllWith_
? Or even just beforeWith_
?”
There is not, and the reason is that they’re redundant.
Note how before_
and beforeAll_
don’t affect the context of the specs.
before_ :: IO () -> SpecWith a -> SpecWith a
beforeAll_ :: IO () -> SpecWith a -> SpecWith a
If we want to not affect the context of the spec, then we can just return it directly.
beforeWith_ :: (a -> IO ()) -> SpecWith a -> SpecWith a
beforeWith_ action =
beforeWith $ \a -> do
action a
pure a
after
The before
family of functions are useful for providing data and preparing the state of the world for a test.
after
is useful for tearing it down, or cleaning up after a test.
after :: ActionWith a -> SpecWith a -> SpecWith a
ActionWith
is a type synonym, so let’s review the definition and inline it here:
type ActionWith a =
a -> IO ()
after :: (a -> IO ()) -> SpecWith a -> SpecWith a
(I often find that inlining type synonyms helps with hspec
when reading and understanding it)
Let’s write a function that deletes the User
out of the database for all the terms.
spec :: Spec
spec = before createDatabase $ do
describe "SELECT" ...
beforeWith createUser $
after deleteUser $
describe "With User" $ do
it "has a db and a user" $ \(db, userId) -> do
...
createUser :: DB -> IO (DB, UserId)
createUser db = do
userId <- runDb $ insert User { name = "asdf" }
pure (db, userId)
deleteUser :: (DB, UserId) -> IO ()
deleteUser (db, userId) = do
runDb $ delete userId
pure ()
Now, we aren’t polluting our database with all those User
rows.
afterAll
does what you expect, if you know how beforeAll
works.
The action is run exactly once, after all spec items have been run.
If we replace after
with afterAll
in the above code, we’ll get some slightly weird results.
beforeWith createUser $
afterAll deleteUser $
describe "With User" $ do
it "has a thing" ...
it "likes cats" ...
it "also likes dogs" ...
The beforeWith
is called each time - so we create a fresh User
for each database.
afterAll
gets called on the last spec item - so we keep the first two User
rows in the database.
after_
and afterAll_
ignore the a
from SpecWith a
.
Instead of being an ActionWith a
or an (a -> IO ())
as the first parameter, it’s merely the IO ()
action.
around
around
is pretty tricky.
It encapsulates the pattern above - create something for each test, then tear it down afterwards.
Most uses of before create $ after destroy $ ...
can be refactored to use around
and enjoy greater exception safety.
Let’s start off with around_
.
It doesn’t worry about the extra context, which makes it easier to understand.
around_ :: (IO () -> IO ()) -> SpecWith a -> SpecWith a
Our first argument is a function, which accepts an IO ()
action and returns another one.
The IO ()
can be named runTest
, and it becomes clear how it works:
spec :: Spec
spec =
around_
(\runTest -> do
putStrLn "beginning"
runTest
putStrLn "ending"
)
$ describe "My tests" $
So, our IO ()
parameter is our test, and we can do whatever we want around it.
Let’s get back to around
.
around :: (ActionWith a -> IO ()) -> SpecWith a -> Spec
around :: ((a -> IO ()) -> IO ()) -> SpecWith a -> SpecWith ()
It’s really similar, but our runTest
is now a function from a
to the IO ()
.
Let’s write our user creation/deletion helper with around
.
spec :: Spec
spec =
around
(\runTest -> do
db <- createDatabase
userId <- createUser db
runTest (db, userId)
deleteUser (db, userId)
) $ describe "With User" $ do
it "has a user" $ \(db, userId) -> ...
it "ok ya i get it" $ \(db, userId) -> ...
One thing that’s neat is that we can use bracket
style to safely close out resources, too.
Instead of creating a database connection, let’s use the withDatabase
sort of API.
spec :: Spec
spec =
around
(\runTest -> do
withDatabase $ \db -> do
userId <- createUser db
runTest (db, userId)
deleteUser (db, userId)
) $ describe "With User" $ do
it "has a user" $ \(db, userId) -> ...
it "ok ya i get it" $ \(db, userId) -> ...
Now, if an exception is thrown in the test or in the around
action, the withDatabase
function gets a chance to clean up the database connection.
Resource safety FTW!
aroundWith
You may have noticed that around
results in a Spec
, not a SpecWith
.
You may have further inferred that there must be an aroundWith
that lifted that restriction.
There is, and the type signature is a bit scary.
aroundWith
:: (ActionWith a -> ActionWith b)
-> SpecWith a
-> SpecWith b
-- inlining ActionWith type synonym
aroundWith
:: ((a -> IO ()) -> (b -> IO ()))
-> SpecWith a
-> SpecWith b
-- deleting unnecessary parens
aroundWith
:: ((a -> IO ()) -> b -> IO ())
-> SpecWith a
-> SpecWith b
The callback to aroundWith
is intriguing.
The b
is provided to us, and we must provide an a
to the callback.
That b
represents the “outer context” of our test suite - the result type, what we’re plugging the whole test into.
While the a
represents the “inner context” of the argument SpecWith a
that we’re passed.
aroundWith
is saying: “I know how to unify these two contexts.”
aroundWith $ \runTest outerContext -> do
innerContext <- createInnerContext outerContext
runTest innerContext
Now, we can rewrite our database creation, user creation, etc to properly delete and create these things. More importantly - it happens in a composable manner.
spec :: Spec
spec = do
let
provideDatabase runTest =
withDatabase $ \db ->
runTest db
around provideDatabase $ describe "With Database" $ do
it "has stuff" ...
it "okay" ...
let
provideUser runTest db = do
userId <- createUser db
runTest (db, userId)
deleteUser (db, userId)
aroundWith provideUser $ describe "With User" $ do
it ...
it ...
We can even use bracket
internally, to ensure that exceptions are handled neatly.
spec :: Spec
spec = do
let
provideDatabase runTest =
withDatabase $ \db ->
runTest db
around provideDatabase $ describe "With Database" $ do
it "has stuff" ...
it "okay" ...
let
provideUser runTest db = do
bracket
(createUser db)
(\userId -> deleteUser (db, userId))
(\userId -> runTest (db, userId))
aroundWith provideUser $ describe "With User" $ do
it ...
it ...
Finally, if you’re just mapping the a
type, there’s mapSubject
, which lets you modify the type for the underlying items.
mapSubject :: (b -> a) -> SpecWith a -> SpecWith b
I love writing tests with hspec
.
Hopefully, you’ll enjoy writing fancy composable tests with the library too!