Erlang Testing with EUnit and Common Test

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 exception

Pattern 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 --verbose

Your 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.html

PropEr: 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 generator

A 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 --verbose

Structuring 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 config

Keep 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

Read more