This post is part of the Ecto Series and the Testing Series.

Setting up a testing environment that abstracts the complexities of test management, database interactions, and data production enables developers to focus on the core behaviors of their application. This post guides you through the process of creating such an environment for Elixir applications using Ecto, ensuring that your tests are both effective and maintainable.

See the code

See the documentation

Add a Test Database to the Docker-Compose File

If using Ecto, set up a PostgreSQL container specifically for testing. Ensure the service avoids conflicts with other locally running environments.

game_db_test:
    image: postgres
    environment:
      POSTGRES_DB: game_db_test
      POSTGRES_USER: test_user
      POSTGRES_PASSWORD: test_password
    ports:
      - "5481:5432"
  • ports: Map the port from the default PostgreSQL 5432 to 5481 to avoid conflicts with other Postgres services running on the default port.

Note that the user and password aren’t sensitive here, as nothing important or persistent will be stored in this database.

Update the Test Config File

Align the test environment by updating its configuration to match the Docker setup. The specific values don’t matter as long as they match the test database in the Docker Compose file.

config :game_app, GameApp.Repo,
  username: "test_user",
  password: "test_password",
  hostname: "localhost",
  port: 5481,
  database: "game_db_test#{System.get_env("MIX_TEST_PARTITION")}",
  pool: Ecto.Adapters.SQL.Sandbox,
  pool_size: 10
  • pool: The Ecto.Adapters.SQL.Sandbox allows tests to run in isolation and rolls back changes after each test, ensuring deterministic behavior.
  • pool_size: Specifies the number of database connections available for testing. This can be increased based on the anticipated load or the number of concurrent tests.

Add Dependencies

Add the following dependencies to the mix.exs file:

defp deps do
    [
      {:floki, ">= 0.30.0", only: :test},
      {:bypass, "~> 2.1", only: [:dev, :test]},
      {:faker, "~> 0.17", only: [:dev, :test]},
      {:ex_machina, "~> 2.7", only: :test},
      {:mox, "~> 1.1.0", only: :test}
    ]
  end
  • Floki: Used to parse and assert HTML output.
  • Bypass: Mocks external HTTP requests for testing.
  • Faker: Generates fake data for tests.
  • ExMachina: Simplifies creating test data and associating records.
  • Mox: Provides mocks for Elixir interfaces (behaviours).

Update the Makefile

Update the Makefile to automate the processes of getting dependencies, setting up the database, and running migrations with simple commands:

setup.test:
 @echo "Getting dependencies for the test environment..."
 MIX_ENV=test mix deps.get
 @echo "Creating database for the test environment..."
 MIX_ENV=test mix ecto.create
 @echo "Running migrations for the test environment..."
 MIX_ENV=test mix ecto.migrate

reset.test:
 @echo "Resetting database for the test environment..."
 MIX_ENV=test mix ecto.drop
 @echo "Recreating database for the test environment..."
 MIX_ENV=test mix ecto.create
 @echo "Migrating database for the test environment..."
 MIX_ENV=test mix ecto.migrate
  • setup.test: Fetches dependencies, creates the test database, and runs migrations.
  • reset.test: Drops the test database, then recreates it and reruns migrations, ensuring a clean state for testing.

Start Faker

Add logic to start Faker in the test/test_helper.exs script:

ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(GameApp.Repo, :manual)
Faker.start()

The test_helper.exs script sets up the test suite: ExUnit.start() initializes the testing framework, Ecto.Adapters.SQL.Sandbox.mode(GameApp.Repo, :manual) configures the database to allow manual transaction control in each test, enabling precise management of transaction start and end points. Faker.start() initializes Faker, ensuring it’s ready to generate realistic dummy data.

Set up the Factory

In test/support/factory.ex:

defmodule GameApp.Factory do
  use ExMachina.Ecto, repo: GameApp.Repo

  alias GameApp.Accounts.Player

  def player_factory do
    %Player{
      name: Faker.Person.name(),
      email: Faker.Internet.email(),
      score: Faker.random_between(1, 100_000)
    }
  end
end

The GameApp.Factory module leverages ExMachina to integrate Faker with Ecto, simplifying the generation of complex test data and its insertion into the test database. The player_factory function is a basic example that creates Player structs with valid but random values for name, email, and score.

Note that the _factory suffix is a convention ExMachina uses to identify a function as a factory.

Add the Factory to the Test Environment

Add the factory to test/support/data_case.ex:

using do
  quote do
    alias GameApp.Repo

    import Ecto
    import Ecto.Changeset
    import Ecto.Query
    import GameApp.DataCase
    import GameApp.Factory
  end
end

Elixir’s quote block generates macros and inserts them into any test file that uses GameApp.DataCase. This enables test modules to directly call all functions from GameApp.Factory without needing to import them individually.

Testing the Database Logic

Set up the Tests

In test/game_app/accounts_test.exs:

defmodule GameApp.AccountsTest do
  use GameApp.DataCase

  alias GameApp.Accounts
  alias GameApp.Accounts.Player

  @moduletag :accounts
  @moduletag :player
  @moduletag :database

  setup do
    player = insert(:player)
    {:ok, player: player}
  end
end

The use GameApp.DataCase statement brings in all the necessary macros, enabling the setup and insert functions, as well as the @moduletag logic. The insert function, provided by Ecto and enhanced by ExMachina, handles inserting information into the database. When :player is passed to insert, ExMachina generates a Player struct filled with realistic data from Faker.

This setup inserts a player for every test in the suite.

The @moduletag is used to categorize tests, such as those interfacing with the database, allowing groups of tests to be run or skipped with commands like mix test --only database or mix test --exclude database.

A Basic Test

In the setup block, a player is inserted, so the list_players function can be tested to ensure it returns a list containing only that player.

describe "list_players/0" do
  test "lists all players", %{player: player} do
    assert Accounts.list_players() == [player]
  end
end

At the end of the test, the setup insertion, along with any updates, is rolled back, ensuring that tests can be run concurrently in a deterministic manner.

Testing get_player/1

The get_player/1 function is tested to return {:ok, player} or {:none} instead of just player or nil.

describe "get_player/1" do
  test "retrieves a player by id", %{player: player} do
    assert {:ok, retrieved_player} = Accounts.get_player(player.id)
    assert_player_attributes(player, retrieved_player)
  end

  test "returns :none when player does not exist" do
    assert {:none} = Accounts.get_player(-1)
  end
end

Using the function name and arity in the describe block allows for quickly locating and addressing broken logic, even though some may consider it less ideal to name describe blocks after functions.

Testing create_player/1

The following business logic is confirmed for creating a player:

  • A player has a default score of 0.
  • A player cannot have a negative score.
  • A player needs a valid email.
  • Two players cannot have the same email.
describe "create_player/1" do
  setup do
    attrs = %{
      name: Faker.Person.name(),
      email: Faker.Internet.email(),
      score: Faker.random_between(1, 100_000)
    }

    {:ok, attrs: attrs}
  end

  test "creates a new player", %{attrs: attrs} do
    assert {:ok, new_player} = Accounts.create_player(attrs)
    assert_player_attributes(attrs, new_player)
  end

  test "creates a player with default score when score is not provided", %{attrs: attrs} do
    attrs = Map.delete(attrs, :score)
    assert {:ok, new_player} = Accounts.create_player(attrs)
    default_score = Player.default_score()
    assert new_player.score == default_score
  end

  test "fails to create a player with negative score", %{attrs: attrs} do
    attrs = Map.put(attrs, :score, -1)
    assert {:error, _} = Accounts.create_player(attrs)
  end

  test "fails to create a player with invalid email", %{attrs: attrs} do
    attrs = Map.put(attrs, :email, "invalid")
    assert {:error, _} = Accounts.create_player(attrs)
  end

  test "fails to create a player with an existing email", %{player: player, attrs: attrs} do
    attrs = Map.put(attrs, :email, player.email)
    assert {:error, _} = Accounts.create_player(attrs)
  end
end

A setup block specifically for the create_player logic is included, generating a new valid player struct for each test.

The logic needed to match each player’s attributes has been extracted to DRY things out:

defp assert_player_attributes(expected, actual) do
    assert actual.name == expected.name
    assert actual.email == expected.email
    assert actual.score == expected.score
  end

Testing update_player/1

The following logic should be confirmed when updating a player:

  • A player’s name and score can be updated.
  • A player’s score cannot be decremented.
  • A player’s email cannot be updated.
describe "update_player/2" do
  test "updates a player successfully", %{player: player} do
    update_attrs = %{name: Faker.Person.name(), score: player.score + 1}
    assert {:ok, updated_player} = Accounts.update_player(player, update_attrs)
    assert updated_player.name == update_attrs.name
    assert updated_player.score == update_attrs.score
  end

  test "fails to decrement player score", %{player: player} do
    update_attrs = %{score: player.score - 1}
    assert {:error, _} = Accounts.update_player(player, update_attrs)
  end

  test "does not change email on update", %{player: player} do
    update_attrs = %{email: Faker.Internet.email()}
    assert {:ok, updated_player} = Accounts.update_player(player, update_attrs)
    refute updated_player.email == update_attrs.email
  end
end

Testing delete_player/1

Finally, confirm that a player can be deleted.

describe "delete_player/1" do
  test "deletes a player", %{player: player} do
    id = player.id
    assert {:ok, _} = Accounts.get_player(id)
    assert {:ok, _} = Accounts.delete_player(player)
    assert {:none} = Accounts.get_player(id)
  end
end

Conclusion

By setting up a dedicated testing environment with tools like Docker, Ecto, ExMachina, and Faker, you enable your team to focus on testing the core behaviors of your application. This approach streamlines test management and data handling, making your development process more efficient and ensuring that your Elixir applications are reliable and maintainable.