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.