-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathalexis_king2.tex
730 lines (630 loc) · 31.3 KB
/
alexis_king2.tex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
\chapter{Using types to unit-test in Haskell - Alexis King}
\label{sec:using_types_to_unit_test}
\begin{quotation}
\noindent\textit{\textbf{William Yao:}}
\textit{Introduces a way of doing more ``traditional'' unit testing for code with side effects, using mocks and hand-written tests.}
\vspace{\baselineskip}
\noindent\textit{Original article: \cite{using_types_to_unit_test}}
\end{quotation}
\noindent Object-oriented programming languages make unit testing easy by
providing obvious boundaries between units of code in the form of
classes and interfaces. These boundaries make it easy to stub out parts
of a system to test functionality in isolation, which makes it possible
to write fast, deterministic test suites that are robust in the face of
change. When writing Haskell, it can be unclear how to accomplish the
same goals: even inside pure code, it can become difficult to test a
particular code path without also testing all its collaborators.
Fortunately, by taking advantage of Haskell's expressive type system,
it's possible to not only achieve parity with object-oriented testing
techniques, but also to provide stronger static guarantees as well.
Furthermore, it's all possible without resorting to extra-linguistic
hacks that static object-oriented languages sometimes use for mocking,
such as dynamic bytecode generation.
\section{First, an aside on testing
philosophy}
\label{first-an-aside-on-testing-philosophy}
Testing methodology is a controversial topic within the larger
programming community, and there are a multitude of different
approaches. This blog post is about \emph{unit testing}, an already
nebulous term with a number of different definitions. For the purposes
of this post, I will define a unit test as a test that stubs out
collaborators of the code under test in some way. Accomplishing that in
Haskell is what this is primarily about.
I want to be clear that I do not think that unit tests are the only way
to write tests, nor the best way, nor even always an applicable way.
Depending on your domain, rigorous unit testing might not even make
sense, and other forms of tests (end-to-end, integration, benchmarks,
etc.) might fulfill your needs.
In practice, though, implementing those other kinds of tests seems to be
well-documented in Haskell compared to pure, object-oriented style unit
testing. As my Haskell applications have grown, I have found myself
wanting a more fine-grained testing tool that allows me to both test a
piece of my codebase in isolation and also use my domain-specific types.
This blog post is about that.
With that disclaimer out of the way, let's talk about testing in
Haskell.
\hypertarget{drawing-seams-using-types}{%
\section{Drawing seams using types}
\label{drawing-seams-using-types}}
One of the primary attributes of unit tests in object-oriented
languages, especially statically-typed ones, is the concept of ``seams''
within a codebase. These are internal boundaries between components of a
system. Some boundaries are obvious---interactions with a database,
manipulation of the file system, and performing I/O over the network, to
name a few examples---but others are more subtle. Especially in larger
codebases, it can be helpful to isolate two related but distinct pieces
of functionality as much as possible, which makes them easier to reason
about, even if they're actually part of the same codebase.
In OO languages, these seams are often marked using interfaces, whether
explicitly (in the case of static languages) or implicitly (in the case
of dynamic ones). By programming to an interface, it's possible to
create ``fake'' implementations of that interface for use in unit tests,
effectively making it possible to stub out code that isn't directly
relevant to the code being tested.
In Haskell, representing these seams is a lot less obvious. Consider a
fairly trivial function that reverses a file's contents on the file
system:
\begin{minted}{haskell}
reverseFile :: FilePath -> IO ()
reverseFile path = do
contents <- readFile path
writeFile path (reverse contents)
\end{minted}
This function is impossible to test without testing against a real file
system. It simply performs I/O directly, and there's no way to ``mock
out'' the file system for testing purposes. Now, admittedly, this
function is so trivial that a unit test might not seem worth the cost,
but consider a slightly more complicated function that interacts with a
database:
\begin{minted}{haskell}
renderUserProfile :: Id User -> IO HTML
renderUserProfile userId = do
user <- fetchUser userId
posts <- fetchRecentPosts userId
return $ div
[ h1 (userName user <> "’s Profile")
, h2 "Recent Posts"
, ul (map (li . postTitle) posts)
]
\end{minted}
It might now be a bit more clear that it could be useful to test the
above function without running a real database and doing all the
necessary context setup before each test case. Indeed, it would be nice
if a test could just provide stubbed implementations for
\texttt{fetchUser} and \texttt{fetchRecentPosts}, then make assertions
about the output.
One way to solve this problem is to pass the results of those two
functions to \texttt{renderUserProfile} as arguments, turning it into a
pure function that could be easily tested. This becomes obnoxious for
functions of even just slightly more complexity, though (it is not
unreasonable to imagine needing a handful of different queries to render
a user's profile page), and it requires significantly restructuring code
simply because the tests need it.
The above code is not only difficult to test, however---it has another
problem, too. Specifically, both functions return \texttt{IO} values,
which means they can effectively do \emph{anything}. Haskell has a very
strong type system for typing terms, but it doesn't provide any
guarantees about effects beyond a simple yes/no answer about function
purity. Even though the \texttt{renderUserProfile} function should
really only need to interact with the database, it could theoretically
delete files, send emails, make HTTP requests, or do any number of other
things.
Fortunately, it's possible to solve \emph{both} problems---a lack of
testability and a lack of type safety---using the same general
technique. This approach is reminiscent of the interface-based seams of
object-oriented languages, but unlike most object-oriented approaches,
it provides additional type safety guarantees without the need to
explicitly modify the code to support some kind of dependency injection.
\subsection{Making implicit interfaces
explicit}
\label{making-implicit-interfaces-explicit}
Statically typed, object-oriented languages provide interfaces as a
language construct to encode certain kinds of contracts into the type
system, and Haskell has something similar. Typeclasses are, in many
ways, an analog to OO interfaces, and they can be used in a similar way.
In the above case, let's write down interfaces that the
\texttt{reverseFile} and \texttt{renderUserProfile} functions can use:
\begin{minted}{haskell}
class Monad m => MonadFS m where
readFile :: FilePath -> m String
writeFile :: FilePath -> String -> m ()
class Monad m => MonadDB m where
fetchUser :: Id User -> m User
fetchRecentPosts :: Id User -> m [Post]
\end{minted}
The really nice thing about these interfaces is that our function
implementations don't have to change \emph{at all} to take advantage of
them. In fact, all we have to change is their types:
\begin{minted}{haskell}
reverseFile :: MonadFS m => FilePath -> m ()
reverseFile path = do
contents <- readFile path
writeFile path (reverse contents)
renderUserProfile :: MonadDB m => Id User -> m HTML
renderUserProfile userId = do
user <- fetchUser userId
posts <- fetchRecentPosts userId
return $ div
[ h1 (userName user <> "’s Profile")
, h2 "Recent Posts"
, ul (map (li . postTitle) posts)
]
\end{minted}
This is pretty neat, since we haven't had to alter our code at all, but
we've managed to completely decouple ourselves from \texttt{IO}. This
has the direct effect of both making our code more abstract (we no
longer rely on the ``real'' file system or a ``real'' database, which
makes our code easier to test) and restricting what our functions can do
(just from looking at the type signatures, we know what side-effects
they can perform).
Of course, since we're now coding against an interface, our code doesn't
actually do much of anything. If we want to actually use the functions
we've written, we'll have to define instances of \texttt{MonadFS} and
\texttt{MonadDB}. When actually running our code, we'll probably still
use \texttt{IO} (or some monad transformer stack with \texttt{IO} at the
bottom), so we can define trivial instances for that existing use case:
\begin{minted}{haskell}
instance MonadFS IO where
readFile = Prelude.readFile
writeFile = Prelude.writeFile
instance MonadDB IO where
fetchUser = SQL.fetchUser
fetchRecentPosts = SQL.fetchRecentPosts
\end{minted}
Even if we go no further, \textbf{this is already incredibly useful}. By
restricting the sorts of effects our functions can perform at the type
level, it becomes a lot easier to see which code is interacting with
what. This can be invaluable when working in a part of a moderately
large codebase that you are unfamiliar with. Even if the only instance
of these typeclasses is \texttt{IO}, the benefits are immediately
apparent.
Of course, this blog post is about testing, so we're going to go further
and take advantage of these seams we've now drawn. The question is: how?
\section{Testing with typeclasses: an initial
attempt}
\label{testing-with-typeclasses-an-initial-attempt}
Given that we now have functions depending on an interface instead of
\texttt{IO}, we can create separate instances of our typeclasses for use
in tests. Let's start with the \texttt{renderUserProfile} function.
We'll create a simple wrapper around the \texttt{Identity} type, since
we don't actually care much about the ``effects'' of our
\texttt{MonadDB} methods:
\begin{minted}{haskell}
import Data.Functor.Identity
newtype TestM a = TestM (Identity a)
deriving (Functor, Applicative, Monad)
unTestM :: TestM a -> a
unTestM (TestM (Identity x)) = x
\end{minted}
Now, we'll create a trivial instance of \texttt{MonadDB} for
\texttt{TestM}:
\begin{minted}{haskell}
instance MonadDB TestM where
fetchUser _ = return User { userName = "Alyssa" }
fetchRecentPosts _ = return
[ Post { postTitle = "Metacircular Evaluator" } ]
\end{minted}
With this instance, it's now possible to write a simple unit test of the
\texttt{renderUserProfile} function that doesn't need a real database
running at all:
\begin{minted}{haskell}
spec = describe "renderUserProfile" $ do
it "shows the user’s name" $ do
let result = unTestM (renderUserProfile (intToId 1234))
result `shouldContainElement` h1 "Alyssa’s Profile"
it "shows a list of the user’s posts" $ do
let result = unTestM (renderUserProfile (intToId 1234))
result `shouldContainElement` ul [ li "Metacircular Evaluator" ]
\end{minted}
This is pretty nice, and running the above tests reveals a nice property
of these kinds of isolated test cases: the test suite runs \emph{really,
really fast}. Communicating with a database, even in extremely simple
ways, takes a measurable amount of time, especially with dozens of
tests. In contrast, even with hundreds of tests, our unit test suite
runs in less than a tenth of a second.
This all seems to be successful, so let's try and apply the same testing
technique to \texttt{reverseFile}.
\subsection{Testing side-effectful
code}
\label{testing-side-effectful-code}
Looking at the type signature for \texttt{reverseFile}, we have a small
problem:
\begin{minted}{haskell}
reverseFile :: MonadFS m => FilePath -> m ()
\end{minted}
Specifically, the return type is \texttt{()}. Making any assertions
against the result of this function would be completely worthless, given
that it's guaranteed to be the same exact thing each time. Instead,
\texttt{reverseFile} is inherently side-effectful, so we want to be able
to test that it properly interacts with the file system in the correct
way.
In order to do this, a simple wrapper around \texttt{Identity} won't be
enough, but we can replace it with something more powerful:
\texttt{Writer}. Specifically, we can use a writer monad to ``log'' what
gets called in order to test side-effects. We'll start by creating a new
\texttt{TestM} type, just like last time:
\begin{minted}{haskell}
newtype TestM a = TestM (Writer [String] a)
deriving (Functor, Applicative, Monad, MonadWriter [String])
logTestM :: TestM a -> [String]
logTestM (TestM w) = execWriter w
\end{minted}
Using this slightly more powerful type, we can write a useful instance
of \texttt{MonadFS} that will track the argument given to
\texttt{writeFile}:
\begin{minted}{haskell}
instance MonadFS TestM where
readFile _ = return "hello"
writeFile _ contents = tell [contents]
\end{minted}
Again, the instance is quite simple, but it now enables us to write a
straightforward unit test for \texttt{reverseFile}:
\begin{minted}{haskell}
spec = describe "reverseFile" $
it "reverses a file’s contents on the filesystem" $ do
let calls = logTestM (reverseFile "foo.txt")
calls `shouldBe` ["olleh"]
\end{minted}
Again, quite simple to both implement and use, and the test itself is
blindingly fast. There's another problem, though, which is that we have
technically left part of \texttt{reverseFile} untested: we've completely
ignored the \texttt{path} argument.
In this contrived example, it may seem silly to test something so
trivial, but in real code, it's quite possible that one would care very
much about testing multiple different aspects about a single function.
When testing \texttt{renderUserProfile}, this was not hard, since we
could reuse the same \texttt{TestM} type and \texttt{MonadDB} instance
for both test cases, but in the \texttt{reverseFile} example, we've
ignored the path entirely.
We \emph{could} adjust our \texttt{MonadFS} instance to also track the
path provided to each method, but this has a few problems. First, it
means every test case would depend on all the various properties we are
testing, which would mean updating every test case when we add a new
one. It would also be simply impossible if we needed to track multiple
types---in this particular case, it turns out that \texttt{String} and
\texttt{FilePath} are actually the same type, but in practice, there may
be a handful of disparate, incompatible types.
Both of the above issues could be fixed by creating a sum type and
manually filtering out the relevant elements in each test case, but a
much more intuitive approach would be to simply have a separate instance
for each case. Unfortunately, in Haskell, creating a new instance means
creating an entirely new type. To illustrate how much duplication that
would entail, we could create the following type and instance for
testing proper propagation of the \texttt{path} argument:
\begin{minted}{haskell}
newtype TestM' a = TestM' (Writer [FilePath] a)
deriving (Functor, Applicative, Monad, MonadWriter [FilePath])
logTestM' :: TestM' a -> [FilePath]
logTestM' (TestM' w) = execWriter w
instance MonadFS TestM' where
readFile path = tell [path] >> return ""
writeFile path _ = tell [path]
\end{minted}
Now it's possible to add an extra test case that asserts that the proper
path is provided to the two filesystem functions:
\begin{minted}{haskell}
spec = describe "reverseFile" $ do
it "reverses a file’s contents on the filesystem" $ do
let calls = logTestM (reverseFile "foo.txt")
calls `shouldBe` ["olleh"]
it "operates on the file at the provided path" $ do
let paths = logTestM' (reverseFile "foo.txt")
paths `shouldBe` ["foo.txt", "foo.txt"]
\end{minted}
This works, but it's ultimately unacceptably complicated. Our test
harness code is now significantly larger than the actual tests
themselves, and the amount of boilerplate is frustrating. Verbose test
suites are especially bad, since forcing programmers to jump through
hoops just to implement a single test reduces the likelihood that people
will actually write good tests, if they write tests at all. In contrast,
if writing tests is easy, then people will naturally write more of them.
The above strategy to writing tests is not good enough, but it does
reveal a particular problem: in Haskell, typeclass instances are not
first-class values that can be manipulated and abstracted over, they are
static constructs that can only be managed by the compiler, and users do
not have a direct way to modify them. With some cleverness, however, we
can actually create an approximation of first-class typeclass
dictionaries, which will allow us to dramatically simplify the above
testing mechanism.
\section{Creating first-class typeclass
instances}
\label{creating-first-class-typeclass-instances}
In order to provide an easy way to construct instances, we need a way to
represent instances as ordinary Haskell values. This is not terribly
difficult, given that instances are conceptually just records containing
a collection of functions. For example, we could create a datatype that
represents an instance of the \texttt{MonadFS} typeclass:
\begin{minted}{haskell}
data MonadFSInst m = MonadFSInst
{ _readFile :: FilePath -> m String
, _writeFile :: FilePath -> String -> m ()
}
\end{minted}
To avoid namespace clashes with the actual method identifiers, the
record fields are prefixed with an underscore, but otherwise, the
translation is remarkably straightforward. Using this record type, we
can easily create values that represent the two instances we defined
above:
\begin{minted}{haskell}
contentInst :: MonadWriter [String] m => MonadFSInst m
contentInst = MonadFSInst
{ _readFile = \_ -> return "hello"
, _writeFile = \_ contents -> tell [contents]
}
pathInst :: MonadWriter [FilePath] m => MonadFSInst m
pathInst = MonadFSInst
{ _readFile = \path -> tell [path] >> return ""
, _writeFile = \path _ -> tell [path]
}
\end{minted}
These two values represent two different implementations of
\texttt{MonadFS}, but since they're ordinary Haskell values, they can be
manipulated and even \emph{extended} like any other records. This can be
extremely useful, since it makes it possible to create a sort of
``base'' instance, then have individual test cases override individual
pieces of functionality piecemeal.
Of course, although we've written these two instances, we have no way to
actually use them. After all, Haskell does not provide a way to
explicitly provide typeclass dictionaries. Fortunately, we can create a
sort of ``proxy'' type that will use a reader to thread the dictionary
around explicitly, and the instance can defer to the dictionary's
implementation.
\subsection{Creating an instance
proxy}
\label{creating-an-instance-proxy}
To represent our proxy type, we'll use a combination of a
\texttt{Writer} and a \texttt{ReaderT}; the former to implement the
logging used by instances, and the latter to actually thread around the
dictionary. Our type will look like this:
\begin{minted}{haskell}
newtype TestM log a =
TestM (ReaderT (MonadFSInst (TestM log)) (Writer log) a)
deriving ( Functor, Applicative, Monad
, MonadReader (MonadFSInst (TestM log))
, MonadWriter log
)
logTestM :: MonadFSInst (TestM log) -> TestM log a -> log
logTestM inst (TestM m) = execWriter (runReaderT m inst)
\end{minted}
This might look rather complicated, and it is, but let's break down
exactly what it's doing.
\begin{enumerate}
\item
The \texttt{TestM} type includes two type parameters. The first is the
type of value that will be logged (hence the name \texttt{log}), which
corresponds to the argument to \texttt{Writer} from previous
incarnations of \texttt{TestM}. Unlike those types, though, we want
this version to work with any \texttt{Monoid}, so we'll make it a type
parameter. The second parameter is simply the type of the current
monadic value, as before.
\item
The type itself is defined as a wrapper around a small monad
transformer stack, the first of which is \texttt{ReaderT}. The state
threaded around by the reader is, in this case, the instance
dictionary, which is \texttt{MonadFSInst}.
However, recall that \texttt{MonadFSInst} accepts a type
variable---the type of a monad itself---so we must provide
\texttt{TestM\ log} as an argument to \texttt{MonadFSInst}. This
slight bit of indirection allows us to tie the knot between the
mutually dependent instances and proxy type.
\item
The base monad in the transformer stack is \texttt{Writer}, which is
used to actually implement the logging functionality, just like in
prior cases. The only difference now is that the \texttt{log} type
parameter now determines what the writer actually produces.
\item
Finally, as before, we use \texttt{GeneralizedNewtypeDeriving} to
derive all the relevant \texttt{mtl} classes, adding the somewhat
wordy \texttt{MonadReader} constraint to the list.
\end{enumerate}
Using this single type, we can now implement a \texttt{MonadFS} instance
that defers to the dictionary carried around within \texttt{TestM}'s
reader state:
\begin{minted}{haskell}
instance Monoid log => MonadFS (TestM log) where
readFile path = do
f <- asks _readFile
f path
writeFile path contents = do
f <- asks _writeFile
f path contents
\end{minted}
This may seem somewhat boilerplate-y, and it is to some extent, but the
important consideration is that this boilerplate only needs to be
written \emph{once}. With this in place, it's now possible to write an
arbitrary number of first-class instances that use the above mechanism
without extending the mechanism at all.
To see what actually using this code would look like, let's update the
\texttt{reverseFile} tests to use the new \texttt{TestM} implementation,
as well as the \texttt{contentInst} and \texttt{pathInst} dictionaries
from earlier:
\begin{minted}{haskell}
spec = describe "reverseFile" $ do
it "reverses a file’s contents on the filesystem" $ do
let calls = logTestM contentInst (reverseFile "foo.txt")
calls `shouldBe` ["olleh"]
it "operates on the file at the provided path" $ do
let paths = logTestM pathInst (reverseFile "foo.txt")
paths `shouldBe` ["foo.txt", "foo.txt"]
\end{minted}
We can do a little bit better, though. Really, the definitions of
\texttt{contentInst} and \texttt{pathInst} are specific to each test
case. With ordinary typeclass instances, we cannot scope them to any
particular block, but since \texttt{MonadFSInst} is just an ordinary
Haskell datatype, we can manipulate them just like any other Haskell
values. Therefore, we can just inline those instances' definitions into
the test cases themselves to keep them closer to the actual tests.
\begin{minted}{haskell}
spec = describe "reverseFile" $ do
it "reverses a file’s contents on the filesystem" $ do
let contentInst = MonadFSInst
{ _readFile = \_ -> return "hello"
, _writeFile = \_ contents -> tell [contents]
}
let calls = logTestM contentInst (reverseFile "foo.txt")
calls `shouldBe` ["olleh"]
it "operates on the file at the provided path" $ do
let pathInst = MonadFSInst
{ _readFile = \path -> tell [path] >> return ""
, _writeFile = \path _ -> tell [path]
}
let paths = logTestM pathInst (reverseFile "foo.txt")
paths `shouldBe` ["foo.txt", "foo.txt"]
\end{minted}
This is pretty good. We're now able to create inline instances of our
\texttt{MonadFS} typeclass, which allows us to write extremely concise
unit tests using ordinary Haskell typeclasses as system seams. We've
managed to cut down on the boilerplate considerably, though we still
have a couple problems. For one, this example only uses a single
typeclass containing only two methods. A real \texttt{MonadFS} typeclass
would likely have at least a dozen methods for performing various
filesystem operations, and writing out the instance dictionaries for
every single method, even the ones that aren't used within the code
under test, would be pretty frustratingly verbose.
This problem is solvable, though. Since instances are just ordinary
Haskell records, we can create a ``base'' instance that just throws an
exception whenever the method is called:
\begin{minted}{haskell}
baseInst :: MonadFSInst m
baseInst = MonadFSInst
{ _readFile = error "unimplemented instance method ‘_readFile’"
, _writeFile = error "unimplemented instance method ‘_writeFile’"
}
\end{minted}
Then code that only uses \texttt{readFile} could only override that
particular method, for example:
\begin{minted}{haskell}
let myInst = baseInst { _readFile = ... }
\end{minted}
Normally, of course, this would be a terrible idea. However, since this
is all just test code, it can be extremely useful in quickly figuring
out what methods need to be stubbed out for a particular test case.
Since all the code actually gets run at test time, attempts to use
unimplemented instance methods will immediately raise an error,
informing the programmer which methods need to be implemented to make
the test pass. This can also help to significantly cut down on the
amount of effort it takes to implement each test.
Another problem is that our approach is specialized exclusively to
\texttt{MonadFS}. What about functions that use both \texttt{MonadFS}
\emph{and} \texttt{MonadDB}, for example? Fortunately, that is not hard
to solve, either. We can adapt the \texttt{MonadFSInst} type to include
fields for all of the typeclasses relevant to our system, turning it
into a generic test fixture of sorts:
\begin{minted}{haskell}
data FixtureInst m = FixtureInst
{ -- MonadFS
_readFile :: FilePath -> m String
, _writeFile :: FilePath -> String -> m ()
-- MonadDB
, _fetchUser :: Id User -> m User
, _fetchRecentPosts :: Id User -> m [Post]
}
\end{minted}
Updating \texttt{TestM} to use \texttt{FixtureInst} instead of
\texttt{MonadFSInst} is trivial, and all the rest of the infrastructure
still works. However, this means that every time a new typeclass is
added, three things need to be updated:
\begin{enumerate}
\item
Its methods need to be added to the \texttt{FixtureInst} record.
\item
Those methods need to be given error-raising defaults in the
\texttt{baseInst} value.
\item
An actual instance of the typeclass needs to be written for
\texttt{TestM} that defers to the \texttt{FixtureInst} value.
\end{enumerate}
Furthermore, most of this manual manipulation of methods is required
every time a particular typeclass changes, whether that means adding a
method, removing a method, renaming a method, or changing a method's
type. This is especially frustrating given that all this code is really
just mechanical boilerplate that could all be derived by the set of
typeclasses being tested.
That last point is especially important: aside from the instances
themselves, every piece of boilerplate above is obviously possible to
generate from existing types alone. With that piece of information in
mind, we can do even better: we can use Template Haskell.
\hypertarget{removing-the-boilerplate-using-test-fixture}{%
\section{\texorpdfstring{Removing the boilerplate using
\texttt{test-fixture}}{Removing the boilerplate using test-fixture}}
\label{removing-the-boilerplate-using-test-fixture}}
The above code was not only rather boilerplate-heavy, it was pretty
complicated. Fortunately, you don't actually have to write it. Enter the
library
\href{http://hackage.haskell.org/package/test-fixture}{\texttt{test-fixture}}:
\begin{minted}{haskell}
import Control.Monad.TestFixture
import Control.Monad.TestFixture.TH
mkFixture "FixtureInst" [''MonadFS, ''MonadDB]
spec = describe "reverseFile" $ do
it "reverses a file’s contents on the filesystem" $ do
let contentInst = def
{ _readFile = \_ -> return "hello"
, _writeFile = \_ contents -> log contents
}
let calls = logTestFixture (reverseFile "foo.txt") contentInst
calls `shouldBe` ["olleh"]
it "operates on the file at the provided path" $ do
let pathInst = def
{ _readFile = \path -> log path >> return ""
, _writeFile = \path _ -> log path
}
let paths = logTestFixture (reverseFile "foo.txt") pathInst
paths `shouldBe` ["foo.txt", "foo.txt"]
\end{minted}
\textbf{That's it.} The above code automatically generates everything
you need to write fast, simple, deterministic unit tests in Haskell. The
\texttt{mkFixture} function is a Template Haskell macro that expands
into a definition quite similar to the \texttt{FixtureInst} type we
wrote by hand, but since it's automatically generated from the typeclass
definitions, it never needs to be updated.
The \texttt{logTestFixture} function replaces the \texttt{logTestM}
function we wrote by hand, but it works exactly the same. The
\texttt{Control.Monad.TestFixture} library also exports a \texttt{log}
function that is a synonym for \texttt{tell\ .\ singleton}, but using
\texttt{tell} directly still works if you prefer.
The \texttt{mkFixture} function also generates a \texttt{Default}
instance, which replaces the \texttt{baseInst} value defined earlier. It
functions the same way, though, producing useful error messages that
refer to the names of unimplemented typeclass methods that have not been
stubbed out.
This blog post is not a \texttt{test-fixture} tutorial---indeed, it is
much more complicated than a \texttt{test-fixture} tutorial would be,
since it covers what the library is really doing under the hood---but if
you're interested, I would highly recommend you take a look at the
\href{http://hackage.haskell.org/package/test-fixture}{\texttt{test-fixture}
documentation on Hackage}.
\section{Conclusion, credits, and similar
techniques}
\label{conclusion-credits-and-similar-techniques}
This blog post came about as the result of a need my coworkers and I
found when writing Haskell code; we wanted a way to write unit tests
quickly and easily, but we didn't find much advice from the rest of the
Haskell ecosystem. The \texttt{test-fixture} library is the result of
that exploratory work, and we currently use it to test a significant
portion of our Haskell code.
It would be extremely unfair to suggest that I was the inventor of this
technique or the inventor of the library. Two of my coworkers,
\href{https://github.com/jxv}{Joe Vargas} and
\href{https://github.com/aztecrex}{Greg Wiley}, came up with the general
approach and wrote \texttt{Control.Monad.TestFixture}, and I simply
wrote the Template Haskell macro to eliminate the boilerplate. With that
in mind, I think I can say with some fairness that I think this
technique is a joy to use when unit testing is a desirable goal, and I
would definitely recommend it if you are interested in doing isolated
testing in Haskell.
The general technique of using typeclasses to emulate effects was in
part inspired by the well-known \texttt{mtl} library. An alternate
approach to writing unit-testable Haskell code is using free monads, but
overall, I prefer this approach over free monads because the typeclass
constraints add type safety in ways that free monads do not (at least
not without additional boilerplate), and this approach also lends itself
well to static analysis-based boilerplate reduction techniques. It has
its own tradeoffs, though, so if you've had success with free monads,
then I certainly make no claim this is a superior approach, just one
that I've personally found pleasant.
As a final note, if you \emph{do} check out \texttt{test-fixture}, feel
free to leave feedback by opening issues on
\href{https://github.com/cjdev/test-fixture/issues}{the GitHub issue
tracker}---even things like confusing documentation are worth a bug
report.