Fuzzing Dates with Elm-Test
Elm, with its elm-test library, allows for a property-based style of testing with its Fuzz test runner and suite of helpers.
Generating dates is something that I’ve found to be a bit of a pain. For the examples below I’ll be using the elm-community/elm-time library.
Broken
Generating the individual parts (year, month, day) with a bunch of Int
s, eg.
import Time.Date exposing (Date, date)
import Fuzz exposing (Fuzzer, int)
sillyDate : Fuzzer Date
sillyDate =
Fuzz.map3 (\y m d -> date y m d)
int -- year
int -- month
int -- day
-- For reference:
-- date : Int -> Int -> Int -> Date
… means you can end up with nonsensical dates, like 2000/99/06
or -5/-9/44
.
And coming up with “sensible” numbers, eg.
conservativeDate : Fuzzer Date
conservativeDate =
Fuzz.map3 (\y m d -> date y m d)
(intRange 1900 2100) -- year
(intRange 1 12) -- month
(intRange 1 28) -- just in case month is February
… means you’re not going to cover the leap-year date of February 29, or even the 30/31-day months the rest of the year.
You could generate some potentially-failing cases and then Fuzz.conditional
-filter the bad ones out, but we’re already heading down a bad road. Let’s see what else we can do instead.
Better
Instead we’re going to start with a year we care about (we’ll pick one later), taking it in as input to a dateForYear : Int -> Fuzzer Date
fuzzer we can stash in a helper module for later reuse.
Time.Date
has an addDays: Int -> Date -> Date
function that, when given a date, will let you add (or subtract) an arbitrary number of days to it; with the library handling the transition as you progress through months and years, the date will stay valid the entire time. If we were to pick the start of the year, eg. date 2017 1 1
for Jan 1st 2017, we could then addDays
to that.
So using this function, we only need two things now: a generated Int for the year, and a generated Int for the number of days we want. They’ll always work together even if generated independently. Solved!
Let’s have a look at how we might define some helpers:
module Test.Helpers.Dates exposing (..)
import Time.Date exposing (Date, date, addDays, isLeapYear)
import Fuzz exposing (Fuzzer, int, intRange)
dateForYear : Int -> Fuzzer Date
dateForYear year =
let
daysUpper = if isLeapYear year then 365 else 364
in
intRange 0 daysUpper
|> Fuzz.map (\days -> addDays days (date year 1 1))
dateWithinYearRange : Int -> Int -> Fuzzer Date
dateWithinYearRange lower upper =
intRange lower upper
|> Fuzz.andThen (\year -> dateForYear year)
… And how we might use them in some tests. They’re silly examples, but show passing and failing cases:
import Test.Helpers.Dates exposing (dateWithinYearRange)
import Date.Extra.Facts
import Expect
import Fuzz exposing (Fuzzer)
import Test exposing (..)
suite : Test
suite =
describe "Random Dates"
[ fuzz (dateNear 2017)
"Always passes"
<| \date ->
date |> Expect.equal date
, fuzz2
(dateForYear 2017)
(dateForYear 2017)
"Fails"
<| \date1 date2 ->
date1 |> Expect.equal date2
, fuzz2 (dateNear 2017) (dateNear 2017)
"Fails a lot more"
<| \date1 date2 ->
date1 |> Expect.equal date2
]
-- A quick convenience:
dateNear : Int -> Fuzzer Date
dateNear y =
dateWithinYearRange (y - 2) (y + 2)
Always passes
does what it says on the tin, and Fails
and Fails a lot more
fall over while preserving some sensible-looking failure output for debugging:
✗ Fails
Given (Date { year = 2017, month = 1, day = 2 },Date { year = 2017, month = 1, day = 1 })
Date { year = 2017, month = 1, day = 2 }
╷
│ Expect.equal
╵
Date { year = 2017, month = 1, day = 1 }
Given (Date { year = 2017, month = 1, day = 1 },Date { year = 2017, month = 1, day = 2 })
Date { year = 2017, month = 1, day = 1 }
╷
│ Expect.equal
╵
Date { year = 2017, month = 1, day = 2 }
↓ Random Dates
✗ Fails a lot more
Given (Date { year = 2019, month = 1, day = 1 },Date { year = 2019, month = 1, day = 2 })
Date { year = 2019, month = 1, day = 1 }
╷
│ Expect.equal
╵
Date { year = 2019, month = 1, day = 2 }
...
Extras
If you want to make sure you hit the leap years, you can either test them separately, or use the Fuzz.frequency
function to make sure they show up sometimes whenever you reach for the date generator:
module Test.Helpers.Dates exposing (..)
import Time.Date exposing (Date, date, isLeapYear)
import Fuzz exposing (Fuzzer, constant, frequency)
import List
dateNear : Int -> Fuzzer Date
dateNear year =
let
knownLeap = 2016
nearestLeap =
List.range -2 2
|> List.map (\n -> year + n)
|> List.foldl (\y rest -> if isLeapYear y then y else rest) knownLeap
in
Fuzz.frequency
[ (1, constant (date nearestLeap 2 29)) -- chosen 25% of the time
, (3, dateWithinYearRange (year - 2) (year + 2)) -- chosen 75% of the time
]
Final Words of Warning
1) The Date
type that the elm-community/elm-time
library exports is a different one to the one that the core Date
module exposes, but the latter appears to be going away. You may need to convert between the two, so just in case you do:
import Time.Date as Time
import Date as Core
coreDateToTimeDate : Core.Date -> Time.Date
coreDateToTimeDate d =
Time.date (Core.year d) (Core.month d) (Core.day d)
2) It looks like Fuzz.andThen
is going away; that means dateWithYearRange
isn’t going to work. I’m not sure what they intend to replace it with.