DEV Community

Cover image for Perfect Elixir: Automating Tests in Elixir Projects
Jon Lauridsen
Jon Lauridsen

Posted on • Edited on

Perfect Elixir: Automating Tests in Elixir Projects

How do we write fast, reliable tests in Elixir? How do we keep modules decoupled and easy to test? Testing is a broad topic with many opinions, and today we’ll explore techniques that will let us have informed discussions about how we want to test. Because good tests aren't just about passing — they’re about helping us move fast, and deploy with confidence.

In this guide, we’ll explore practical strategies for injecting dependencies and using test doubles in ways that keep our code modular, our tests concurrent, and our team productive.

Table of Contents

 

Why Test?

Automated testing is foundational for continuous delivery and high-performing teams. The question isn’t should we test — it’s how we test in ways that keep code simple, decoupled, and easy to evolve.

The DevOps Research & Assessment (DORA) research shows the same thing again and again: teams with fast feedback loops perform better. Testing well isn’t a nice-to-have — it’s a key capability.

ℹ️ BTW for more on DORA I've written about "Accelerate", the scientific analysis of software delivery.

A core challenge in test automation lies in managing dependencies between modules. Excessive coupling complicates testing and leads to brittle, intertwined systems. Decoupled modules allow for simpler and clearer systems, and often test doubles are used to isolate dependencies.

This topic can be contentious because some developers use test doubles sparingly (only at external interfaces like APIs or databases), while others inject them frequently, even between internal systems. Rather than taking sides, we'll focus purely on how we can inject test doubles, as understanding these techniques lets teams make informed decisions about testing strategies.

ℹ️ BTW if you’re new to terms like "test double", Martin Fowler's Test Doubles and Mocks Aren't Stubs are excellent starting points.

But enough talk, let's get practical about this.

 

Getting Started with ExUnit

ExUnit is Elixir's built-in testing framework. It's simple and fast:

defmodule Math do
  def multiply(a, b), do: a * b
end
defmodule MathTest do
  use ExUnit.Case
  test "multiplying two numbers returns their product" do
    assert Math.multiply(2, 3) == 6
  end
end
Enter fullscreen mode Exit fullscreen mode
$ mix test
…
1 test, 0 failures
Enter fullscreen mode Exit fullscreen mode

ExUnit is also well-documented and works great out of the box — so let’s skip the basics and talk about the real challenges: how we develop simple, decoupled code and tests.

 

Dependency Injection Techniques

Dependency injection helps reduce tightly coupled code. Consider:

defmodule Warehouse do  
  def empty?(%Warehouse{inventories: inventories}) do  
    inventories  
    |> Enum.flat_map(&Inventory.all_products/1)  
    |> Enum.empty?()  
  end  
end
Enter fullscreen mode Exit fullscreen mode

(assume both Warehouse and Inventory are complex domains in their own rights, backed by database- and API-calls)

Here, Warehouse directly depends on Inventory, which creates a tight coupling that complicates testing.

To isolate Warehouse tests effectively we should look at how Elixir lets us inject test doubles — and where each approach shines (or stumbles).

 

Mock (Global Overriding)

Elixir allows for sophisticated runtime module swapping, and that's what the Mock library (built on Erlang’s :meck) enables.

With Mock, the Warehouse tests directly mock the Inventory module:

defmodule WarehouseTest do
  use ExUnit.Case, async: false
  import Mock

  test "warehouse is empty when its inventories have no products" do
    with_mock Inventory, all_products: fn _ -> [] end do
      warehouse = %Warehouse{inventories: [%Inventory{id: 1}]}
      assert Warehouse.empty?(warehouse) == true
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Here Inventory.all_products/1 is mocked to always return an empty list, and so the warehouse is always empty regardless of how the Inventory domain actually queries and counts its products.

That Mock can just "reach in" and simply swap out a dependency at runtime is very powerful. But it also comes with drawbacks:

  • 🛑 It's Global: Tests cannot run concurrently because the module replacement affects the global module namespace. That's bad for performance, and it's a detail that's easy to forget and then the symptoms will be hard-to-debug issues where tests will fail seemingly at random.
  • 🛑 It's "Magic": Runtime code doesn't show that dependencies are being swapped, which can cause developer confusion. This is a debatable point that depends on the team, but often more explicit code is preferable.

 

Application Config Injection 🛑

This approach changes the runtime code to read which module to use from Elixir's configuration system (aka Application environment):

git-nice-diff -U1 HEAD~
/lib/warehouse.ex
L#5:
     inventories
-    |> Enum.map(&Inventory.all_products/1)
+    |> Enum.map(&inventory_impl().all_products/1)
     |> List.flatten()
L#9:
   end
+  defp inventory_impl(), do: Application.get_env(:myapp, :inventory, Inventory)
 end
Enter fullscreen mode Exit fullscreen mode

And tests can now override the module to use via put_env/4:

defmodule InventoryStub do
  def all_products(_), do: []
end

defmodule WarehouseTest do
  use ExUnit.Case, async: false

  test "warehouse is empty when its inventories have no products" do
    Application.put_env(:myapp, :inventory, InventoryStub)
    warehouse = %Warehouse{inventories: [%Inventory{id: 1}]}
    assert Warehouse.empty?(warehouse) == true
  end
end
Enter fullscreen mode Exit fullscreen mode

ℹ️ BTW you might have better ideas for creating a stub than this InventoryStub module, but for now we're only focused on how stubs are injected, we'll cover smarter ways to create stubs later.

This makes the override explicit — which is good — but it comes with a difficult tradeoff:****

  • 🛑 It's (Still) Global: The configuration system is global, so tests still cannot run concurrently.
  • 🛑 Maybe a Misuse? Is it appropriate to use the application environment for dependency switching? It's primarily designed for user-defined configuration of an application's features (e.g. database URLs, feature toggles), should it also expose internal dependencies that users are never intended to adjust?

ℹ️ _BTW the Elixir documentation on library configuration advises against using the application environment for anything other than truly global settings. While that guidance is for libraries and so not a perfect match for our context, I think it reinforces the idea that it's surprising to have configuration options that aren't meant to be user-configured.

 

Scoped Injection with Mox

The widely used Mox is Elixir's most widely used mocking library, and it recommends the Config-Based injection in its examples. And it has a smart feature that scopes mocks to the calling process which makes Mox tests concurrent safe. That's a big improvement!

First step is registering the mock (but note: Mox can only create mocks from behaviour-based modules):

/test/test_helper.exs
@@ -2 +2,4 @@ ExUnit.start()
 Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, :manual)
+
+Mox.defmock(InventoryStub, for: Inventory)
+Application.put_env(:myapp, :inventory, InventoryStub)
Enter fullscreen mode Exit fullscreen mode

That makes InventoryStub globally available, and Warehouse tests can now configure it:

defmodule WarehouseTest do
  use ExUnit.Case, async: true
  import Mox

  test "warehouse is empty when its inventories have no products" do
    InventoryStub |> stub(:all_products, fn _ -> [] end)
    warehouse = %Warehouse{inventories: [%Inventory{id: 1}]}
    assert Warehouse.empty?(warehouse) == true
  end
end
Enter fullscreen mode Exit fullscreen mode

That works, and these tests run asynchronously so performance is optimal.

There's still a line being blurred by utilizing user config for internal dependency switching, but as long as a team can commit to doing things "the Mox way" it'll work fine.

  • 🛑 Mocks are globally registered: Because of the configuration system we are forced to come up with names for our inventory keys. In our example the key myapp.inventory is a small accidental complexity because it only exists to facilitate the internal injection.

So far, we’ve explored a few solid techniques. But Elixir gives us even more options — and there's still room for a more elegant solution.

ℹ️ BTW Mox's is not too opinionated on how one should inject its mocks, I think its documentation suggests Config-Based because it's easy to explain and requires no additional tools. The blog that gave rise to Mox includes a hint that one does not have to use the config system: José Valim's "Mocks and explicit contracts". It's a good read whichever way you prefer injecting dependencies.

 

Function Argument Injection

Injecting by argument is a classic technique:

git-nice-diff -U1 HEAD~
/lib/warehouse.ex
L#3:
-  def empty?(%Warehouse{inventories: inventories}) do
+  def empty?(%Warehouse{inventories: inventories}, inventory_impl) do
     inventories
-    |> Enum.map(&inventory_impl().all_products/1)
+    |> Enum.map(&inventory_impl.all_products/1)
     |> List.flatten()
Enter fullscreen mode Exit fullscreen mode

This way tests can easily pass a test double:

defmodule InventoryStub do
  def all_products(_), do: []
end

defmodule WarehouseTest do
  use ExUnit.Case, async: true

  test "warehouse is empty when its inventories have no products" do
    warehouse = %Warehouse{inventories: [%Inventory{id: 1}]}
    assert Warehouse.empty?(warehouse, InventoryStub) == true
  end
end
Enter fullscreen mode Exit fullscreen mode

Argument injection can be especially useful for modules that are "orchestration-focused", meaning their purpose is to orchestrate dependencies. But be weary of issues such as:

  • 🛑 Argument pollution: It's very problematic if functions that are already doing a thing ends up polluted by also having to specify dependencies. Callers shouldn't have to wire up internal systems unless that's the point of that module.
  • 🛑 Challenging to Scale: It can become cumbersome to keep multiple functions aligned in how they deal with dependencies.

 

Process Hierarchy Injection (via Process Tree)

Elixir’s process model gives a unique superpower: hierarchical test isolation. Using the ProcessTree library, we can inject dependencies scoped to a test’s process and all its children — without globals or polluting arguments.

ℹ️ BTW for more details about ProcessTree see JB Steadman's blog async: false is the worst. Here's how to never need it again. Great blog, and my thanks to JB for making this library available 🙏.

First, to make it simple and easy to use ProcessTree we'll quickly wrap it in our own module:

defmodule InjectorTree do
  @doc """
  Injects the specified stub such that it gets registered as an override
  in the process hierarchy.
  """
  def inject(module, stub) do
    injector_map = Process.get(:injector_tree, %{})
    Process.put(:injector_tree, Map.put(injector_map, module, stub))
    :ok
  end

  @doc """
  Provides (aka returns) the specified module such that if that module
  has been injected then that override is what gets returned.

  If the module has not been injected the specified module is returned.
  """
  def provide(module) do
    ProcessTree.get(:injector_tree, default: %{})
    |> Map.get(module, module)
  end
end
Enter fullscreen mode Exit fullscreen mode

Then we make the Warehouse runtime code "provide" an Inventory:

git-nice-diff -U1 HEAD~
/lib/warehouse.ex
L#1:
 defmodule Warehouse do
+  import InjectorTree, only: [provide: 1]
+
   defstruct [:inventories]

-  def empty?(%Warehouse{inventories: inventories}, inventory_impl) do
+  def empty?(%Warehouse{inventories: inventories}) do
     inventories
-    |> Enum.map(&inventory_impl.all_products/1)
+    |> Enum.map(&provide(Inventory).all_products/1)
     |> List.flatten()
Enter fullscreen mode Exit fullscreen mode

And tests can now "inject" a stub:

defmodule WarehouseTest do
  use ExUnit.Case, async: true
  import InjectorTree, only: [inject: 2]

  test "warehouse is empty when its inventories have no products" do
    inject(Inventory, InventoryStub)
    warehouse = %Warehouse{inventories: [%Inventory{id: 1}]}
    assert Warehouse.empty?(warehouse) == true
  end
end
Enter fullscreen mode Exit fullscreen mode

✅ This technique offers clear, explicit, and isolated dependency injection, granting us concurrent-safe tests while keeping runtime code clean and explicit. Very cool!

ℹ️ BTW a more sophisticated implementation of InjectorTree could probably compile itself out in production to avoid any theoretical overhead caused by traversing the process hierarchy. But such optimizations are beyond the scope of this article.

 

Test Double Strategies

Now that we’ve seen how to inject dependencies — let’s talk about how to build test doubles that are simple, safe, and aligned with our code.

The Elixir community offers several robust methods for defining test doubles, we'll examine them in more detail and consider their strengths and potential trade-offs.

 

Manual Fakes via Behaviour

A straightforward yet powerful approach involves explicitly creating fake implementations using Elixir behaviours. This keeps the fake and the real implementation synchronized, enforced by compile-time checks:

defmodule FakeInventory do
  @behaviour Inventory
  @impl Inventory
  def all_products(%Inventory{} = _), do: []
end
Enter fullscreen mode Exit fullscreen mode

Tests then inject the fake:

defmodule WarehouseTest do
  use ExUnit.Case, async: true
  import InjectorTree, only: [inject: 2]

  test "warehouse is empty when its inventories have no products" do
    inject(RealInventory, FakeInventory)
    warehouse = %Warehouse{inventories: [%Inventory{id: 1}]}
    assert Warehouse.empty?(warehouse) == true
  end
end
Enter fullscreen mode Exit fullscreen mode

This method is powerful because it is explicit and straightforward, and require no external dependencies.

However, the approach can be effort-intensive, as maintaining parallel implementations (real and fake) demands careful designing, and the fake can require its own tests if complexity grows significantly.

Despite the maintenance cost, this is a highly effective and explicit approach for test doubles.

 

Mox (Simplified Use) 🛑

Yes we're covering Mox again, but this we'll simplify its use by only using it to create a mock (we'll not rely on Mox's "per-process mock scoping" feature):

defmodule WarehouseTest do
  use ExUnit.Case, async: true
  import InjectorTree, only: [inject: 2]
  import Mox

  test "warehouse is empty when its inventories have no products" do
    inject(
      RealInventory,
      defmock(InventoryMock, for: Inventory)
      |> stub(:all_products, fn _ -> [] end)
    )

    warehouse = %Warehouse{inventories: [%Inventory{id: 1}]}
    assert Warehouse.empty?(warehouse) == true
  end
end
Enter fullscreen mode Exit fullscreen mode

The change may appear subtle, but this code is simpler: Mox is used to create a test double, but nothing is hidden in how that test double gets injected. It's all nicely explicit code, where all the steps of creating, injecting, and configuring the mock is expressed right in the test file.

And Mox has validation to ensure mocks align with the defined behaviour, preventing incorrect mocking:

$ git-nice-diff -U0 .
/test/warehouse_test.exs
@@ -10 +10 @@ defmodule WarehouseTest do
-      |> stub(:all_products, fn _ -> [] end)
+      |> stub(:FOO, fn -> "bar" end)

$ mix test
  1) test warehouse is empty when its inventories have no products (WarehouseTest)
     ** (ArgumentError) unknown function FOO/0 for mock InventoryMock
Enter fullscreen mode Exit fullscreen mode

Great! And the Mox documentation is exceptionally good and well worth a read.

Mox, however, does not care about typespec enforcement, but the library Hammox promises to fix that so lets give that a try next.

ℹ️ _BTW this section marks Mox with a red "do not use" sign only because Hammox turns out to be a more powerful drop-in replacement. It's not a sign of criticism, Mox is awesome!

 

Hammox (Type-Safe Mocks)

Hammox extends Mox by adding typespec validation. It's a drop-in replacement, bringing greater safety and validation:

$ git-nice-diff -U1 .
/test/warehouse_test.exs
L#3:
   import InjectorTree, only: [inject: 2]
-  import Mox
+  import Hammox

$ mix test
…
7 tests, 0 failures
Enter fullscreen mode Exit fullscreen mode

Hammox ensures return values conform to typespecs, e.g. here the mock returns an invalid number-list:

$ git-nice-diff -U0 .
/test/warehouse_test.exs
@@ -10 +10 @@ defmodule WarehouseTest do
-      |> stub(:all_products, fn _ -> [] end)
+      |> stub(:all_products, fn _ -> [1] end)

$ mix test
  1) test warehouse is empty when its inventories have no products (WarehouseTest)
     test/warehouse_test.exs:6
     ** (Hammox.TypeMatchError) 
     Returned value [1] does not match type [String.t()].
7 tests, 1 failure
Enter fullscreen mode Exit fullscreen mode

That's very cool! This added safety makes Hammox particularly recommended for behaviour-based mocking.

 

Double (Ad-Hoc Mocking)

Double addresses situations where behaviours feel like unnecessary overhead. It specifically allows ad-hoc mocking without behaviours, offering quick and flexible mocks.

$ git-nice-diff -U1 .
/test/test_helper.exs
L#1:
 ExUnit.start()
+Application.ensure_all_started(:double)
 Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, :manual)
Enter fullscreen mode Exit fullscreen mode

ℹ️ BTW a lesser known side-effect of behaviours is that implementations do not auto-complete in IEx. That's by design. It's a small extra friction that Double avoids by allowing ad-hoc mocking.

The test can now stub Inventory, even when it's just a plain old Elixir module (no behaviours, etc.):

defmodule WarehouseTest do
  use ExUnit.Case, async: true
  import InjectorTree, only: [inject: 2]
  import Double

  test "warehouse is empty when its inventories have no products" do
    inject(Inventory, Inventory |> stub(:all_products, fn _ -> [] end))

    warehouse = %Warehouse{inventories: [%Inventory{id: 1}]}
    assert Warehouse.empty?(warehouse) == true
  end
end
Enter fullscreen mode Exit fullscreen mode

That's very elegant. And Double also provides correctness-validation, e.g. we can't stub a function that doesn't exist on the target module:

$ git-nice-diff -U0 .
/test/warehouse_test.exs
@@ -7 +7 @@ defmodule WarehouseTest do
-    inject(Inventory, Inventory |> stub(:all_products, fn _ -> [] end))
+    inject(Inventory, Inventory |> stub(:FOO, fn -> "BAR" end))

$ mix test
  1) test warehouse is empty when its inventories have no products (WarehouseTest)
     test/warehouse_test.exs:6
     ** (VerifyingDoubleError) The function 'FOO/0' is not defined in :InventoryDouble326
7 tests, 1 failure
Enter fullscreen mode Exit fullscreen mode

However, Double doesn't verify stubbed return values against typespecs. If pragmatic flexibility and simplicity matter more to your team than strict typespec enforcement, Double is an excellent choice.

 

Conclusion

We’ve covered a lot — but the takeaway is simple:

✅ Fast tests
✅ Clear dependencies
✅ Concurrent, isolated setups

Use Manual Fakes for clarity, Hammox for strictness, or Double for pragmatic speed. And consider Process Hierarchy Injection for clean, concurrent-safe test wiring.

Ultimately there’s no single best way to test — the focus should be on what lets your team move fast and ship with confidence. Whatever combo you choose — stay explicit, keep things simple, and test fearlessly 💪.

ℹ️ BTW I’ve seen complex “Inversion of Control” frameworks that turned code into spaghetti. Be cautious of cleverness — simplicity scales best.

Top comments (0)