I'm fairly new to elixir and functional programming in general and I'm struggling to properly unit test functions that are composed of other functions. The general question is: when I have a function f that uses other functions g, h... internally, which approach should I take to test the whole?
Coming from the OOP world the first approach that comes to mind involves injecting the functions f depends of. I could unit test g, h... and inject all of those as arguments to f. Then, unit tests for f would just make sure it calls the injected functions as expected. This feels like overfitting though, and as an overall cumbersome approach that is against the functional mindset for which function composition should be a cheap thing to do and you should not be concerning yourself on passing all those arguments around the whole codebase.
I can also unit test g, h... as well as f by treating each of those as black boxes, which feels like the appropriate thing to do, but then the complexity of f's tests increases dramatically. Having simple tests that scale is one of the main purposes of unit testing.
To make the argument more concrete I'll put an example of a function that composes other functions inside and that I don't know how to unit test properly. This in particular is code for a plug that handles the creation of a resource in a RESTful fashion. Note that some of the "dependencies" are pure functions (such as validate_account_admin) but others are not (Providers.create):
def call(conn, _opts) do
account_uuid = conn.assigns.current_user.account["uuid"]
with {:ok, conn} <- Http.Authorization.validate_account_admin(conn),
{:ok, form_data} <- Http.coerce_form_data(conn, FormData),
{:ok, provider} <- Providers.create(FormData.to_provider(form_data), account_uuid: account_uuid) do
Http.respond_create(conn, Http.provider_path(provider))
else
{:error, reason, messages} -> Http.handle_error(conn, reason, messages)
end
end
Thanks!
Maybe this will be quite subjective answer, because there might be no perfect and ultimate one for such question.
Your assumption for me is wrong in terms of using public functions inside other public function. You shouldn't do that at all in business logic areas, because they should be separated and the only place where you can do that and - in fact - you has to is in controllers, but you test controllers with integration tests, not with unit tests, so all you care in such tests are proper and valid responses.
I like Erlang's explicit approach to declare which functions should be public by using export clause. In Elixir you should also follow this approach and whatever should be hidden in the module, should be declared with defp and defmacrop respectively for private functions and private macros.
Your unit tests should follow the rule of black box - you care about the output based on the input. That's all. Test is dumb and doesn't know at all how function under test looks like and what it contains.
In your example you're using some functions in the Plug callfunction and I'm pretty sure that this plug makes more than it should - remember about single responsible principle. This makes this one function almost impossible to test without mocking... I would rewrite this plug into 3 or 4 four separated plugs, because with clause is redundant - plugs check the outcom of previous plug to proceed - it's case inside case, just what with does.
Considering you have new plugs you can use some extra functions inside the plug except call and init that do the real work defined as private functions and this action would propably help you organize your code and avoid creating chained modules in terms of usage and responsibility.
Then, unit tests would be much easier, because you would test isolated plugs.
Assuming that you have this plug called like this:
plug MyPlug
you would rewrite into:
plug :validate_is_admin
plug :coerce_form_data
plug :create_from_form_data
Maybe it's simplified, but I hope you get what I meant here.
TL; DR: Split functions into smaller ones and test them in isolation. Hide internal computations in private functions and test only public API.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With