Since Lunar Logic is the oldest Ruby shop in Poland, it's no wonder people have been exposed extensively to Ruby and Rails. Being part of the community, we have gotten accustomed to its values. In particular, we have used automated testing a bunch. This is made easy by the gems and tools that are available in the ecosystem.
Unfortunately, when the friction of doing something approaches zero, so does employing it without questioning its costs and benefits. As a matter of fact, testing does not come for free: tests are code and as such require time to write and maintain.
We argue that automated testing is yet another tool to achieve a shorter feedback loop. But not the only one and not the right one in every possible situation. Also, tests can be used in a variety of scenarios: TDD, unit testing, property-based testing, acceptance testing, etc.
At the unit level, where we will focus our attention in this post, sometimes it's arguable if tests are needed. That is especially true when working with a language with a sound type system like Elm.
But let's talk with some code in front of our eyes. We will start with a stupid example just to prove the point and then move to production code from the open source AirCasting.
The first example is the
sum a b = a + b
Should we test it? Of course not! Let's forget for a second we shouldn't have written the function in the first place: there's no need to redefine
+. In any case, the Elm compiler applies
+ only to
numbers. Also, the fact that
+ returns the correct result should be and is guaranteed by the language maintainers. Therefore, the only problems we could introduce in the
sum function are using the wrong operator (e.g.
-) or not using the addends properly (e.g.
sum a b = a,
sum a b = a + 1).
What about the following
boolToInt bool = case bool of True -> 1 False -> 0
Elm enforces callers to use a Boolean for
bool. That's because the function branches on
False. This function is so simple and declarative that we wouldn't put the effort to test. Where we could consider a test is when composing
boolToInt together in a more complex
sumBoolsOrDefault predicate bool1 bool2 default = if predicate then sum (boolToInt bool1) (boolToInt bool2) else default
Following a functional programming style the basic building blocks of a program are simple and declarative functions. Most of the times the developer and the type system are enough to guarantee the correctness. It's when composing simple things together that testing starts paying off. But where's the tipping point? It depends.
Let's take an example from AirCasting. In particular, each session at the bottom of the screen shows the timeframe in which measurements were taken. For recordings spanning multiple days we want to display "mm/dd/yyyy hh:mm - mm/dd/yyyy hh:mm" otherwise "mm/dd/yyyy hh:mm - hh:mm":
The logic resides in the
Times module. Let's follow the thinking process that brought us to the formatting code.
Therefore we first need to write functions to extract and format year, month, day, hour and minutes from
For the year it's enough to use
toYear : Zone -> Posix -> Int and take the last two digits:
toYear posix = posix |> Time.toYear Time.utc |> String.fromInt |> String.right 2
For the day we can use
toDay : Zone -> Posix -> Int and pad a "0" to the left if needed to have two digits, thus:
toDay posix = posix |> Time.toDay Time.utc |> String.fromInt |> String.padLeft 2 '0'
For the month we can use
toMonth : Zone -> Posix -> Month and write a function to transform a
Month into a string:
monthToString month = case month of Jan -> "01" Feb -> "02" Mar -> "03" ...
Hours and minutes follow a similar pattern:
toHour posix = posix |> Time.toHour Time.utc |> String.fromInt |> String.padLeft 2 '0' toMinute posix = posix |> Time.toMinute Time.utc |> String.fromInt |> String.padLeft 2 '0'
Also, we need to distinguish between two posix values being on the same date or not:
areOnSameDate p1 p2 = toYear p1 == toYear p2 && toMonth p1 == toMonth p2 && toDay p1 == toDay p2
Should we test the functions mentioned above? In our case we haven't done so. In fact, they are all simple and declarative building blocks. Now, let's put everything together:
format start end = let toFullDate p = toMonth p ++ "/" ++ toDay p ++ "/" ++ toYear p ++ " " ++ toTime p in toFullDate start ++ (if areOnSameDate start end then "-" ++ toTime end else " - " ++ toFullDate end )
We used types to drive the development and the
format function is where we decided to test. That's because the function is "complex" enough to necessitate test coverage.
What defines "complex" is hard to say. But I can steal some wisdom out of an Elm discourse thread:
- it's hard to predict;
- tricky code;
- I fear or I know I will fear of changing some functions;
- encoders / decoders;
- sequence of actions for data;
- logic that can't easily be encoded in the type system;
- correctness in very important parts.
Be sure to read the thread mentioned above because it's full of great stuff. Thanks a lot to all the people who have participated and shared so much there!
Special thanks to Rafał for proofreading this post.