Erlang Testing with EUnit and Common Test
Erlang was designed for systems that must not fail — telecom infrastructure running nine nines of uptime. That heritage means Erlang's testing tools are mature, battle-tested, and deeply integrated with the OTP framework. EUnit handles unit tests in a lightweight, embeddable way. Common Test provides a full-featured framework for integration and system tests. PropEr brings property-based testing to the BEAM. Together, they cover the full testing pyramid for Erlang and Elixir systems.
This guide covers all three tools with practical examples, including testing gen_server behaviors and concurrency patterns.
EUnit Overview
EUnit is part of OTP — no external dependencies needed. It uses a header file and a naming convention:
-module(calculator).
-export([add/2, subtract/2, divide/2]).
add(A, B) -> A + B.
subtract(A, B) -> A - B.
divide(_, 0) -> {error, division_by_zero};
divide(A, B) -> {ok, A / B}.Tests live in the same module (or a separate _tests module), guarded by TEST:
-module(calculator).
-export([add/2, subtract/2, divide/2]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
add(A, B) -> A + B.
subtract(A, B) -> A - B.
divide(_, 0) -> {error, division_by_zero};
divide(A, B) -> {ok, A / B}.
-ifdef(TEST).
add_test() ->
?assertEqual(4, add(2, 2)),
?assertEqual(0, add(0, 0)),
?assertEqual(-1, add(1, -2)).
subtract_test() ->
?assertEqual(1, subtract(3, 2)),
?assertEqual(-5, subtract(0, 5)).
divide_test() ->
?assertEqual({ok, 2.0}, divide(4, 2)),
?assertEqual({error, division_by_zero}, divide(10, 0)).
-endif.The _test() suffix is EUnit's auto-discovery convention. Any function whose name ends in _test and takes no arguments is automatically treated as a test.
Assert Macros
EUnit's assert macros provide descriptive failure messages:
?assert(Expr) % true check
?assertNot(Expr) % false check
?assertEqual(Expected, Actual) % structural equality
?assertMatch(Pattern, Expr) % pattern match
?assertNotEqual(A, B) % not equal
?assertException(Class, Term, Expr) % exception check
?assertError(Term, Expr) % error exception
?assertExit(Term, Expr) % exit exception
?assertThrow(Term, Expr) % throw exceptionPattern matching with ?assertMatch is especially useful in Erlang:
parse_response_test() ->
Response = parse_response(<<"{\"status\": \"ok\", \"id\": 42}">>),
?assertMatch({ok, #{<<"status">> := <<"ok">>}}, Response).
exception_test() ->
?assertError(badarg, binary_to_integer(<<"abc">>)),
?assertError(function_clause, divide(0, 0)).Test Generators
EUnit test generators return lists of test descriptors, enabling more complex test organization:
%% A test_ function (trailing underscore) returns a test generator
divide_test_() ->
[
?_assertEqual({ok, 2.0}, divide(4, 2)),
?_assertEqual({ok, 0.5}, divide(1, 2)),
?_assertEqual({ok, -3.0}, divide(-6, 2)),
?_assertEqual({error, division_by_zero}, divide(10, 0))
].
%% Named tests in a generator
parse_test_() ->
[
{"parses valid JSON",
?_assertMatch({ok, _}, parse_json(<<"{\"key\": 1}">>))},
{"rejects invalid JSON",
?_assertMatch({error, _}, parse_json(<<"not json">>))},
{"handles empty object",
?_assertMatch({ok, #{}}, parse_json(<<"{}">>))}
].The ?_assertEqual macro (note leading underscore) creates a test object rather than immediately asserting. This is required inside generators.
Running with rebar3
# Run all EUnit tests
rebar3 eunit
<span class="hljs-comment"># Run a specific module
rebar3 eunit --module calculator
<span class="hljs-comment"># Run a specific test
rebar3 eunit --module calculator --<span class="hljs-built_in">test add_test
<span class="hljs-comment"># Verbose output
rebar3 eunit --verbose
<span class="hljs-comment"># Coverage report
rebar3 cover --reset
rebar3 eunit
rebar3 cover --verboseYour rebar.config may need:
{eunit_opts, [verbose, {report, {eunit_progress, [colored, profile]}}]}.Setup and Teardown with Fixtures
EUnit supports fixtures via the ?setup and ?foreach macros:
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
%% Setup once per test case
user_store_test_() ->
{setup,
fun setup/0, % Setup function
fun teardown/1, % Teardown function (receives setup result)
fun tests/1}. % Tests function (receives setup result)
setup() ->
{ok, Pid} = user_store:start_link(),
Pid.
teardown(Pid) ->
gen_server:stop(Pid).
tests(Pid) ->
[
{"insert and retrieve",
fun() ->
ok = user_store:insert(Pid, #{id => 1, name => <<"Alice">>}),
{ok, User} = user_store:get(Pid, 1),
?assertEqual(<<"Alice">>, maps:get(name, User))
end},
{"delete removes user",
fun() ->
ok = user_store:insert(Pid, #{id => 2, name => <<"Bob">>}),
ok = user_store:delete(Pid, 2),
?assertEqual(not_found, user_store:get(Pid, 2))
end}
].
%% foreach runs setup/teardown around each individual test
cache_test_() ->
{foreach,
fun() -> {ok, C} = cache:start_link([]), C end,
fun(C) -> gen_server:stop(C) end,
[fun(C) ->
fun() ->
cache:put(C, key, value),
?assertEqual({ok, value}, cache:get(C, key))
end
end,
fun(C) ->
fun() ->
?assertEqual(miss, cache:get(C, nonexistent))
end
end]}.
-endif.Common Test Overview
Common Test (ct) is OTP's full-featured testing framework for integration and system tests. It uses Erlang modules as test suites with a specific behavior:
-module(api_SUITE).
-behaviour(ct_suite).
-export([all/0, suite/0,
init_per_suite/1, end_per_suite/1,
init_per_testcase/2, end_per_testcase/2]).
-export([test_create_user/1,
test_get_user/1,
test_delete_user/1]).
all() ->
[test_create_user, test_get_user, test_delete_user].
suite() ->
[{timetrap, {seconds, 30}}].
init_per_suite(Config) ->
{ok, _} = application:ensure_all_started(myapp),
[{base_url, "http://localhost:8080"} | Config].
end_per_suite(_Config) ->
application:stop(myapp).
init_per_testcase(_TestCase, Config) ->
db:clear_tables(),
Config.
end_per_testcase(_TestCase, _Config) ->
ok.Test cases receive the Config proplist:
test_create_user(Config) ->
BaseUrl = proplists:get_value(base_url, Config),
Body = jiffy:encode(#{name => <<"Alice">>, email => <<"alice@example.com">>}),
{ok, {{_, 201, _}, _Headers, ResponseBody}} =
httpc:request(post,
{BaseUrl ++ "/users", [], "application/json", Body},
[], []),
Response = jiffy:decode(list_to_binary(ResponseBody), [return_maps]),
ct:pal("Created user: ~p", [Response]),
true = is_integer(maps:get(<<"id">>, Response)),
<<"Alice">> = maps:get(<<"name">>, Response).
test_get_user(Config) ->
BaseUrl = proplists:get_value(base_url, Config),
%% First create a user
CreateBody = jiffy:encode(#{name => <<"Bob">>, email => <<"bob@example.com">>}),
{ok, {{_, 201, _}, _, CreateResponse}} =
httpc:request(post,
{BaseUrl ++ "/users", [], "application/json", CreateBody},
[], []),
#{<<"id">> := UserId} = jiffy:decode(list_to_binary(CreateResponse), [return_maps]),
%% Then fetch it
Url = BaseUrl ++ "/users/" ++ integer_to_list(UserId),
{ok, {{_, 200, _}, _, GetResponse}} =
httpc:request(get, {Url, []}, [], []),
User = jiffy:decode(list_to_binary(GetResponse), [return_maps]),
<<"Bob">> = maps:get(<<"name">>, User).ct:pal for Logging
ct:pal writes to the Common Test log file — essential for debugging integration tests:
test_something(Config) ->
Result = some_operation(),
ct:pal("Operation result: ~p", [Result]),
ct:pal("Config: ~p", [Config]),
Expected = compute_expected(),
?assertEqual(Expected, Result).Logs appear in _build/test/logs/ organized by suite and test run.
Running with rebar3 ct
# Run all Common Test suites
rebar3 ct
<span class="hljs-comment"># Run a specific suite
rebar3 ct --suite api_SUITE
<span class="hljs-comment"># Run a specific test case
rebar3 ct --suite api_SUITE --<span class="hljs-keyword">case test_create_user
<span class="hljs-comment"># Run with specific config file
rebar3 ct --config <span class="hljs-built_in">test/ct.config
<span class="hljs-comment"># Open HTML test report
open _build/test/logs/index.htmlPropEr: Property-Based Testing for Erlang
PropEr is Erlang's property-based testing library, analogous to Haskell's QuickCheck. Install it in rebar.config:
{deps, [
{proper, "1.4.0"}
]}.Properties use the ?FORALL macro:
-module(sorting_prop).
-include_lib("proper/include/proper.hrl").
-include_lib("eunit/include/eunit.hrl").
%% Properties must start with prop_
prop_sort_idempotent() ->
?FORALL(Xs, list(integer()),
lists:sort(Xs) =:= lists:sort(lists:sort(Xs))).
prop_sort_permutation() ->
?FORALL(Xs, list(integer()),
lists:sort(Xs) -- Xs =:= [] andalso Xs -- lists:sort(Xs) =:= []).
prop_sort_length() ->
?FORALL(Xs, list(integer()),
length(lists:sort(Xs)) =:= length(Xs)).
%% Run via EUnit
prop_test_() ->
[
?_assert(proper:quickcheck(prop_sort_idempotent())),
?_assert(proper:quickcheck(prop_sort_permutation())),
?_assert(proper:quickcheck(prop_sort_length()))
].PropEr generators:
integer() % any integer
nat() % non-negative integer
float() % any float
binary() % any binary
atom() % any atom
list(Type) % list of Type
vector(N, Type) % list of exactly N elements
map(KeyType, ValType) % map
oneof([A, B, C]) % one of these values
frequency([{3, A}, {7, B}]) % weighted choice
range(Min, Max) % integer in range
?LET(Var, Gen, Expr) % derived generatorA realistic property for a parser:
prop_parse_roundtrip() ->
?FORALL(User, user_generator(),
begin
Json = json:encode(User),
{ok, Decoded} = json:decode(Json),
Decoded =:= User
end).
user_generator() ->
?LET({Id, Name, Age},
{pos_integer(),
non_empty(binary()),
range(0, 120)},
#{id => Id, name => Name, age => Age}).Testing gen_server Behaviors
gen_server modules have a specific structure that requires care when testing:
%% src/counter.erl
-module(counter).
-behaviour(gen_server).
-export([start_link/0, increment/1, get/1, reset/1]).
-export([init/1, handle_call/3, handle_cast/2]).
start_link() ->
gen_server:start_link(?MODULE, 0, []).
increment(Pid) ->
gen_server:cast(Pid, increment).
get(Pid) ->
gen_server:call(Pid, get).
reset(Pid) ->
gen_server:call(Pid, reset).
init(N) -> {ok, N}.
handle_call(get, _From, N) ->
{reply, N, N};
handle_call(reset, _From, _N) ->
{reply, ok, 0}.
handle_cast(increment, N) ->
{noreply, N + 1}.Test the gen_server by starting real instances:
-module(counter_test).
-include_lib("eunit/include/eunit.hrl").
counter_test_() ->
{foreach,
fun() ->
{ok, Pid} = counter:start_link(),
Pid
end,
fun(Pid) ->
gen_server:stop(Pid)
end,
[fun(Pid) ->
{"starts at zero",
fun() -> ?assertEqual(0, counter:get(Pid)) end}
end,
fun(Pid) ->
{"increments correctly",
fun() ->
counter:increment(Pid),
counter:increment(Pid),
counter:increment(Pid),
%% cast is async — wait for processing
timer:sleep(10),
?assertEqual(3, counter:get(Pid))
end}
end,
fun(Pid) ->
{"reset returns to zero",
fun() ->
counter:increment(Pid),
counter:increment(Pid),
timer:sleep(10),
ok = counter:reset(Pid),
?assertEqual(0, counter:get(Pid))
end}
end]}.Testing Concurrency
Erlang's concurrency model is message passing. Testing concurrent code requires handling async messages:
%% Test that a process sends a message in response
async_worker_test() ->
Parent = self(),
%% Spawn a process that will send us a result
spawn(fun() ->
Result = do_async_work(heavy_computation),
Parent ! {result, Result}
end),
%% Wait for the message (with timeout)
receive
{result, Value} ->
?assertEqual(expected_result, Value)
after 5000 ->
?assert(false) % Timeout is a test failure
end.
%% Test ordering of messages
message_order_test() ->
Parent = self(),
Pid = spawn(fun() ->
receive
{Parent, first} -> Parent ! {self(), received_first};
{Parent, second} -> Parent ! {self(), received_second}
end,
receive
{Parent, first} -> Parent ! {self(), received_first};
{Parent, second} -> Parent ! {self(), received_second}
end
end),
Pid ! {Parent, first},
Pid ! {Parent, second},
receive
{Pid, received_first} -> ok
after 1000 -> ?assert(false)
end,
receive
{Pid, received_second} -> ok
after 1000 -> ?assert(false)
end.For testing supervision trees, use ct with real application startup and process monitoring:
%% Test that supervisor restarts crashed workers
supervisor_restart_test_() ->
{setup,
fun() ->
{ok, SupPid} = my_supervisor:start_link(),
SupPid
end,
fun(SupPid) ->
exit(SupPid, shutdown)
end,
fun(SupPid) ->
{"worker restarts after crash",
fun() ->
%% Get current worker
[{worker, WorkerPid, _, _}] = supervisor:which_children(SupPid),
%% Kill it
exit(WorkerPid, kill),
timer:sleep(100), % Give supervisor time to restart
%% Supervisor should have a new worker
[{worker, NewPid, _, _}] = supervisor:which_children(SupPid),
?assertNotEqual(WorkerPid, NewPid),
?assert(is_process_alive(NewPid))
end}
end}.CI Setup with rebar3
# .github/workflows/test.yml
name: Erlang Tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Erlang/OTP
uses: erlef/setup-beam@v1
with:
otp-version: '26.2'
rebar3-version: '3.23'
- name: Cache rebar3 dependencies
uses: actions/cache@v3
with:
path: |
~/.cache/rebar3
_build
key: ${{ runner.os }}-rebar3-${{ hashFiles('rebar.config', 'rebar.lock') }}
- name: Compile
run: rebar3 compile
- name: Run EUnit tests
run: rebar3 eunit --verbose
- name: Run Common Test suites
run: rebar3 ct
- name: Run PropEr properties
run: rebar3 proper --numtests 200
- name: Generate coverage report
run: |
rebar3 cover --reset
rebar3 eunit
rebar3 cover --verboseStructuring Erlang Tests
A typical Erlang project structure:
apps/
myapp/
src/
myapp_app.erl
calculator.erl
user_store.erl
test/
calculator_test.erl % EUnit
user_store_test.erl % EUnit with fixtures
sorting_prop.erl % PropEr properties
test/
api_SUITE.erl % Common Test integration
db_SUITE.erl % Common Test DB tests
ct.config % Common Test configKeep EUnit tests in test/ within the application, and Common Test suites in the top-level test/ directory.
Key Differences: EUnit vs Common Test
| Feature | EUnit | Common Test |
|---|---|---|
| Location | Same module or _tests.erl |
Separate _SUITE.erl |
| Scope | Unit tests | Integration/system tests |
| Output | Terminal | HTML reports in _build/test/logs/ |
| Config | Minimal | Rich ct.config |
| Fixtures | ?setup, ?foreach |
init_per_suite, init_per_testcase |
| Parallelism | Test generators | Groups with parallel |
| Log output | io:format | ct:pal |
Use EUnit for pure functions, small modules, and fast feedback. Use Common Test when you need a real OTP application running, external services, HTML reports, or complex multi-suite setups.
HelpMeTest monitors your Erlang/OTP services 24/7 with AI-powered test generation — start free at helpmetest.com