Test for async Task inside a GenServer

Hi,

We have a GenServer which calls different APIs with Task.Supervisor.async_nolink. The result is sent to def handle_info({ref, answer}, state) where we process it and update the state.

Example:

  def handle_info(:load_company_id, state) do
    Task.Supervisor.async_nolink(MyApp.TaskSupervisor, fn ->
      case api().get_company_id(state.id) do
        {:ok, company_id} ->
          {:loaded_company_id, company_id}

        {:error, reason} ->
          {:error, {:load_company_id, reason}}
      end
    end)

    {:noreply, state}
  end

  def handle_info({ref, {:loaded_company_id, company_id}}, state) do
    Process.demonitor(ref, [:flush])
    new_state = state
    {:noreply, new_state}
  end

What we wondering now - how to test the GenServer properly. For example a test could be to send the :load_company_id message, and the verify somehow that :loaded_company_id was received back and state was updated.

But how to do it? Or maybe there are other tricks what to do about it?

Usually Iā€™d only use Task.{Supervisor}.async* functions for functions where you expect to explicitly call an await in the same lexical scope. Why not just run Task.Supervisor.start_child and have it call() back into the genserver when yourā€™e done?

def handle_* do
  this = self()
  Task.Supervisor.start_child(Supervisor, fn ->
    #business logic
    __MODULE__.report_result(this, result)
   end)
end

where report_result does a GenServer.call()

Also, this is a matter of maybe nitpicking, but generally you shouldnā€™t use handle_info in your private API, itā€™s mostly for receiving a system-supplied message (nodeup, nodedown, processdown, tcp, udp, ssh, etc). Did you mean to use handle_continue/2?

For example a test could be to send the :load_company_id message, and the verify somehow that :loaded_company_id was received back and state was updated.

that seems about right. Why wouldnā€™t it work? If youā€™re worried about stuff like ecto not working, if you use Task.Supervisor.start_child, elixir will do the right thing and automagically associate the task process with your test process so that it can see your ecto sandbox.

1 Like

But even if we do __MODULE__.report_result(this, result), how and what do we verify that in a test?

Only way I see now is to verify the state, but it would need some Process.sleep in the test, since it is async.

Only way I see now is to verify the state, but it would need some Process.sleep in the test, since it is async.

sounds good to meā€¦ Probably I would put in a Process.sleep and then leave a comment that if CI gets too slow (Iā€™m looking at you, travis) or if the test becomes flaky, to do a polling operation in a loop.

What, for real? If I have this in the setup start_supervised!({MyGenServer, params}) then if I call assert_receive in the test, it is actually checking the mailbox of the genserver and not the test process? Didnā€™t know that.

no, it doesnā€™t do that. Sorry ā€“ I didnā€™t phrase it clearly. Ecto sandboxes are associated with your test pid via a ā€˜lookup tableā€™ (iā€™m putting that in quotes, itā€™s actually $callers, I think, if you curious about it then check the elixir 1.9 release notes), and when you spawn your task, the task gets associated with the Ecto sandbox too.

Same goes for Mox.

1 Like

Ah, ok then. Ecto is irrelevant for us, so thatā€™s OK. Thanks for explaining though, I will look into it.

So sleep is the way to go then? Sounds a bit dirty to me. It also only lets me assert the end state. What if I wanted to test the order of received messages? How would you go about it?

One way would be to implement the async fetching logic as separate module and make it an ā€œinjectableā€ dependency in your start_link. That way you can test it independently, and in the tests for your GenServer you can provide a mock synchronous implementation.

3 Likes

Btw, I had start_child before, but then read this in the docs

Note that the spawned process is not linked to the caller, but only to the supervisor. This command is useful in case the task needs to perform side-effects (like I/O) and does not need to report back to the caller.

So I decided to go for async_nolink.

Was it a bad move?

If you do something with the result of your asynchronous call (e.g. if you use the company_id reported by your task upon completion), and you donā€™t want to crash the caller (your GenServer) if the task crashes, then async_nolink is appropriate.

1 Like

I just want to add the company_id to the GenServerā€™s state, nothing else. To me this sounds like reporting back to the caller.

Maybe itā€™s just me, but I think thereā€™s something strange about listening in on handle_info for an Task.async callback. In my mind itā€™s breaching some sort of SRP. Or maybe that in my mind the Task.async datastructure is supposed to be opaque? (donā€™t know if it actually is) so matching on it is mildly dispreferred.

oh huh, the docs actually recommend using handle_info. TIL.

Yes, it is reporting back then. I was asking because in the code you posted, company_id is not used yet (handle_info matches it, but doesnā€™t use it).

You need to make sure to handle failures and the corresponding :DOWN messages though.

actually to get back to the original question, you could certainly just write a test that directly calls your moduleā€™s handle_info itself and waits for the async message in test, but i would consider that only a partial test, you really do want to make sure that the state in your genserver gets updated.

test "this function" do
  test_state = #
  MyModule.handle_info(:load_company_id, test_state)
  assert_receive {_ref, result}
  # asserts on result
end
3 Likes

Hey, this is brilliantly simple! Awesome.

FWIW, also consider carefully whether you really want an async call to api().get_company_id - itā€™s a common pattern in languages where blocking a thread is expensive and ties up resources, but thatā€™s less of a problem in BEAM.

The big issue with the async call is that then every other handler in your GenServer has to deal with what should happen if loaded_company_id hasnā€™t arrived yet. Sometimes the most logical state for an individual GenServer is ā€œblocked on this external callā€.

1 Like

Would you mind elaborating on this? Iā€™m currently using an async call in a GenServer to access an external resource and would appreciate the insight.

Letā€™s say you have an async call flow like this in a GenServer:

  • make a request to the external resource and return
  • GenServer waits in its receive loop
  • receive the result eventually

If a message requiring the data from the external resource comes in while the request is in-flight, how should the GenServer reply? You could push the messages into some kind of queue, but now youā€™re basically re-implementing the process mailbox.

As an alternative, the same flow but synchronized doesnā€™t have the same problem:

  • make a request to the external resource and BLOCK
  • incoming messages to the GenServer stay in the mailbox
  • when the result arrives, the rest of the messages can be handled

In this situation, other processes cannot interact with the GenServer in a ā€œloading but not loadedā€ state.

1 Like

If you can afford to block this is the best solution. Otherwise you do not have to reinvent the mailbox. Well, kind of, but you just keep the from argument from handle_call to reply later.

Also when starting tasks from a gen server I do not like to start with async or async_nolink but rather wrap the actual task in a function that will send the result with a known tag like {:fetched_stuff_result, task_return} where task_return is an ok/error tuple. So you do not have to manage the task reference and just match on a known tag in handle_info.

1 Like