View Source Finitomata.ExUnit (Finitomata v0.29.0)
Helpers and assertions to make Finitomata
implementation easily testable.
Testing with Finitomata.ExUnit
There are several steps needed to enable extended testing with Finitomata.ExUnit
.
In the first place, mox
dependency should be included in your mix.exs
project file
{:mox, "~> 1.0", only: [:test]}
Then, the Finitomata
declaration should include a listener. If you already have the
listener, it should be changed to Mox
in :test
environment, and the respecive Mox
should be defined somewhere in test/support
or like
@listener (if Mix.env() == :test, do: MyFSM.Mox, else: MyFSM.Listener)
use Finitomata, fsm: @fsm, listener: @listener
# or
# use Finitomata, fsm: @fsm, listener: {:mox, MyFSM.Listener}
If you don’t have an actual listener, the special :mox
value for listener
would do
everything, including an actual Mox
declaration in test
environment.
use Finitomata, fsm: @fsm, listener: :mox
The last thing you need, import Mox
into your test file which also does
import Finitomata.ExUnit
. That’s it, now your code is ready to use Finitomata.ExUnit
fancy testing.
Example
Consider the following simple FSM
defmodule Turnstile do
@fsm ~S[
ready --> |on!| closed
opened --> |walk_in| closed
closed --> |coin_in| opened
closed --> |switch_off| switched_off
]
use Finitomata, fsm: @fsm, auto_terminate: true
@impl Finitomata
def on_transition(:opened, :walk_in, _payload, state) do
{:ok, :closed, update_in(state, [:data, :passengers], & &1 + 1)}
end
def on_transition(:closed, :coin_in, _payload, state) do
{:ok, :opened, state}
end
def on_transition(:closed, :off, _payload, state) do
{:ok, :switched_off, state}
end
end
Of course, in the real life, one would not only collect the total number of passengers passed in the state, but also validate the coin value to let in or fail a transition, but for the demonstration purposes this one is already good enough.
We now want to test it works as expected. Without Finitomata.ExUnit
, one would
write the test like below
# somewhere else → Mox.defmock(Turnstile.Mox, for: Finitomata.Listener)
test "standard approach" do
start_supervised(Finitomata.Supervisor)
fini_name = "Turnstile_1"
fsm_name = {:via, Registry, {Finitomata.Registry, fini_name}}
Finitomata.start_fsm(Turnstile, fini_name, %{data: %{passengers: 0}})
Finitomata.transition(fini_name, :coin_in)
assert %{data: %{passengers: 0}}} = Finitomata.state(Turnstile, "Turnstile_1", :payload)
Finitomata.transition(fini_name, :walk_in)
assert %{data: %{passengers: 1}}} = Finitomata.state(Turnstile, "Turnstile_1", :payload)
Finitomata.transition(fini_name, :switch_off)
Process.sleep(200)
refute Finitomata.alive?(Turnstile, "Turnstile_1")
end
At the first glance, there is nothing wrong with this approach, but it requires
an enormous boilerplate, it cannot check it’s gone without using Process.sleep/1
,
but most importantly, it does not allow testing intermediate states.
If the FSM has instant transitions (named with a trailing bang, like foo!
) which
are invoked automatically by Finitomata
itself, there is no way to test intermediate
states with the approach above.
OK, let’s use Mox
then (assuming Turnstile.Mox
has been declared and added
as a listener in test environment to use Finitomata
)
# somewhere else → Mox.defmock(Turnstile.Mox, for: Finitomata.Listener)
test "standard approach" do
start_supervised(Finitomata.Supervisor)
fini_name = "Turnstile_1"
fsm_name = {:via, Registry, {Finitomata.Registry, fini_name}}
parent = self()
Turnstile.Mox
|> allow(parent, fn -> GenServer.whereis(fsm_name) end)
|> expect(:after_transition, 4, fn id, state, payload ->
parent |> send({:on_transition, id, state, payload}) |> then(fn _ -> :ok end)
end)
Finitomata.start_fsm(Turnstile, fini_name, %{data: %{passengers: 0}})
Finitomata.transition(fini_name, :coin_in)
assert_receive {:on_transition, ^fsm_name, :opened, %{data: %{passengers: 0}}}
# assert %{data: %{passengers: 0}}} = Finitomata.state(Turnstile, "Turnstile_1", :payload)
Finitomata.transition(fini_name, :walk_in)
assert_receive {:on_transition, ^fsm_name, :closed, %{data: %{passengers: 1}}}
# assert %{data: %{passengers: 1}}} = Finitomata.state(Turnstile, "Turnstile_1", :payload)
Finitomata.transition(fini_name, :switch_off)
assert_receive {:on_transition, ^fsm_name, :switched_off, %{data: %{passengers: 1}}}
Process.sleep(200)
refute Finitomata.alive?(Turnstile, "Turnstile_1")
end
That looks better, but there is still too much of boilerplate. Let’s see how it’d look like
with Finitomata.ExUnit
.
describe "Turnstile" do
setup_finitomata do
parent = self()
initial_passengers = 42
[
fsm: [implementation: Turnstile, payload: %{data: %{passengers: initial_passengers}})],
context: [passengers: initial_passengers]
]
end
test_path "respectful passenger", %{passengers: initial_passengers} do
:coin_in ->
assert_state :opened do
assert_payload do
data.passengers ~> ^initial_passengers
end
end
:walk_in ->
assert_state :closed do
assert_payload do
data.passengers ~> one_more when one_more == 1 + initial_passengers
end
end
:switch_off ->
assert_state :switched_off
assert_state :*
end
With this approach, one could test the payload in the intermediate states, and validate
messages received from the FSM with assert_receive/3
.
No other code besides assert_state/2
, assert_payload/1
, and ExUnit.Assertions.assert_receive/3
is
permitted to fully isolate the FSM execution from side effects.
Custom environments
In the bigger application, it might be not convenient to declare mocks for
each and every case when Finitomata
/Infinitomata
might have been called under the hood.
For such cases, one might pass mox_envs: :finitomata
to an FSM declaration,
or set such a config options as config :finitomata, :mox_envs, :finitomata
. That would result
in mocks implemented for listener: :mox
in this environment(s) only.
Then the tests should have been split into two groups assuming the finitomata tests were generated with the mix task (see below)
mix test --exclude finitomata
MIX_ENV=finitomata mix test --exclude test --include finitomata
Don’t forget to add :finitomata
env to the list of envs where mox
is installed
Test Scaffold Generation
mix
tasks to simplify testing
One might generate the tests scaffold for all possible paths in the FSM with a mix
task
mix finitomata.generate.test --module MyApp.FSM
besides the mandatory --module ModuleWithUseFinitomata
argument, it also accepts
--dir
and --file
arguments (defaulted to test/finitomata
and
Macro.underscore(module) <> "_test.exs
) respectively.)
Summary
Functions
Asserts the payload within test_path/3
and assert_transition/3
.
Asserts the state within test_path/3
context.
Convenience macro to assert a transition initiated by event_payload
argument on the FSM defined by the test context previously setup
with a call to setup_finitomata/1
.
Convenience macro to assert a transition initiated by event_payload
argument on the FSM defined by first three arguments.
This macro initiates the FSM implementation specified by arguments passed.
Setups Finitomata
for testing in the case and/or in ExUnit.Case.describe/2
block.
Convenience macro to test the whole Finitomata path, from starting to ending state.
Functions
Asserts the payload within test_path/3
and assert_transition/3
.
assert_payload do
counter ~> 42
user.id ~> ^user_id # assuming `user_id` variable is in context
end
@spec assert_state( state :: Finitomata.Transition.state(), do_block :: [{:do, Macro.t()}] ) :: any()
Asserts the state within test_path/3
context.
Typically, one would assert the state and the payload within it as shown below
assert_state :idle do
assert_payload do
data.counter ~> value when is_integer(value)
data.listener ~> ^pid # assuming `pid` variable is in context
end
end
Convenience macro to assert a transition initiated by event_payload
argument on the FSM defined by the test context previously setup
with a call to setup_finitomata/1
.
Last regular argument in a call to assert_transition/3
would be an
event_payload
in a form of {event, payload}
, or just event
for no payload.
to_state
argument would be matched to the resulting state of the transition,
and block
accepts validation of the payload
after transition in a form of
test "some", ctx do
assert_transition ctx, {:increase, 1} do
:counted ->
assert_payload do
user_data.counter ~> 2
internals.pid ~> ^parent
end
# or: assert_payload %{user_data: %{counter: 2}, internals: %{pid: ^parent}}
assert_receive {:increased, 2}
end
end
Any matchers should be available on the right side of ~>
operator in the same way as the first
argument of match?/2
.
Each argument might be matched several times.
...
assert_payload do
user_data.counter ~> {:foo, _}
internals.pid ~> pid when is_pid(pid)
end
Convenience macro to assert a transition initiated by event_payload
argument on the FSM defined by first three arguments.
NB it’s not recommended to use low-level helpers, normally one should
define an FSM in setup_finitomata/1
block and use assert_transition/3
or even better test_path/3
.
parent = self()
assert_transition id, impl, name, {:increase, 1} do
:counted ->
assert_payload do
user_data.counter ~> 2
internals.pid ~> ^parent
end
# or: assert_payload %{user_data: %{counter: 2}, internals: %{pid: ^parent}}
assert_receive {:increased, 2}
end
See: assert_transition/3
for examples of matches and arguments
This macro initiates the FSM implementation specified by arguments passed.
NB it’s not recommended to use low-level helpers, normally one should
define an FSM in setup_finitomata/1
block, which would initiate
the FSM amongs other things.
Arguments:
id
— aFinitomata
instance, carrying multiple _FSM_simpl
— the module implementing FSM (havinguse Finitomata
clause)name
— the name of the FSMpayload
— the initial payload for this FSMoptions
— the options to control the test, such astransition_count
— the number of expectations to declare (defaults to number of states)
Once called, this macro will start Finitomata.Suprevisor
with the id
given,
define a mox for impl
unless already efined,
Mox.allow/3
the FSM to call testing process,
and expectations as a listener to after_transition/3
callback,
sending a message of a shape {:on_transition, id, state, payload}
to test process.
Then it’ll start FSM and ensure it has entered Finitomata.Transition.entry/2
state.
Setups Finitomata
for testing in the case and/or in ExUnit.Case.describe/2
block.
It would effectively init the FSM with an underlying call to init_finitomata/5
,
and put finitomata
key into context
, assigning :test_pid
subkey to the pid
of the running test process, and mixing :context
content into test context.
Although one might pass the name, it’s more convenient to avoid doing it, in this case the name would be assigned from the test name, which guarantees uniqueness of _FSM_s running in concurrent environment.
It should return the keyword which would be validated with NimbleOptions
schema
:fsm
(non-emptykeyword/0
) - Required. The FSM declaration to be used in tests.:id
(term/0
) - The ID of theFinitomata
tree. The default value isnil
.:implementation
- Required. The implementatoin ofFinitomata
(the module withuse Finitomata
.):name
(String.t/0
) - The name of theFinitomata
instance.:payload
(term/0
) - Required. The initial payload for the FSM to start with.:options
(keyword/0
) - Additional options to use in FSM initialization. The default value is[]
.:transition_count
(non_neg_integer/0
) - The expected byMox
number of transitions to handle.
:mocks
(list ofatom/0
) - Additional mocks to be passed to the test. The default value is[]
.:context
(keyword/0
) - The additional context to be passed to actualExUnit.Callbacks.setup/2
call. The default value is[]
.
Example:
describe "MyFSM tests" do
setup_finitomata do
parent = self()
[
fsm: [implementation: MyFSM, payload: %{}],
context: [parent: parent]
]
end
…
Convenience macro to test the whole Finitomata path, from starting to ending state.
Must be used with a setup_finitomata/1
callback.
Example:
test_path "The only path", %{finitomata: %{test_pid: parent}} do
{:start, self()} ->
assert_state :started do
assert_payload do
internals.counter ~> 1
pid ~> ^parent
end
assert_receive {:on_start, ^parent}
end
:do ->
assert_state :done do
assert_receive :on_do
end
assert_state :* do
assert_receive :on_end
end
end