herman

herman

Best practices for testing behaviour implementations concurrently

I have seen some good examples for best practices in sharing test suites:
Test a module for a given behaviour
Test a behaviour that uses super
How to test a behaviour
reusing a test suite

I am still curious on what’s the best practice for swapping behaviour implementations through config while allowing tests to run concurrently. Primarily from a library perspective. I have managed to come right using Mox (option 2 below), but I can’t shake the feeling that there are better ways to do it. I would love thoughts and input.

Example

Let’s say I have an adapter module

defmodule MyApp.Adapter do

  @callback action_1() :: :ok | :error
  @callback action_2() :: :ok | :error
  @callback action_3() :: :ok | :error

  def action_1, do: impl.action_1

  def action_2, do: impl.action_2

  def action_3, do: impl.action_3

  def impl, do: Application.get_env(:my_app, :adapter, AdapterImpl)
end

for completeness it can be used by something like this

defmodule MyApp.AdapterCaller do
alias MyApp.Adapter

  def call_adapter("action_1"), do: Adapter.action_1
  def call_adapter("action_2"), do: Adapter.action_2
  def call_adapter("action_3"), do: Adapter.action_3
end

Lets say 3 adapters implement the behaviour namely AdapterOne, AdapterTwo, AdapterThree

I have referenced quite a few code bases and gone through ExUnit and Mox docs, but haven’t felt “Yeah this is the way to lock in”.

It is pretty simple to setup generic tests that can be used for all implementations (especially following the above links), but using the application config means that I can’t run them in parallel for no reason other than setup and I want that concurrency.

In trying to figure out which way I want to go, I have explored the following ways to manage it:

1. Set the config during setup

setup do
Application.put_env(:my_app, :adapter, AdapterOne)
end

In terms of being able to test, this works fine, but because of the shared state of the config, I can’t run them in parallel because it would swap adapters mid test if I’m unlucky

2. Use Mox and create a Mock Adapter, Set the Env and Mock returns.

in test_helper.exs

Mox.defmock(MockAdapter, for: MyApp.Adapter)
Application.put_env(:my_app, :adapter, MockAdapter)

and in the test

Mox.expect(MockAdapter, :action_1, fn -> AdapterOne.action_1 end)

This is my preferred approach because of the parameter checking and other assertions I get for free, but I’m not married to it.
It solves the shared configuration issue, and it looks like I can avoid conflicts with its allowances and some setup stuff I will play with after I posted that.
Is this generally what is recommended?

3. Put Env in TestCases

I avoid this because it’s basically the same as 1. but catches you when you least expect it

4. Change code to allow dependency injection.

If I change my adapter implementation to

defmodule MyApp.AdapterCaller do
alias MyApp.Adapter

  def call_adapter("action_1", adapter \\AdapterOne), do: adapter.action_1
  def call_adapter("action_2", adapter \\AdapterOne), do: adapter.action_1
  def call_adapter("action_3", adapter \\AdapterOne), do: adapter.action_1
end

It becomes simple to test concurrently because I’m passing the adapter I’m testing. There are occasions where this is my goto approach for general design and without defaults, but it’s incredibly situational.

  • If the behaviour is nested deep in your code base, you have a cascading expansion of contract changes but also any caller needs to manage an instance that should be a config.
  • Designing code to be testable is good, but changing the design of code in order to test is bad.

This approach is in my pocket but only appropriate <40% of the time.

5. Do unnecessary things with a mock instance

Definitely don’t do this, but listing as it’s a thought that crossed my mind.
if you have a mock instance created somewhere

Application.put_env(:my_app, :adapter, MockAdapter)

You can create a set of parameters and configs that ensures the correct instance is returned based on the test.
While possible this quickly becomes an unmaintanable mess and should go no further than a thought experiment.

6. Mix test setup and params.

An option I think is valid but I have not explored it properly, but I think you can setup suites and run sets of tests in isolation.
When do people usually take this approach? Is it more for performance than isolating collisions?

7. Partitions coming in 1.18

Having read through it, this seems like another way you could manage setting environments without conflict
Changelog

Would love to know thoughts or better approaches!

Marked As Solved

LostKobrakai

LostKobrakai

I’d ask if you really need to go through MyApp.Adapter to get to that behaviour difference.

But also you added the Application.get_env making the adapter selection be based on global state. You could choose a different path and base the adapter selection on something you can control per (async) integration tests, which is basically your 4. point.

Personally I feel like many discussions like these exist because there’s code in the tested codebases, which make dependency injection impossible just to then need more complexity to be able to still do flexible dependency injection for tests. I prefer removing the parts in the code preventing dependency injection. Reasonable dependency injection roots (where selections are being made) are either application start callbacks for application wide selection or endpoints for webrequest local selection. Having those selections be scattered throughout a codebase is not a great idea in my mind.

Sometimes you have external dependencies (as in cannot be run locally, not just being external to elixir) where you then need some mocking to be able to flexibly not just select a module, but also how it acts. Those are where Mox and similar tools imo become actually useful, rather than also using it for everything remotely related to dependency injection.

Also Liked

LostKobrakai

LostKobrakai

Why test through MyApp.Adapter in the first place? Test AdapterOne, AdapterTwo, AdapterThree concurrently through a shared set of testcases. No need to deal with the complexity of global state to begin with.

dimitarvp

dimitarvp

To me protocols are mostly a persona non-grata simply because they are a bit… implicit. You have a variable, you call a function on it and you are not sure what gets called.

I realize it’s a flimsy and slippery slope but to me having the ability to dispatch explicitly somewhere (including in config) is a touch more valuable.

I don’t feel strongly about it, mind you, it was a choice made almost at the beginning of my Elixir journey so it’s likely not a very up-to-date opinion either.

dimitarvp

dimitarvp

IMO your point 4 is the best you can do. As @LostKobrakai said, make sure not to “infect” too many modules / functions in your app with the ability to supply an implementation though – that dependency-injection surface should be minimal, and it does not make sense to allow it in too many places anyway (f.ex. why would you be able to use the real implementation of a 3rd party API in one context / business-domain function but then immediately use a mock downstream of that function, right?).

krasenyp

krasenyp

Since you settled on DI, there’s an approach which, for some reason, is not the blessed way but in my opinion is very good.

You define a protocol with your top-level functions. Then, you implement the protocol for a struct which holds the dependencies. The struct data defaults to environment configuration. They can be mocked, real, faked, etc. The struct can be created via a new function, and its data can be overridden by the function’s arguments.with

The advantage is that you can have multiple implementers of your business logic protocol. You gain flexibility but also unfamiliarity with the wider community.

Where Next?

Popular in Questions Top

Brian
What is the proper way to load a module from a file in to IEX? In the python world, doing something like this pretty standard: from ....
New
shahryarjb
Hello, I get Persian date from my client and convert it to normal calendar like this: def jalali_string_to_miladi_english_number(persi...
New
yawaramin
In the Dialyzer docs ( http://erlang.org/doc/man/dialyzer.html#requesting-or-suppressing-warnings-in-source-files ), there is a way to tu...
New
mcarvalho
What is the difference between System.get_env and Application.get_env? For example, what are best practices to use one versus another.
New
mgjohns61585
Could someone help me? I'm making my first elixir program, number guessing game. I can't figure out how to convert the user's guess from ...
New
makeitrein
Hey all, just started picking up Elixir last week and am writing a scraper as a learning project. Baby step #1 is extracting the number ...
New
ashish173
I am using Ecto timestamps with postgres, I can see the timestamps() use the :naive_dateime but for my use case I wanted to store the ti...
New
electic
Hi, I am new to Elixir. I am trying to use the DateTime component to insert a date into MySQL however the there seems to be no way to fo...
New
skosch
To my knowledge, put_in, Map.update etc. all have the one limitation of not automatically creating intermediate keys when needed (for exa...
New
vrod
I am using the Starship cross-shell prompt – it seems pretty nice, but I get some errors: [WARN] - (starship::utils): Executing command ...
New

Other popular topics Top

Brian
What is the proper way to load a module from a file in to IEX? In the python world, doing something like this pretty standard: from ....
New
peerreynders
Manning 2016 Halloween weekend sale via Deal of the Day Friday, October 28 - Half off all MEAPs - code WM102816LT Saturday, October 29 ...
326 29600 154
New
dotdotdotPaul
Okay, I'm having a heck of a time trying to figure out how to best handle the validation of belongs_to associations in Ecto. I'm sure I'...
New
yawaramin
In the Dialyzer docs ( http://erlang.org/doc/man/dialyzer.html#requesting-or-suppressing-warnings-in-source-files ), there is a way to tu...
New
ycv005
I have followed this StackOverflow post to install the specific version of Erlang. And When I am running mix ecto.setup then getting fol...
New
SoCreat
i’m a new one to elixir which editor can i use vs code? or atom? Thanks! :smiley:
New
jononomo
I am trying to figure out how Mix knows whether the environment is test, dev, or prod -- where is this set? Thanks.
New
msaraiva
Surface is an experimental library built on top of Phoenix LiveView and its new LiveComponent API that aims to provide a more declarative...
564 42633 214
New
AstonJ
by Lance Halvorsen Elixir and Phoenix are generating tremendous excitement as an unbeatable platform for building modern web application...
460 27162 124
New
siddhant3030
Hi, I have to write a raw query for one of my project. But till now I have used ecto queries and don’t have much experience writing raw ...
New

We're in Beta

About us Mission Statement