EUnit Testing gen_fsm – Part 1


This post will be TDD based and shows how the Tradepost program is developed and is tested.

First iteration – Start and Stop

I start writing the test! This gives me the opportunity to crystallize my thoughts about the program.

The module implementing the tests of the Tradepost

-module(tradepost_tests).
-include_lib("eunit/include/eunit.hrl").
% This is the main point of "entry" for my EUnit testing.
% A generator which forces setup and cleanup for each test in the testset
main_test_() ->
    {foreach,
     fun setup/0,
     fun cleanup/1,
     % Note that this must be a List of TestSet or Instantiator
     % (I have instantiators == functions generating tests)
     [
      % First Iteration
      fun started_properly/1,
     ]}.

% Setup and Cleanup
setup()      -> {ok,Pid} = tradepost:start_link(), Pid.
cleanup(Pid) -> tradepost:stop(Pid).

% Pure tests below
% ------------------------------------------------------------------------------
% Let's start simple, I want it to start and check that it is okay.
% I will use the introspective function for this
started_properly(Pid) ->
    fun() ->
            ?assertEqual(pending,tradepost:introspection_statename(Pid)),
            ?assertEqual([undefined,undefined,undefined,undefined,undefined],
                         tradepost:introspection_loopdata(Pid))
    end.

Compilation and running should fail as the gen_fsm module is barely minimal

zen:EUnitFSM zenon$ tree .
.
├── ebin
├── include
├── src
│   └── tradepost.erl
└── test
    └── tradepost_tests.erl

4 directories, 2 files
zen:EUnitFSM zenon$ 

zen:EUnitFSM zenon$ erlc -o ebin/ src/*.erl test/*.erl
zen:EUnitFSM zenon$ erl -pa ebin/
Erlang R13B04 (erts-5.7.5) [source] [64-bit] [smp:4:4] [rq:4]
[async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.7.5  (abort with ^G)
1> eunit:test(tradepost).
tradepost_tests: started_properly...*failed*
::error:{assertEqual_failed,
          [{module,tradepost_tests},
           {line,33},
           {expression,"tradepost : introspection_statename ( Pid )"},
           {expected,pending},
           {value,ok}]}
  in function tradepost_tests:'-started_properly/1-fun-0-'/2
  in call from tradepost_tests:'-started_properly/1-fun-2-'/1

module 'tradepost_tests'
*** context cleanup failed ***
::error:undef
  in function tradepost:stop/1
    called as stop(<0.39.0>)

=======================================================
  Failed: 1.  Skipped: 0.  Passed: 0.
One or more tests were cancelled.
error
2>

Clearly the tradepost does not handle the introspective functions and stop, let’s write them first.

The interface

introspection_statename(TradePost) ->
    gen_fsm:sync_send_all_state_event(TradePost,which_statename).
introspection_loopdata(TradePost) ->
    gen_fsm:sync_send_all_state_event(TradePost,which_loopdata).
stop(Pid) -> gen_fsm:sync_send_all_state_event(Pid,stop).

And the handling

handle_sync_event(which_statename, _From, StateName, LoopData) ->
    {reply, StateName, StateName, LoopData};
handle_sync_event(which_loopdata, _From, StateName, LoopData) ->
    {reply,tl(tuple_to_list(LoopData)),StateName,LoopData};
handle_sync_event(stop,_From,_StateName,LoopData) ->
    {stop,normal,ok,LoopData}.

Running

zen:EUnitFSM zenon$ erl -pa ebin/
Erlang R13B04 (erts-5.7.5) [source] [64-bit] [smp:4:4] [rq:4]
[async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.7.5  (abort with ^G)
1> eunit:test(tradepost,[verbose]).
======================== EUnit ========================
module 'tradepost'
  module 'tradepost_tests'
    tradepost_tests: started_properly...ok
    [done in 0.004 s]
  [done in 0.005 s]
=======================================================
  Test passed.
ok
2>


Wow, I’m glad we got that sorted out. Now,  as we have set the first nail in the mountain and hooked us to it, the climb begins in a series of cycles. Next up, the Seller API.

Second Iteration – Seller API

What the seller needs, is a way to insert an item, and a way to remove an item. Also to identify him/her self with a naive password approach. Once identified, the seller (and only the seller) should be able to add and retract items.

The main_test_() has now been expanded to

% This is the main point of "entry" for my EUnit testing.
% A generator which forces setup and cleanup for each test in the testset
main_test_() ->
    {foreach,
     fun setup/0,
     fun cleanup/1,
     % Note that this must be a List of TestSet or Instantiator
     % (I have instantiators)
     [
      % First Iteration
      fun started_properly/1,
      % Second Iteration
      fun identify_seller/1,
      fun insert_item/1,
      fun withdraw_item/1
     ]}.

And the definition of the tests themselves

% Now, we are adding the Seller API tests
identify_seller(Pid) ->
    fun() ->
            % From Pending, identify seller, then state should be pending
            % loopdata should now contain seller_password
            ?assertEqual(pending,tradepost:introspection_statename(Pid)),
            ?assertEqual(ok,tradepost:seller_identify(Pid,seller_password)),
            ?assertEqual(pending,tradepost:introspection_statename(Pid)),
            ?assertEqual([undefined,undefined,seller_password,undefined,
                       undefined],tradepost:introspection_loopdata(Pid))
    end.

insert_item(Pid) ->
    fun() ->
            % From pending and identified seller, insert item
            % state should now be item_received, loopdata should now contain itm
            tradepost:introspection_statename(Pid),
            tradepost:seller_identify(Pid,seller_password),
            ?assertEqual(ok,tradepost:seller_insertitem(Pid,playstation,
                                                  seller_password)),
            ?assertEqual(item_received,tradepost:introspection_statename(Pid)),
            ?assertEqual([playstation,undefined,seller_password,undefined,
                       undefined],tradepost:introspection_loopdata(Pid))
    end.

withdraw_item(Pid) ->
    fun() ->
            % identified seller and inserted item, withdraw item
            % state should now be pending, loopdata should now contain only password
            tradepost:seller_identify(Pid,seller_password),
            tradepost:seller_insertitem(Pid,playstation,seller_password),
            ?assertEqual(ok,tradepost:withdraw_item(Pid,seller_password)),
            ?assertEqual(pending,tradepost:introspection_statename(Pid)),
            ?assertEqual([undefined,undefined,seller_password,undefined,
                       undefined],tradepost:introspection_loopdata(Pid))
    end.

Now, this is a lot of code duplication per test, and  I shall start rewriting the code into something more wieldy, but first: the failed compilation and the code making it pass.

zen:EUnitFSM zenon$ erlc -o ebin/ src/*.erl test/*.erl
zen:EUnitFSM zenon$ erl -pa ebin/
Erlang R13B04 (erts-5.7.5) [source] [64-bit] [smp:4:4] [rq:4]
[async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.7.5  (abort with ^G)
1> eunit:test(tradepost).
tradepost_tests: identify_seller...*failed*
::error:undef
  in function tradepost:seller_identify/2
    called as seller_identify(<0.39.0>,seller_password)
  in call from tradepost_tests:'-identify_seller/1-fun-1-'/2
  in call from tradepost_tests:'-identify_seller/1-fun-4-'/1

tradepost_tests: insert_item...*failed*
::error:undef
  in function tradepost:seller_identify/2
    called as seller_identify(<0.39.0>,seller_password)
  in call from tradepost_tests:'-insert_item/1-fun-3-'/1

tradepost_tests: withdraw_item...*failed*
::error:undef
  in function tradepost:seller_identify/2
    called as seller_identify(<0.39.0>,seller_password)
  in call from tradepost_tests:'-withdraw_item/1-fun-3-'/1

=======================================================
  Failed: 3.  Skipped: 0.  Passed: 1.
error
2>

Some writing and enjoying later, the module

%%%-------------------------------------------------------------------
%%% @author Gianfranco <zenon@zen.home>
%%% @copyright (C) 2010, Gianfranco
%%% Created :  2 Sep 2010 by Gianfranco <zenon@zen.home>
%%%-------------------------------------------------------------------
-module(tradepost).
-behaviour(gen_fsm).

%% API
-export([start_link/0,introspection_statename/1,introspection_loopdata/1,
         stop/1,seller_identify/2,seller_insertitem/3,withdraw_item/2]).

%% States
-export([pending/2,pending/3,item_received/3]).

%% gen_fsm callbacks
-export([init/1, handle_event/3, handle_sync_event/4, handle_info/3,
         terminate/3, code_change/4]).
-record(state, {object,cash,seller,buyer,time}).

%%% API
start_link() -> gen_fsm:start_link(?MODULE, [], []).

introspection_statename(TradePost) ->
    gen_fsm:sync_send_all_state_event(TradePost,which_statename).
introspection_loopdata(TradePost) ->
    gen_fsm:sync_send_all_state_event(TradePost,which_loopdata).
stop(Pid) -> gen_fsm:sync_send_all_state_event(Pid,stop).

seller_identify(TradePost,Password) ->
    gen_fsm:sync_send_event(TradePost,{identify_seller,Password}).
seller_insertitem(TradePost,Item,Password) ->
    gen_fsm:sync_send_event(TradePost,{insert,Item,Password}).

withdraw_item(TradePost,Password) ->
    gen_fsm:sync_send_event(TradePost,{withdraw,Password}).

%%--------------------------------------------------------------------
pending(_Event,LoopData) -> {next_state,pending,LoopData}.

pending({identify_seller,Password},_Frm,LoopD = #state{seller=Password}) ->
    {reply,ok,pending,LoopD};
pending({identify_seller,Password},_Frm,LoopD = #state{seller=undefined}) ->
    {reply,ok,pending,LoopD#state{seller=Password}};
pending({identify_seller,_},_,LoopD) ->
    {reply,error,pending,LoopD};

pending({insert,Item,Password},_Frm,LoopD = #state{seller=Password}) ->
    {reply,ok,item_received,LoopD#state{object=Item}};
pending({insert,_,_},_Frm,LoopD) ->
    {reply,error,pending,LoopD}.

item_received({withdraw,Password},_Frm,LoopD = #state{seller=Password}) ->
    {reply,ok,pending,LoopD#state{object=undefined}};
item_received({withdraw,_},_Frm,LoopD) ->
    {reply,error,item_received,LoopD}.

%%--------------------------------------------------------------------
handle_sync_event(which_statename, _From, StateName, LoopData) ->
    {reply, StateName, StateName, LoopData};
handle_sync_event(which_loopdata, _From, StateName, LoopData) ->
    {reply,tl(tuple_to_list(LoopData)),StateName,LoopData};
handle_sync_event(stop,_From,_StateName,LoopData) ->
    {stop,normal,ok,LoopData};
handle_sync_event(_E,_From,StateName,LoopData) ->
    {reply,ok,StateName,LoopData}.

%%--------------------------------------------------------------------
init([]) -> {ok, pending, #state{}}.
handle_event(_Event, StateName, State) ->{next_state, StateName, State}.
handle_info(_Info, StateName, State) -> {next_state, StateName, State}.
terminate(_Reason, _StateName, _State) -> ok.
code_change(_OldVsn, StateName, State, _Extra) -> {ok, StateName, State}.

And proof for the functioning of it.

zen:EUnitFSM zenon$ erlc -o ebin/ src/*.erl test/*.erl
zen:EUnitFSM zenon$ erl -pa ebin/ -eval 'eunit:test(tradepost,[verbose]).'
Erlang R13B04 (erts-5.7.5) [source] [64-bit] [smp:4:4] [rq:4]
[async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.7.5  (abort with ^G)
1> ======================== EUnit ========================
module 'tradepost'
  module 'tradepost_tests'
    tradepost_tests: started_properly...ok
    tradepost_tests: identify_seller...ok
    tradepost_tests: insert_item...ok
    tradepost_tests: withdraw_item...ok
    [done in 0.015 s]
  [done in 0.015 s]
=======================================================
  All 4 tests passed.
1>

Awesome! And we got a bonus as well: how to run eunit directly from the command line with the -eval command. Cool, oneliners always make you feel more “1337”.

It all looks very good so far, but I will use the third iteration to fixing up the eunit module, it has way to much duplication and could be made more declarative (specifying what should be computed – not how [ which is kind of more abstract <of course someone will oppose>]).

Third Iteration –  Eunit GenFSM DSL (do not worry)

Testing gen_fsm’s should ideally be all about testing state transitions, in-state computations and variables. For this purpose, I would like to have my DSL that handles all of the pesky details for me.

To exemplify, I would like to write the first test

started_properly(Pid) ->
    fun() ->
            ?assertEqual(pending,tradepost:introspection_statename(Pid)),
            ?assertEqual([undefined,undefined,undefined,undefined,undefined],
                         tradepost:introspection_loopdata(Pid))
    end.

as something along these lines

started_properly(Pid) ->
    {"Proper startup test",
     [{statename,is,pending},
      {loopdata,is,[undefined,undefined,undefined,undefined,undefined]}
      ]}.

And, this test

insert_item(Pid) ->
    fun() ->
        % From pending and identified seller, insert item
        % state should now be item_received, loopdata should now contain itm
        tradepost:introspection_statename(Pid),
        tradepost:seller_identify(Pid,seller_password),
        ?assertEqual(ok,tradepost:seller_insertitem(Pid,playstation,
                                              seller_password)),
        ?assertEqual(item_received,tradepost:introspection_statename(Pid)),
        ?assertEqual([playstation,undefined,seller_password,undefined,
                   undefined],tradepost:introspection_loopdata(Pid))
    end.

as something along these lines

insert_item(Pid) ->
    {"Insert Item Test",
      [{state,is,pending},
       {call,tradepost,seller_identify,[Pid,seller_password],ok},
       {call,tradepost,seller_insertitem,[Pid,playstation,seller_password]},
       {state,is,item_received},
       {loopdata,is,[playstation,undefined,seller_password,undefined,undefined]}
      ]}.

This is a symbolic method that defines our DSL in a yet readable way, hiding the logic. For this to work, we need a translation from our DSL to actual EUnit syntax without losing the EUnit machinery. We will also drop the intrusive introspective functions for the usage of the better sys:get_status/1 (thank you Ulf).

Thus, let the journey begin, first with the translation of the Test. This can be done in two ways, either at runtime or at compile time with parse-transforms. I choose the runtime one with translation functions and macros. Ulf Wiger has a neat library for cooler parse transforms, but I shall not use this for now, (the code will probably be revised many times)

The test module has now been changed to

-module(tradepost_tests).
-include_lib("eunit/include/eunit.hrl").
-include("include/eunit_fsm.hrl").

% This is the main point of "entry" for my EUnit testing.
% A generator which forces setup and cleanup for each test in the testset
main_test_() ->
    {foreach,
     fun setup/0,
     fun cleanup/1,
     % Note that this must be a List of TestSet or Instantiator
     [
      % First Iteration
      fun started_properly/1,
      % Second Iteration
      fun identify_seller/1,
      fun insert_item/1,
      fun withdraw_item/1
     ]}.

% Setup and Cleanup
setup()      -> {ok,Pid} = tradepost:start_link(), Pid.
cleanup(Pid) -> tradepost:stop(Pid).

% Pure tests below
% ------------------------------------------------------------------------------
% Let's start simple, I want it to start and check that it is okay.
% I will use the introspective function for this
started_properly(Pid) ->
    ?fsm_test(tradepost,Pid,"Started Properly Test",
      [{state,is,pending},
       {loopdata,is,[undefined,undefined,undefined,undefined,undefined]}
     ]).

% Now, we are adding the Seller API tests
identify_seller(Pid) ->
    ?fsm_test(Pid,"Identify Seller Test",
      [{state,is,pending},
       {call,tradepost,seller_identify,[Pid,seller_password],ok},
       {state,is,pending},
       {loopdata,is,[undefined,undefined,seller_password,undefined,undefined]}
      ]).

insert_item(Pid) ->
    ?fsm_test(Pid,"Insert Item Test",
       [{state,is,pending},
        {call,tradepost,seller_identify,[Pid,seller_password],ok},
        {call,tradepost,seller_insertitem,[Pid,playstation,seller_password],ok},
        {state,is,item_received},
        {loopdata,is,[playstation,undefined,seller_password,undefined,undefined]}
       ]).

withdraw_item(Pid) ->
    ?fsm_test(Pid,"Withdraw Item Test",
       [{state,is,pending},
        {call,tradepost,seller_identify,[Pid,seller_password],ok},
        {call,tradepost,seller_insertitem,[Pid,button,seller_password],ok},
        {state,is,item_received},
        {call,tradepost,seller_withdraw_item,[Pid,seller_password],ok},
        {state,is,pending},
        {loopdata,is,[undefined,undefined,seller_password,undefined,undefined]}
       ]).

And the supporting logic is found in eunit_fsm.hrl

-define(fsm_test(Id,Title,CmdList),
  {Title,fun() -> [ eunit_fsm:translateCmd(Id,Cmd) || Cmd <- CmdList] end}).

together with eunit_fsm.erl

-module(eunit_fsm).
-export([translateCmd/2,get/2]).
-define(Expr(X),??X).
translateCmd(Id,{state,is,X}) ->
    case get(Id,"StateName") of
        X -> true;
        _V ->  .erlang:error({statename_match_failed,
                              [{module, ?MODULE},
                               {line, ?LINE},
                               {expected, X},
                               {value, _V}]})
    end;
translateCmd(_Id,{call,M,F,A,X}) ->
    case apply(M,F,A) of
        X -> ok;
        _V ->  .erlang:error({function_call_match_failed,
                              [{module, ?MODULE},
                               {line, ?LINE},
                               {expression, ?Expr(apply(M,F,A))},
                               {expected, X},
                               {value, _V}]})
    end;
translateCmd(Id,{loopdata,is,X}) ->
    case tl(tuple_to_list(get(Id,"StateData"))) of
        X    -> true;
        _V ->    .erlang:error({loopdata_match_failed,
                                [{module, ?MODULE},
                                 {line, ?LINE},
                                 {expected, X},
                                 {value, _V}]})
    end.

% StateName or StateData
get(Id,Which) ->
    {status,_Pid,_ModTpl, List} = sys:get_status(Id),
    AllData = lists:flatten([ X || {data,X} <- lists:last(List) ]),
    proplists:get_value(Which,AllData).

Now, we have a method of expressing the tests more clearly, next up is the buyer API. Testing if of the essence!

zen:EUnitFSM zenon$ tree .
.
├── ebin
├── include
│   └── eunit_fsm.hrl
├── src
│   └── tradepost.erl
└── test
    ├── eunit_fsm.erl
    └── tradepost_tests.erl

4 directories, 4 files
zen:EUnitFSM zenon$ 

Compile and run

zen:EUnitFSM zenon$ erlc -o ebin/ src/*.erl test/*.erl
zen:EUnitFSM zenon$ erl -pa ebin/ -eval 'eunit:test(tradepost,[verbose]).'
Erlang R13B04 (erts-5.7.5) [source] [64-bit] [smp:4:4] [rq:4]
[async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.7.5  (abort with ^G)
1> ======================== EUnit ========================
module 'tradepost'
  module 'tradepost_tests'
    tradepost_tests: started_properly (Started Properly Test)...[0.001 s] ok
    tradepost_tests: identify_seller (Identify Seller Test)...ok
    tradepost_tests: insert_item (Insert Item Test)...ok
    tradepost_tests: withdraw_item (Withdraw Item Test)...ok
    [done in 0.014 s]
  [done in 0.014 s]
=======================================================
  All 4 tests passed.

1>

And so, it works. Next part is adding buyer API and extending the DSL where needed, also maybe rewriting it with Ulf’s awesome parse transform libs.

Leave a comment

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

Follow

Get every new post delivered to your Inbox.

%d bloggers like this: