Help To Test Your Library
I like to keep applications as tiny as possible. Everything that is not related to business logic, might and should be extracted into packages. Packages are great, reusable and better testable; once created and tested (and hopefully benchmarked,) it does not require the application to bother about its internals. It just works™. hex.pm provides private package functionality for those hesitating to open source their general-purpose libraries.
Despite the proven robustness of external libraries, we would probably still want to test their integration though. Any library might be great on its own, but how would it fit our application quirks?
Well, greatest libraries are welcoming for integration testing. Ecto provides a sandbox for testing. Broadway comes with a DummyProducer.
Providing a sandbox might be cumbersome and tricky in general, while DummyWorker
is a must in any library shipped with love and care. Apparently, it’s both extremely useful for users of your library and easy to implement. Follow Broadway’s path, send a message to the calling process instead of performing real job. That simple.
Imagine we have a library that posts a slack message into some channel within your company workspace. Our application has some actions which should end up with a message in this slack channel. The integration test should then test that a call to perform_this_action
has some side effects actually performing a real job and notifies the slack channel afterward. We use homebrewed SlackPoster
library, which is thoroughly tested and is proven to work well with posting messages. But how would we go about integration test? Check the slack channel ensuring the message had indeed arrived? Nah.
This imaginary SlackPoster
library is probably already having a behaviour defining the callback similar to that
@callback post(message :: map()) :: :ok | {:error, reason} when reason: any()
If the library author was kind enough, they should have provided a real implementation
defmodule SlackPoster do
@behaviout Poster
@impl Poster
def post(%{} = message),
do: message |> Jason.encode!() |> Engine.send_message()
…
alongside with a dummy implementation for better testing
defmodule DummyPoster do
@behaviout Poster
@impl Poster
def post(%{} = message) do
{callback_pid, message} =
Map.pop(message, :callback, @default_value)
send(callback_pid, {:slack_sent, message})
end
And now one can test the integration easily with ExUnit.Assertions.assert_receive/3
test "slack integration" do
# wait how would I supply a pid of process here?!
assert_receive {:slack_sent, ^expected_message}
end
Yes, there is still a glitch. When it’ll come to DummyPoster.post/1
, it would require a pid
to provide callback, and there is no way to supply it transparently without bringing test logic into application core, which is meh. Is it a deadend?—Not at all.
Your library should provide a message proxy process. It is to be started within a test suite (e. g. from setup/1
callback,) to route messages to the test process. Then we might do the following.
defmodule DummyPoster do
@behaviout Poster
@impl Poster
def post(%{} = message) do
{callback_pid, message} =
Map.pop(message, :callback, Application.get_env(:my_app, :dummy_proxy))
Process.send(callback_pid, {:slack_sent, message}, [])
end
and in our test we would need to register ourselves right before assert_receive/1
test "slack integration" do
expected_message = ...
proxy = Application.get_env(:my_app, :dummy_proxy)
proxy.register(expected_message, self())
assert_receive {:slack_sent, ^expected_message}
proxy.unregister(expected_message, self())
end
The draft implementation of proxy process is trivial.
Another way would be to use PubSub
mechanism for routing messages, but this is slightly more cumbersome. Envío
library aims to cover a boilerplate for pubsub internals, allowing simple drop-in implementations for publishers and subscribers. One day I’d write about it in details too.
Happy making your customers happy testing!