Coming from the Ruby/Rails world I was searching for a way to mock the current time in my Elixir test suite, something like Timecop for Elixir. I didn’t find anything that suited my needs so I decided to give it a go by myself.

I had a module that contained following code:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 defmodule Multipster . Token do def encode ( user ) do # [...] end def decode ( token ) do # [...] end defp verify_expiration ( expiration ) do expiration > current_timestamp () end defp get_expiration do current_timestamp () + 30 * 60 end defp current_timestamp do :os . system_time ( :second ) end end

As you can see it depends on the current time. What if we want to test that an expired token is invalid? We have to encode the token, travel in time, and try to decode it.

1 2 3 4 5 6 7 8 9 10 11 12 defmodule Multipster . TokenTest do use ExUnit . Case , async: true alias Multipster . Token test "return error when token expired" do token = Token . encode ( % Multipster . User { id: 4 }) # travel in time { :error , _ } = Token . decode ( token ) end end

How can we travel in time? We have to manipulate the current time.

My main source of knowledge about mocking in Elixir was Mocks and explicit contracts by José Valim himself. The article suggests to avoid mocking (as a verb) and instead write mocks (as a noun). I was curious of this approach so I decided to give it a try.

I changed the implementation of current_timestamp/0 to:

1 2 3 defp current_timestamp do Multipster . CurrentTime . get_timestamp () end

Multipster.CurrentTime is just an interface that delegates the function call to a selected implementation:

1 2 3 4 5 6 7 8 9 defmodule Multipster . CurrentTime do @adapter :multipster |> Application . get_env ( __MODULE__ ) |> Keyword . fetch! ( :adapter ) def get_timestamp do @adapter . get_timestamp () end end

The default implementation contains code that we already saw:

1 2 3 4 5 defmodule Multipster . CurrentTime . Real do def get_timestamp do :os . system_time ( :second ) end end

We configure it in config/confix.exs :

1 2 config :multipster , Multipster . CurrentTime , adapter: Multipster . CurrentTime . Real

The only environment where we want to use a different adapter is test :

1 2 config :multipster , Multipster . CurrentTime , adapter: Multipster . CurrentTime . Mock

In order to implement our mock we have to write code that can keep state and allow this state to be manipulated from tests. I decided to use Agent for that, because it seemed to be a recommended pattern.

The state that we want to keep in the agent is is_frozen and frozen_value . Initially we start with an unfrozen time and we allow to change this state from tests by providing freeze/0 , freeze/1 , and unfreeze/0 functions. Of course we also have to implement the required interface – get_timestamp/0 . Here we go:

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 defmodule Multipster . CurrentTime . Mock do use Agent def start_link do Agent . start_link ( fn -> % { is_frozen: false , frozen_value: nil } end , name: __MODULE__ ) end def get_timestamp do state = Agent . get ( __MODULE__ , fn state -> state end ) if state [ :is_frozen ] do state [ :frozen_value ] else :os . system_time ( :second ) end end def freeze do freeze ( :os . system_time ( :second )) end def freeze ( timestamp ) do Agent . update ( __MODULE__ , fn _state -> % { is_frozen: true , frozen_value: timestamp } end ) end def unfreeze do Agent . update ( __MODULE__ , fn _state -> % { is_frozen: false , frozen_value: nil } end ) end end

I was excited when writing this code as it was my first experience with message passing in Elixir!

We need one more thing to get the agent to work – we have to start it. We will do it in test/test_helper.exs :

1 { :ok , _ } = Multipster . CurrentTime . Mock . start_link ()

And now we can travel in time:

1 2 3 4 5 6 7 8 9 10 11 12 defmodule Multipster . TokenTest do use ExUnit . Case , async: true alias Multipster . Token test "return error when token expired" do token = Token . encode ( % Multipster . User { id: 4 }) Multipster . CurrentTime . Mock . freeze ( :os . system_time ( :second ) + 31 * 60 ) { :error , _ } = Token . decode ( token ) end end

It works, yay!

Are we done yet? Not really, there are a few things to improve here.

They say that there’s no global state in Elixir, but here we have a similar problem – an agent holding state that isn’t magically reset between tests. All tests executed after the above one will use the frozen time. We don’t want that so we have to explicitly unfreeze the time.

Initially I went with:

1 2 3 4 5 6 7 8 9 test "return error when token expired" do token = Token . encode ( % Multipster . User { id: 4 }) Multipster . CurrentTime . Mock . freeze ( :os . system_time ( :second ) + 31 * 60 ) { :error , _ } = Token . decode ( token ) Multipster . CurrentTime . Mock . unfreeze () end

The problem is that if {:error, _} = Token.decode(token) doesn’t match we will never execute Multipster.CurrentTime.Mock.unfreeze() .

We need something like an after hook from RSpec. ExUnit.Callbacks.on_exit/2 should do the job:

1 2 3 4 5 6 7 8 test "return error when token expired" do token = Token . encode ( % Multipster . User { id: 4 }) Multipster . CurrentTime . Mock . freeze ( :os . system_time ( :second ) + 31 * 60 ) on_exit & Multipster . CurrentTime . Mock . unfreeze / 0 { :error , _ } = Token . decode ( token ) end

This also helps us maintain a logical order in the test – assertion at the end.

As we have only one agent in the whole application, concurrently running multiple tests that mess with the time is not safe. Two tests may attempt to change the current time at the very same moment. This can lead to random, hard-to-reproduce test failures. We have to specify:

1 use ExUnit . Case , async: false

I’d really appreciate input on how to improve my code so that tests can be safely run concurrently.

That said, this method allowed me to mock the time in my Elixir application, and I had a lot of fn while writing the code. I hope that you benefited from the blog post as well.