Erlang's limitations in module encapsulation
I was recently looking at mocking frameworks for Erlang, to facilitate unit testing. Mocking is a mechanism for easily producing objects and functionality to limit the scope of your tests. For example:
-module(a).
-export([a/1]).
a(X) ->
b(X) * X.
b(X) ->
X * 5.
Pretend that b/1
does something like go to a database, or connect to
some other remote service, or read from a file… something that may
either take a long time, or might not be available in a test
environment. For a unit test for a/1
, what you’re really concerned
with is testing the functionality of a/1
, not b/1
; to accomplish this,
you’d like to mock up the functionality of b/1
to be something known,
so that you can prove a/1
.
What you should be able to do with mocking is something like this (there are a lot of different ways that you could define the mocking interface; I’m just picking an arbitrary one):
mock:expect( ?MODULE, b, fun(X) -> X end ).
Then you could define a unit test that says:
4 = a(2).
This makes a/1
provable through induction, because it isolates the code
in a/1
– which is exactly what you want in a unit test.
Unfortunately, you can’t do this in Erlang in such a way that the unit
tests don’t impact the way you write the code being tested. This is
due to a flaw in Erlang modules, and particularly, the
export
directive.
If you want to do mocking sample above in Erlang, the code being tested must become
-module(a).
-export([a/1,b/1]).
a(X) ->
a:b(X) * X.
b(X) ->
X * 5.
This is because mocking replaces the functionality of a function with
different functionality, and the Erlang compiler hard-links the call to
b/1
in a/1
unless you specify the module of b/1
. This, in itself, is
not a problem: the problem is that even within module a, references to
functions within the same module must be exported. That is, if a/1
wants to call a:b/1
, b/1
must be exported. Why is this a problem?
Because it leads to comments like this, which you find all too commonly
in Erlang projects:
-export([a/1]).
%
-------------------------------------
% WARNING: DO NOT CALL THESE FUNCTIONS
-export([b/1]).
There are a number of reasons why the authors had to export b/1
.
Because spawn/3
can’t call functions that aren’t exported. Because
mocking requires it. Because they want to support code reloading. All
of which are valid use cases, and all of which force developers to
over-expose their API, breaking encapsulation, and suggesting that
Erlang’s module handling is flawed.