Testing Dates in Elixir
I recently worked on an Elixir/Phoenix project with some complex date processing logic and
needed to test the behaviour in a simple and predictable way. Unfortunately any code that
directly uses DateTime.utc_now
will generate different results every time we run the
test suite and make it hard to assert
the correct results.
For example, consider the following trivial example:
defmodule Event
defstruct [ :type, :occurred_on ]
def new(type) do
%Event{ type: type, occurred_on: DateTime.utc_now }
end
end
test "event occurs now" do
event = Event.new(:signup)
assert event.name == :signup
assert event.occurred_on == DateTime.utc_now # BANG!
end
This test fails because time progressed between Event.new
and the final assert
.
To resolve this issue we need to be able to mock DateTime.now
to always return a fixed, known
time. It would also be nice to provide convient time travel methods to travel
forward and backward in time while testing. The simplest way to achieve this is to ensure that
any code that needs access to the current time uses a proxy Clock
module instead of
using DateTime.now
directly.
We start with a basic module that forwards the call directly…
defmodule MyApp.Clock do
def utc_now
DateTime.utc_now
end
end
For the remainder of this article we will incrementally enhance this module to enable mocking the time returned, as well as adding time travel helper methods and more…
A Simple Mockable Clock
If we want to freeze
the current time then the first decision we have to make is where should we
store that ephemeral information? Each test must be able to freeze time independently, so
we can’t use a global Agent
or GenServer
. Luckily the Elixir testing framework - ExUnit
- runs
each individual test as it’s own process so we already have the perfect place - the Process dictionary.
The process dictionary is an in-memory key/value store that is unique to the current process. Think
of it a little bit like thread local storage in other languages.
Let’s use the process dictionary to add the ability to freeze and unfreeze the current time:
defmodule MyApp.Clock do
def utc_now do
Process.get(:mock_utc_now) || DateTime.utc_now
end
def freeze do
Process.put(:mock_utc_now, utc_now())
end
def freeze(%DateTime{} = on) do
Process.put(:mock_utc_now, on)
end
def unfreeze do
Process.delete(:mock_utc_now)
end
end
Basic Usage
Using our Clock
module we can update the trivial example from earlier:
defmodule Event
defstruct [ :type, :occurred_on ]
def new(type) do
%Event{ type: type, occurred_on: Clock.utc_now }
end
end
test "event occurs now" do
Clock.freeze
event = Event.new(:signup)
assert event.name == :signup
assert event.occurred_on == Clock.utc_now
end
This time the test will pass because we freeze the time at the start of the test, so the call
to Clock.utc_now
hidden inside Event.new
will return the exact same value as that returned in
the final assert
.
As well as simply freezing the current time in order to stabilize the test, we can go further
and freeze the clock at any specific moment in time. Imagine if our example Event
module had an
occurred_on_label
to display the time as a string. We could test this method in a
predictable way with an explicit date:
test "event.occurred_on_label" do
Clock.freeze ~U[2020-01-01 10:20:30Z]
event = Event.new(:signup)
assert Event.occurred_on_label(event) == "Jan 1st 2020, 10:20am"
end
By substituting DateTime.utc_now
with Clock.utc_now
throughout, we have gained control over the
current time within our test environment.
Configure Ecto to use our mockable Clock
In addition to our application code, we may be using Ecto for database access. In which case it is
quite likely that Ecto is configured to automatically populate created_at
and updated_at
timestamp fields by calling DateTime.utc_now
. Luckily Ecto can be configured to use a custom
method when populating these fields. In our schema modules we can configure Ecto to use our
new Clock
instead:
defmodule MyApp.User
use Ecto.Schema
@timestamps_opts [
autogenerate: { MyApp.Clock, :utc_now, [] },
type: :utc_datetime_usec # use :microsecond precision when storing timestamps
]
schema "users" do
# ...
end
end
To avoid having to duplicate this configuration in every schema, we might want to create our
own base module and update all schemas to use MyApp.Schema
instead of use Ecto.Schema
:
defmodule MyApp.Schema do
defmacro __using__(_) do
quote do
use Ecto.Schema
@timestamps_opts [
autogenerate: { MyApp.Clock, :timestamp, [] },
type: :utc_datetime_usec,
]
end
end
end
defmodule MyApp.User
use MyApp.Schema
...
end
Time Travel
Now that we have our custom Clock
module we can extend it further with helpers to allow our tests to run
at any point in time:
defmacro time_travel(to, do: block) do
quote do
previous = Clock.utc_now # save the current time
Clock.freeze(unquote(to)) # freeze the clock at the new time
result = unquote(block) # run the test block
Clock.freeze(previous) # reset the clock back to the previous time
result # and return the result
end
end
Giving us even more flexibility in our tests…
test "event.new always occurs 'now'" do
Clock.time_travel ~U[2020-01-01 12:30:00Z] do
event = Event.new(:signup)
assert event.occurred_on_label == "Jan 1st 2020, 12:30pm"
end
Clock.time_travel ~U[2020-02-02 12:30:00Z] do
event = Event.new(:signup)
assert event.occurred_on_label == "Feb 2nd 2020, 12:30pm"
end
end
A Configurable Mockable Clock
Finally, if we want to ensure that the Clock
is not accidentally manipulated in our production
environment then we can hide the freezing and time travel methods behind application
configuration in config/test.exs
config :up, MyApp.Clock, freezable: :true
… and wrap the dangerous methods behind the compile time configuration flag…
defmodule MyApp.Clock do
@config Application.compile_env(:app, __MODULE__) || []
if @config[:freezable] do
# ... define freezing and time travel methods from earlier here
else
# ... otherwise only allow read-only access to the current time
def utc_now do
DateTime.utc_now
end
end
end
Conclusion
In order to write stable and predictable tests for applications that deal with time we need a simple way to control the current time in our tests. This article has shown how to do this for an Elixir project…
- Implement a proxy
Clock
module to encapsulate access toDateTime.utc_now
- Update application code to use
Clock.utc_now
instead ofDateTime.utc_now
- Configure Ecto to use
Clock.utc_now
instead ofDateTime.utc_now
- Add
Clock
helper methods for tests to freeze and travel thru time - Use the Process dictionary to store the (per-test) frozen time.
- Write awesome tests!
The Clock
module in my recent project includes more powerful time travel test helpers such as
Clock.from_now
and Clock.ago
. It also becomes a very convenient place to provide real run-time
helpers for timezone conversion from UTC to LOCAL times - but that’s a much-more complex
conversation for another time :-)