Compose your Elixir test setup

2023-05-21

Alan Vardy
 elixir  tests

Test Tubes From Unsplash

Test setup should be damp, not wet

When writing acceptance tests (as opposed to unit tests), we often need to set up the “state” of the application by doing things like inserted database records, putting values in caches, and mocking API calls. This code can be laborious to write, and we soon find ourselves copying and pasting test cases in order to use the same setup again and again. This results in rather WET code (We Enjoy Typing). In order to DRY (Don’t Repeat Yourself) out the code a little we can abstract out the setup into imported functions that are easy to read and reason about.

Tests should help you make changes, not hold you back

Abstracting out test setup is a big benefit when writing out new test cases, but the even larger benefit is the ease in making changes to a large test suite. When the setup for inserting a new user is in one place, and is used by 1000 test cases, a change to the user schema that breaks all 1000 tests can be resolved by fixing that one test setup.

An example

For this case we are writing a test that requires a post, and the post requires a user to be associated with it. So we can compose two functions together like so.

defmodule MyApp.PostTest do
  setup [:user, :post]

  test "can add a comment", %{user: user, post: post} do
    assert :ok = Post.add_comment(user, post)
  end

  # Setup functions take a map of the context
  # And the map they return is merged into that context
  defp user(_) do
    %{user: Factory.insert_user()}
  end

  defp post(%{user: user}) do
    post = Factory.insert_post(%{user_id: user.id})
  end
end

This removes the implementation details from the test case, but doesn’t hide it from the reader. Another test case can add or remove components as well.

defmodule MyApp.PostTest do

...

describe "subscribers" do
  setup [:user, :post, :subscriber, :comment]

  test "subscriber can view comments" do
    ...    
  end

  ...
end

We will soon find that we need to share common test setup across multiple test files, and for this common test setup modules are useful.

defmodule MyApp.Support.TestSetup do
  @doc "Insert a new user"
  def user(_) do
    %{user: Factory.insert_user()}
  end

  @doc "Insert a new post"
  def post(%{user: user}) do
    post = Factory.insert_post(%{user_id: user.id})
  end
end

When adding a support folder to your test directory for the first time you may need to add it to the compiler paths in your mix.exs file.

defmodule MyApp.MixProject do
  use Mix.Project

  def project do
    [
      ...
      
      elixirc_paths: elixirc_paths(Mix.env()),

      ...
    ]
  end

  # Specifies which paths to compile per environment.
  defp elixirc_paths(:test), do: ["lib", "test/support"]
  defp elixirc_paths(_), do: ["lib"]
end

And then the functions can then be explicitly imported into the test file. Some would use macros to bring in these test setup functions much like how Phoenix does, but the issue with that is that while many Elixir developers are familiar with Phoenix and can handle that implicit overhead it is not great to force other developers to hunt for additional hidden imports.

defmodule MyApp.PostTest do
  import MyApp.Support.TestSetup, only [user: 1, post: 1]

  setup [:user, :post]

  test "can add a comment", %{user: user, post: post} do
    assert :ok = Post.add_comment(user, post)
  end
end

Stack them like Lego

By keeping these setup functions small, composable and explicit like little toy blocks it will be much easier to write new tests and maintain old ones as codebases grow and change.

Like what you see?

Related Posts