← Back to DevBytes

Testing Strategies for Erlang Applications

Introduction to Testing Erlang Applications

Erlang is a functional programming language designed for building concurrent, distributed, and fault-tolerant systems. Because Erlang applications often run in production for years without downtime, testing them thoroughly is not optional — it is essential. This tutorial covers the core testing strategies used by Erlang developers, from unit tests with EUnit to property-based testing with QuickCheck-style frameworks, and integration testing of OTP supervision trees.

Why Testing Matters in Erlang

Erlang's concurrency model, based on lightweight processes and message passing, introduces a class of bugs that are difficult to reproduce manually. Race conditions, deadlocks, and process crashes under specific message orderings can lurk in code that appears correct on the surface. A disciplined testing strategy helps you:

The Erlang Testing Ecosystem

Erlang ships with several testing tools as part of OTP, and the community has built additional frameworks. The most important ones are:

Unit Testing with EUnit

EUnit is the most common starting point for Erlang developers. It provides a simple macro-based syntax for writing tests. EUnit tests live in modules named foo_tests or directly in the implementation module using the ?_test and ?assertEqual macros.

A Simple EUnit Example

Suppose you have a module that performs basic arithmetic operations:

-module(calc).
-export([add/2, divide/2]).

add(A, B) ->
    A + B.

divide(_A, 0) ->
    {error, division_by_zero};
divide(A, B) ->
    A / B.

The corresponding test module would look like this:

-module(calc_tests).
-include_lib("eunit/include/eunit.hrl").

add_test() ->
    ?assertEqual(5, calc:add(2, 3)),
    ?assertEqual(0, calc:add(-1, 1)).

divide_by_zero_test() ->
    ?assertEqual({error, division_by_zero}, calc:divide(10, 0)).

divide_normal_test() ->
    ?assertEqual(5.0, calc:divide(10, 2)).

Run the tests from the Erlang shell with:

eunit:test(calc).

Using Test Generators

EUnit also supports test generators, which produce test sets dynamically. This is useful for data-driven tests:

-module(calc_tests).
-include_lib("eunit/include/eunit.hrl").

add_cases_test_() ->
    Cases = [
        {1, 2, 3},
        {0, 0, 0},
        {-1, 1, 0},
        {100, 200, 300}
    ],
    [?_assertEqual(Expected, calc:add(A, B)) || {A, B, Expected} <- Cases].

The trailing underscore in add_cases_test_/0 tells EUnit that this is a generator function. Each element in the returned list becomes an individual test.

Testing Concurrent Processes

Testing concurrent code requires more care than testing pure functions. You need to spawn processes, send messages, and assert on the results. A common pattern is to use a test process that sends a message back to the calling process so the test can receive and assert on it.

Example: Testing a Simple Server

Consider a basic gen_server that stores a counter:

-module(counter).
-behaviour(gen_server).
-export([start_link/0, increment/1, get_value/1, stop/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
         terminate/2, code_change/3]).

start_link() ->
    gen_server:start_link(?MODULE, 0, []).

increment(Pid) ->
    gen_server:cast(Pid, increment).

get_value(Pid) ->
    gen_server:call(Pid, get_value).

stop(Pid) ->
    gen_server:stop(Pid).

init(Initial) ->
    {ok, Initial}.

handle_call(get_value, _From, State) ->
    {reply, State, State}.

handle_cast(increment, State) ->
    {noreply, State + 1}.

handle_info(_Msg, State) ->
    {noreply, State}.

terminate(_Reason, _State) ->
    ok.

code_change(_OldVsn, State, _Extra) ->
    {ok, State}.

The test module verifies the counter behaves correctly:

-module(counter_tests).
-include_lib("eunit/include/eunit.hrl").

increment_test() ->
    {ok, Pid} = counter:start_link(),
    ?assertEqual(0, counter:get_value(Pid)),
    counter:increment(Pid),
    counter:increment(Pid),
    ?assertEqual(2, counter:get_value(Pid)),
    counter:stop(Pid).

Notice that the test starts the server, interacts with it, and then stops it. Always clean up processes in your tests to avoid leaking processes across test runs.

Testing Supervision Trees

One of Erlang's strengths is its supervision model. Testing that supervisors correctly restart crashed children is a critical integration test. Here is an example using Common Test:

-module(supervisor_SUITE).
-include_lib("common_test/include/ct.hrl").

-compile(export_all).

all() ->
    [child_restarts_on_crash].

init_per_testcase(_TestCase, Config) ->
    {ok, Pid} = my_sup:start_link(),
    [{sup_pid, Pid} | Config].

end_per_testcase(_TestCase, Config) ->
    Pid = proplists:get_value(sup_pid, Config),
    gen_server:stop(Pid).

child_restarts_on_crash(Config) ->
    Pid = proplists:get_value(sup_pid, Config),
    Child = my_sup:get_child_pid(Pid),
    true = is_process_alive(Child),
    exit(Child, kill),
    timer:sleep(100),
    NewChild = my_sup:get_child_pid(Pid),
    true = is_process_alive(NewChild),
    ok.

This test verifies that when a child process is killed, the supervisor restarts it. The timer:sleep/1 call gives the supervisor time to perform the restart. In production tests, you may want to poll for the new process instead of using a fixed sleep.

Property-Based Testing with PropEr

Property-based testing flips the traditional testing model. Instead of writing individual test cases, you describe properties that your code should satisfy for any valid input. The testing framework then generates hundreds of random inputs to try to falsify the property.

PropEr is the most popular property-based testing tool for Erlang. Add it to your rebar.config:

{deps, [
    {proper, "1.4.0"}
]}.

{profiles, [
    {test, [
        {deps, [
            {proper, "1.4.0"}
        ]}
    ]}
]}.

Writing a Property

Here is a property that verifies list reversal is its own inverse:

-module(list_utils_tests).
-include_lib("proper/include/proper.hrl").

prop_reverse_reverse() ->
    ?FORALL(List, list(integer()),
        begin
            Reversed = lists:reverse(List),
            lists:reverse(Reversed) =:= List
        end).

Run it with:

proper:quickcheck(list_utils_tests:prop_reverse_reverse()).

PropEr will generate random lists of integers and check that reversing twice yields the original list. If it finds a counterexample, it shrinks it to the minimal failing case.

Testing Stateful Systems

PropEr can also test stateful systems using a symbolic model. You define a command set, a state model, and pre- and post-conditions. PropEr then generates random sequences of commands and checks that the real system matches the model. This is especially powerful for testing gen_servers and databases.

-module(counter_proper).
-include_lib("proper/include/proper.hrl").

command(_State) ->
    oneof([
        {call, counter, increment, [symbolic_call, counter, start_link, []]},
        {call, counter, get_value, [symbolic_call, counter, start_link, []]}
    ]).

The full stateful testing API in PropEr is extensive. Refer to the PropEr documentation for complete examples of command generation, state modeling, and shrinking.

Mocking with Meck

Sometimes you need to isolate the unit under test from external dependencies such as databases or HTTP services. Meck lets you replace functions in other modules with mock implementations during tests.

Add Meck to your test profile in rebar.config:

{profiles, [
    {test, [
        {deps, [meck]}
    ]}
]}.

Example: Mocking a Database Call

-module(user_service_tests).
-include_lib("eunit/include/eunit.hrl").

-include_lib("eunit/include/eunit.hrl").

fetch_user_test() ->
    meck:new(db, [non_strict]),
    meck:expect(db, find, fun(<<"alice">>) -> {ok, #{name => "Alice"}};
                              (_) -> {error, not_found}
                           end),
    ?assertEqual({ok, #{name => "Alice"}}, user_service:fetch_user(<<"alice">>)),
    ?assertEqual({error, not_found}, user_service:fetch_user(<<"bob">>)),
    meck:unload(db).

Always call meck:unload/1 at the end of your test to restore the original module. Failing to do so can cause mock functions to leak into other tests.

Measuring Code Coverage

OTP includes the cover module for measuring code coverage. With Rebar3, you can run your tests with coverage enabled:

rebar3 do eunit, ct, cover

This runs both EUnit and Common Test suites, then generates an HTML coverage report in _build/test/cover/. Aim for high coverage on business logic modules, but do not chase 100% coverage on trivial code or generated boilerplate.

Best Practices

Keep Tests Pure Where Possible

Prefer testing pure functions over testing processes. Pure functions are deterministic and easy to test. Move logic out of gen_server callbacks into separate modules that can be tested without spawning processes.

Use Setup and Teardown Consistently

Both EUnit and Common Test provide fixtures for setup and teardown. Use them to start and stop processes, open and close connections, and initialize state. This prevents tests from interfering with each other.

setup_test_() ->
    {foreach,
        fun() -> {ok, Pid} = counter:start_link(), Pid end,
        fun(Pid) -> gen_server:stop(Pid) end,
        [
            fun(Pid) -> ?assertEqual(0, counter:get_value(Pid)) end,
            fun(Pid) ->
                counter:increment(Pid),
                ?assertEqual(1, counter:get_value(Pid))
            end
        ]
    }.

Test the Happy Path and the Edge Cases

Always test the normal flow first, then add tests for boundary conditions: empty lists, zero values, maximum sizes, and unexpected inputs. Property-based testing is excellent for discovering edge cases you might not think of manually.

Avoid Testing Implementation Details

Test the public API of your modules, not their internal structure. If your tests depend on the internal state representation of a gen_server, they will break every time you refactor. Instead, assert on observable behavior: return values, messages sent, and side effects.

Run Tests in CI

Integrate your test suite into a continuous integration pipeline. Use Rebar3 to run all tests on every commit:

rebar3 do ct, eunit, proper, cover

Fail the build if coverage drops below a threshold or if any test fails. This keeps the codebase healthy over time.

Use Common Test for Integration Scenarios

While EUnit is great for unit tests, Common Test shines for multi-step integration scenarios. It supports test groups, test suites, per-test configuration, and HTML logging. Use it when a test involves multiple components, external services, or complex setup.

Conclusion

Testing Erlang applications requires a layered approach: unit tests with EUnit for pure logic, process-level tests for gen_servers and supervisors, property-based tests with PropEr for discovering edge cases, and integration tests with Common Test for verifying entire subsystems. By combining these tools and following best practices around purity, isolation, and coverage, you can build Erlang systems that are reliable, maintainable, and safe to evolve over time. The investment you make in testing pays dividends every time you refactor, add features, or debug a production issue — and in a language designed for systems that must never go down, that investment is well worth it.

— Ad —

Google AdSense will appear here after approval

← Back to all articles