How to test a periodic GenServer?

I am working on a project which includes a kind of GenServer that performs a task every N seconds.
This is built using the well-known pattern of the GenServer sending itself a message using Process.send_after(self(), :perform_work):

defmodule MyPeriodicGenServer do
  use GenServer
  defstruct [:interval_sec, :timer_ref] # <- add other state-related fields here

  def start_link(state = %__MODULE__{}) do
    GenServer.start_link(__MODULE__, state)
  end

  def init(state) do
      state
      |> schedule_work()
      |> then(&{:ok, &1})
  end

  def handle_info(:perform_work, state) do
    state
    |> schedule_work()
    |> perform_work()
    |> then(&{:noreply, &1})
  end

  defp schedule_work(state) do
    # We keep track of the timer_ref to potentially cancel or change the interval. (Not shown in this example code)
    timer_ref = Process.send_after(self(), :perform_work, :timer.seconds(state.interval_sec))
    %{state | timer_ref: timer_ref}
  end

  defp perform_work(state) do
    # ... perform some important work that potentially includes side-effects
    # like working with a DB or calling a remote API
    # here
    state
  end
end

However, how to test such a thing?

One idea which comes to mind is to regard the GenServer as a black box, and only check whether (the side effects done by) perform_work happen the expected amount of times.
But this would at the very least require changing interval_sec to, say, interval_millisec, so that we can run the GenServer’s logic faster during testing. But then still the test would be relatively slow and somewhat timing-sensitive.

Is there a better way?

1 Like

Make it compile-time constant, with the test environment much faster. As long as you’re within the test timeout limit you should be ok…

But I would say just test that it can happen more than once and call it a day.

PS the :timer.send_interval pattern is much much better than the repeated send pattern because it won’t have drift over time and the drift won’t vary based on how many other things the VM is doing.

3 Likes

Another approach is to treat it as more of a white box. Do a send(pid, :perform_work) and then verify the side effects. You could even directly call handle_info with a dummy state on the callback module.

3 Likes

Use :erlang.trace/3 to send message back to the test process, then you can test with assert_receive/2

1 Like

I would be totally fine with having such a test if:

  • You can make it async: true,
  • You can make it relatively fast (the delay being in the order of milliseconds),
  • The test passes/fails consistently,
  • Those kinds of tests constitute a small percentage of the whole test suite.

I’m OK with having some of the tests slower if they can run concurrently.

2 Likes

Thank you all for the insightful replies!

While it prevents drift, usage of functions in the :timer module is known to be a potential bottleneck if many timers need to be maintained at the same time, as the timing logic is managed by a single process. See the Timer section in the Erlang Efficiency guide. (Side note: This will improve somewhat in the next version of OTP because of this PR.)
For this particular project, the potential drift is an OK trade-off; we actually want a process where perform_work takes longer than its interval to slow down, rather than fill its mailbox faster than it can work.
For other projects this will of course be different :blush:

I’ll probably do a combination of what @gregvaughn suggested:

As well as @stefanchrobot’s advice that as long as only a small percentage of the suite consists of timing-sensitive tests, it should be fine.

:green_heart:

1 Like