Testing Dates in Elixir

Wed, Aug 19, 2020

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…

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 :-)