Testing and Deployment
ExUnit Framework
Section titled “ExUnit Framework”ExUnit is the built-in testing framework for Elixir. It ships with the language and provides everything needed for unit testing, integration testing, and doctests.
Test File Structure
Section titled “Test File Structure”Test files live in the test/ directory and follow naming conventions:
test/├── test_helper.exs # Runs before all tests├── my_app_test.exs # Test for the main module└── my_app/ ├── user_test.exs # Test for MyApp.User └── repo_test.exs # Test for MyApp.RepoThe test_helper.exs file sets up the test environment:
ExUnit.start()
## Set test environmentApplication.ensure_all_started(:my_app)
## Configure databaseEcto.Adapters.SQL.Sandbox.mode(MyApp.Repo, :manual)Basic Tests
Section titled “Basic Tests”defmodule MathTest do use ExUnit.Case, async: true
test "addition works" do assert 1 + 1 == 2 end
test "string concatenation" do result = "Hello" <> " " <> "World" assert result == "Hello World" end
test "pattern matching in tests" do {:ok, value} = {:ok, 42} assert value == 42 endendassert and refute
Section titled “assert and refute”assert passes if the expression is truthy. refute passes if the expression is falsy:
test "assert examples" do assert 2 + 2 == 4 assert [1, 2, 3] |> Enum.sum() == 6 assert is_list([1, 2, 3]) assert Map.has_key?(%{a: 1}, :a)end
test "refute examples" do refute false refute nil refute 1 == 2 refute Map.has_key?(%{a: 1}, :b)endassert_raise
Section titled “assert_raise”assert_raise verifies that an exception is raised:
test "division by zero raises" do assert_raise ArithmeticError, fn -> 1 / 0 endend
test "custom exception with message" do assert_raise MyApp.Error, "invalid input", fn -> MyApp.validate!(nil) endend
# Capture the exception for further assertionstest "exception details" do assert_raise RuntimeError, fn -> raise "something bad" endendassert_receive
Section titled “assert_receive”assert_receive verifies that a specific message arrives in the process mailbox:
test "process sends a message" do pid = spawn(fn -> Process.sleep(50) send(self(), {:done, 42}) end)
assert_receive {:done, 42}end
# With timeouttest "message with timeout" do send(self(), :hello)
assert_receive :hello, 1000end
# refute_receivetest "no unexpected messages" do refute_receive :unexpected, 100enddescribe Blocks
Section titled “describe Blocks”describe groups related tests and supports shared setup:
defmodule CalculatorTest do use ExUnit.Case
describe "addition" do test "positive numbers" do assert Calculator.add(1, 2) == 3 end
test "negative numbers" do assert Calculator.add(-1, -2) == -3 end
test "mixed signs" do assert Calculator.add(-5, 10) == 5 end end
describe "division" do test "exact division" do assert Calculator.divide(10, 2) == {:ok, 5.0} end
test "division by zero" do assert Calculator.divide(10, 0) == {:error, :division_by_zero} end endendSetup and Teardown
Section titled “Setup and Teardown”defmodule DatabaseTest do use ExUnit.Case, async: false
setup do # Runs before each test :ok = MyApp.DB.connect() on_exit(fn -> # Runs after each test (even if it fails) MyApp.DB.disconnect() end) :ok end
test "insert and retrieve" do MyApp.DB.insert(:users, %{name: "Alice"}) assert MyApp.DB.get(:users, "Alice") != nil endendSetup with Context
Section titled “Setup with Context”defmodule UserControllerTest do use ExUnit.Case
setup do user = %{id: 1, name: "Test User", role: :admin} {:ok, user: user} end
test "admin can access dashboard", context do user = context.user assert {:ok, _} = UserController.dashboard(user) end
# Pattern matching on context directly test "user has correct role", %{user: user} do assert user.role == :admin endendSetup with Tags
Section titled “Setup with Tags”defmodule FeatureTest do use ExUnit.Case, async: true
setup tags do if tags[:integration] do # Setup for integration tests {:ok, conn: start_connection()} else # Setup for unit tests {:ok, conn: nil} end end
@tag integration: true test "database integration" do assert true end
test "unit test" do assert true endendRun tests with a specific tag:
# Run only integration testsmix test --only integration
# Exclude integration testsmix test --exclude integrationDoctests
Section titled “Doctests”Doctests extract examples from @doc attributes and run them as tests:
defmodule Math do @doc """ Adds two numbers.
## Examples
iex> Math.add(2, 3) 5
iex> Math.add(-1, 1) 0
iex> Math.add(0, 0) 0 """ @spec add(number(), number()) :: number() def add(a, b), do: a + bend
defmodule MathTest do use ExUnit.Case doctest MathendDoctests are excellent for documentation that doubles as tests. They ensure examples in documentation stay accurate.
ExUnit.Callbacks
Section titled “ExUnit.Callbacks”ExUnit.Callbacks provides hooks for test lifecycle management:
defmodule CallbacksTest do use ExUnit.Case, async: true
setup_all do # Runs once before all tests in the module shared_resource = create_shared_resource() {:ok, shared: shared_resource} end
setup context do # Runs before each test if context.test == "test special case" do {:ok, special: true} else :ok end end
test "first test", %{shared: shared} do assert shared != nil endendMocking
Section titled “Mocking”Mox is the standard mocking library for Elixir. It generates mock modules from behaviours and ensures mocks are only used in tests:
{:mox, "~> 1.1", only: :test}# Define a behaviourdefmodule StorageBehaviour do @callback get(key :: String.t()) :: {:ok, any()} | {:error, :not_found} @callback put(key :: String.t(), value :: any()) :: :okend
# In test_helper.exsMox.defmock(StorageMock, for: StorageBehaviour)
# In testsdefmodule UserServiceTest do use ExUnit.Case import Mox
setup :verify_on_exit!
test "retrieves user from storage" do expect(StorageMock, :get, fn "user:123" -> {:ok, %{id: 123, name: "Alice"}} end)
assert {:ok, user} = UserService.find(123) assert user.name == "Alice" end
test "handles not found" do expect(StorageMock, :get, fn "user:999" -> {:error, :not_found} end)
assert {:error, :not_found} = UserService.find(999) end
test "allows multiple expectations" do expect(StorageMock, :get, fn "user:1" -> {:ok, %{id: 1}} end) expect(StorageMock, :get, fn "user:2" -> {:ok, %{id: 2}} end) expect(StorageMock, :get, fn "user:3" -> {:error, :not_found} end)
assert {:ok, _} = UserService.find(1) assert {:ok, _} = UserService.find(2) assert {:error, :not_found} = UserService.find(3) end
test "stub returns same value regardless of args" do stub(StorageMock, :get, fn _ -> {:ok, %{id: 0, name: "Stub"}} end)
assert {:ok, user} = UserService.find(any_number) assert user.name == "Stub" endendMox Conventions
Section titled “Mox Conventions”- Always call
setup :verify_on_exit!to verify all expectations were called - Use
expectfor specific call expectations (order matters, arguments must match) - Use
stubfor default returns when specific arguments do not need matching - Mox allows both private and public functions to be mocked
- Mox ensures mocks cannot leak into non-test code
Property-Based Testing
Section titled “Property-Based Testing”StreamData
Section titled “StreamData”StreamData brings property-based testing to Elixir (similar to QuickCheck):
{:stream_data, "~> 0.6", only: [:test, :dev]}defmodule MathPropertyTest do use ExUnit.Case, async: true use ExUnitProperties
property "addition is commutative" do check all a <- integer(), b <- integer() do assert Math.add(a, b) == Math.add(b, a) end end
property "addition is associative" do check all a <- integer(), b <- integer(), c <- integer() do result1 = Math.add(Math.add(a, b), c) result2 = Math.add(a, Math.add(b, c)) assert result1 == result2 end end
property "sort is idempotent" do check all list <- list_of(integer()) do assert Enum.sort(Enum.sort(list)) == Enum.sort(list) end end
property "reverse(reverse(list)) == list" do check all list <- list_of(term()) do assert Enum.reverse(Enum.reverse(list)) == list end end
property "string length after upcase is the same" do check all str <- string(:alphanumeric) do assert String.length(String.upcase(str)) == String.length(str) end endendCustom Generators
Section titled “Custom Generators”defmodule CustomGenerators do use ExUnitProperties
def user do gen all name <- string(:alphanumeric, min_length: 1, max_length: 50), age <- integer(0..120), email <- string(:ascii, min_length: 5, max_length: 100) do %{name: name, age: age, email: email} end end
def non_empty_list(gen) do gen all [head | tail] <- list_of(gen, min_length: 1) do [head | tail] end endend
defmodule UserPropertyTest do use ExUnit.Case, async: true use ExUnitProperties
property "user name is always a string" do check all user <- CustomGenerators.user() do assert is_binary(user.name) assert String.length(user.name) > 0 assert user.age >= 0 and user.age <= 120 end endendShrinking
Section titled “Shrinking”When StreamData finds a failing case, it automatically shrinks the input to find the minimal failing example:
property "list concatenation preserves length" do check all a <- list_of(integer(), min_length: 0), b <- list_of(integer(), min_length: 0) do assert length(a ++ b) == length(a) + length(b) endendIf this property fails, StreamData will try to find the smallest lists that trigger the failure, making bugs easier to understand and fix.
Mix Environments
Section titled “Mix Environments”Environment Configuration
Section titled “Environment Configuration”Mix has three built-in environments: dev, test, and prod:
config/├── config.exs # Base configuration├── dev.exs # Development overrides├── test.exs # Test overrides├── prod.exs # Production overrides└── runtime.exs # Runtime-only (secrets, env vars)import Config
config :my_app, ecto_repos: [MyApp.Repo]
config :my_app, MyApp.Repo, pool_size: 10
import_config "#{config_env()}.exs"import Config
config :my_app, MyApp.Repo, username: "postgres", password: "postgres", database: "my_app_dev", hostname: "localhost", show_sensitive_data_on_connection_error: true, pool_size: 10
config :my_app, MyAppWeb.Endpoint, debug_errors: true, code_reloader: true, check_origin: false
config :logger, level: :debugimport Config
config :my_app, MyApp.Repo, username: "postgres", password: "postgres", database: "my_app_test", hostname: "localhost", pool: Ecto.Adapters.SQL.Sandbox
config :logger, level: :warningimport Config
config :my_app, MyApp.Repo, pool_size: 20
config :my_app, MyAppWeb.Endpoint, cache_static_lookup_assets: true, force_ssl: [hsts: true]
config :logger, level: :info# config/runtime.exs (for secrets and env vars)import Config
if config_env() == :prod do secret_key_base = System.get_env("SECRET_KEY_BASE") || raise "SECRET_KEY_BASE environment variable is missing"
database_url = System.get_env("DATABASE_URL") || raise "DATABASE_URL environment variable is missing"
config :my_app, MyAppWeb.Endpoint, secret_key_base: secret_key_base
config :my_app, MyApp.Repo, url: database_url, pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")endRunning in Different Environments
Section titled “Running in Different Environments”# Default (dev)mix run
# Test environmentMIX_ENV=test mix run
# Production environmentMIX_ENV=prod mix run
# Specific commandsMIX_ENV=test mix testMIX_ENV=prod mix releaseMix Releases
Section titled “Mix Releases”Creating a Release
Section titled “Creating a Release”Releases package your application into a self-contained directory that includes the BEAM VM, your application, and all dependencies:
def project do [ app: :my_app, version: "0.1.0", elixir: "~> 1.16", start_permanent: Mix.env() == :prod, deps: deps(), releases: [ my_app: [ version: "0.1.0", applications: [runtime_tools: :permanent] ] ] ]end# Build a releaseMIX_ENV=prod mix release
# The release is created in _build/prod/rel/my_app/# Contains:# - bin/my_app # Start/stop/daemonize scripts# - lib/ # BEAM VM and compiled code# - releases/ # Version info and upgrades# - erts-* # Erlang Runtime SystemRunning a Release
Section titled “Running a Release”# Start in foreground_build/prod/rel/my_app/bin/my_app start
# Start as daemon_build/prod/rel/my_app/bin/my_app daemon
# Run remote console (connect to running node)_build/prod/rel/my_app/bin/my_app remote
# Stop the application_build/prod/rel/my_app/bin/my_app stop
# Check status_build/prod/rel/my_app/bin/my_app pid
# Run a command_build/prod/rel/my_app/bin/my_app eval "MyApp.health_check()"Release Configuration
Section titled “Release Configuration”# mix.exs - release configurationdef project do [ releases: [ my_app: [ version: "0.1.0", applications: [runtime_tools: :permanent], # Include ERTS (Erlang Runtime System) include_erts: true, # Strip debug info strip_beams: true, # Cookie for distributed Erlang cookie: "my_app_cookie", # VM args vm_args: "rel/vm.args.eex" ] ] ]endHot Code Upgrades
Section titled “Hot Code Upgrades”One of BEAM”s most distinctive features is hot code swapping — loading new code without stopping the system:
# 1. Build the current releaseMIX_ENV=prod mix release
# 2. Make code changes and bump version
# 3. Build the new releaseMIX_ENV=prod mix release
# 4. Generate an upgrade packageMIX_ENV=prod mix release --upgrade
# 5. Install the upgrade on the running system_build/prod/rel/my_app/bin/my_app upgrade "0.2.0"
# 6. If needed, roll back_build/prod/rel/my_app/bin/my_app downgrade "0.1.0"During a hot upgrade:
- New processes use the new version of modules
- Old processes continue running with the old version
- Messages are serialized and deserialized if needed
- The system never goes down
The code_change/3 callback in GenServer handles state transformation between versions:
defmodule MyServer do use GenServer
@impl true def code_change({:down, _vsn}, state, _extra) do # Downgrade: new state -> old state old_state = downgrade_state(state) {:ok, old_state} end
def code_change({:up, _vsn}, old_state, _extra) do # Upgrade: old state -> new state new_state = upgrade_state(old_state) {:ok, new_state} endendDeployment with Docker
Section titled “Deployment with Docker”Dockerfile for Elixir/Phoenix
Section titled “Dockerfile for Elixir/Phoenix”# Build stageFROM elixir:1.16-alpine AS build
RUN apk add --no-cache build-base git curl
WORKDIR /app
# Install hex + rebarRUN mix local.hex --force && mix local.rebar --force
# Copy mix files first (layer caching)COPY mix.exs mix.lock ./RUN mix deps.get --only prodRUN mix deps.compile
# Copy source codeCOPY config configCOPY lib libCOPY priv priv
# Compile applicationRUN mix compile
# Compile assets (if Phoenix)RUN mix assets.deploy
# Create releaseRUN mix release --overwrite
# Runtime stageFROM alpine:3.19 AS app
RUN apk add --no-cache ncurses-libs openssl
WORKDIR /app
COPY --from=build /app/_build/prod/rel/my_app ./
ENV HOME=/appENV MIX_ENV=prodENV SECRET_KEY_BASE=change_me
EXPOSE 4000
CMD ["bin/my_app", "start"]Docker Compose
Section titled “Docker Compose”version: "3.8''services: app: build: . ports: - "4000:4000' environment: - DATABASE_URL=postgres://postgres:postgres@db/my_app_prod - SECRET_KEY_BASE=${SECRET_KEY_BASE} depends_on: db: condition: service_healthy
db: image: postgres:16-alpine environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres - POSTGRES_DB=my_app_prod healthcheck: test: ['CMD-SHELL', 'pg_isready -U postgres'] interval: 5s timeout: 5s retries: 5 volumes: - pgdata:/var/lib/postgresql/data
volumes: pgdata:Multi-stage Builds
Section titled “Multi-stage Builds”Multi-stage builds keep the final image small by separating build dependencies from runtime:
# Builddocker build -t my-app:latest .
# Rundocker run -d \ -p 4000:4000 \ -e DATABASE_URL=postgres://... \ -e SECRET_KEY_BASE=$(openssl rand -base64 48) \ --name my-app \ my-app:latestUmbrella Applications
Section titled “Umbrella Applications”Structure
Section titled “Structure”Umbrella applications organize multiple Elixir applications within a single repository:
my_umbrella/├── apps/│ ├── core/ # Shared domain logic│ │ ├── lib/│ │ ├── test/│ │ └── mix.exs│ ├── web/ # Web server│ │ ├── lib/│ │ ├── test/│ │ └── mix.exs│ └── worker/ # Background processing│ ├── lib/│ ├── test/│ └── mix.exs├── config/│ ├── config.exs│ ├── dev.exs│ ├── test.exs│ └── prod.exs├── mix.exs # Root mix.exs└── mix.lockRoot mix.exs
Section titled “Root mix.exs”defmodule Umbrella.MixProject do use Mix.Project
def project do [ apps_path: "apps", version: "0.1.0", start_permanent: Mix.env() == :prod, deps: deps(), aliases: aliases() ] end
defp deps do [{phoenix: "~> 1.7"}] end
defp aliases do [ "test.all": ["test --only umbrella"], setup: ["deps.get", "ecto.setup --only umbrella"] ] endendBenefits and Trade-offs
Section titled “Benefits and Trade-offs”Benefits:
- Shared dependencies reduce compilation time and disk usage
- Code sharing between apps via standard Mix dependencies
- Single repository with unified versioning
- Coordinated deployments
Trade-offs:
- Coupling between apps can increase over time
- Release granularity is coarse (all apps deploy together)
- Test runtimes are longer
- Complex dependency graphs between sub-apps
Intuition
Section titled “Intuition”Quality assurance: Testing is like proof-reading your code — it catches errors before they reach users. Deployment is shipping your finished product to customers.
Why it matters: Good testing practices prevent bugs and improve code quality. Automated deployment ensures consistency and reliability.
The key insight: Test early, test often — catching bugs early is much cheaper than fixing them in production.
Summary
Section titled “Summary”Elixir provides a comprehensive testing and deployment story:
- ExUnit provides
test,describe,assert,refute,assert_raise,assert_receive setupandsetup_allprovide per-test and per-module hooks- Doctests extract examples from
@docand run them as tests - Mox generates type-safe mocks from behaviours
- StreamData enables property-based testing with automatic shrinking
- Mix environments (
dev,test,prod) manage configuration mix releasecreates self-contained, deployable packages- Hot code upgrades enable zero-downtime deployments
- Docker multi-stage builds produce small, efficient images
- Umbrella apps organize multiple applications in one repository
Common Mistakes
Section titled “Common Mistakes”Putting secrets in config/prod.exs: Committing secrets to version control is a security risk. Use config/runtime.exs which reads environment variables at runtime, keeping secrets out of the codebase.
Forgetting async: true in isolated tests: Tests without async: true run sequentially by default. Adding async: true to tests that don’t share state significantly speeds up the test suite.
Skipping code_change/3 in hot upgrades: Without implementing the code_change/3 callback in GenServers, hot code upgrades will crash processes that have state. Always implement state transformation for both upgrades and downgrades.