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.
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.
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.
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.
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.
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.
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
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ā.
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.
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.