Elixir and Ecto: Testing behaviors
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.
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
to5481
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.