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:
- Catch logic errors early in the development cycle.
- Verify that supervision trees restart processes correctly after failures.
- Ensure message-passing protocols between processes behave as expected.
- Document expected behavior through executable specifications.
- Refactor with confidence, knowing tests will catch regressions.
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:
- EUnit — a lightweight unit testing framework included in OTP.
- Common Test — a powerful framework for integration and system testing, also part of OTP.
- PropEr — an open-source property-based testing tool inspired by QuickCheck.
- Meck — a mocking library for replacing module functions during tests.
- Cover — OTP's built-in code coverage tool.
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.