Finitomata.ExUnit (Finitomata v0.40.0)

Copy Markdown View Source

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, :switch_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.)

Testing without Mox

If you’d rather not depend on Mox, declare the FSM with the built-in Finitomata.ExUnit.Listener instead of :mox:

use Finitomata, fsm: @fsm, listener: Finitomata.ExUnit.Listener
# or, test-only:
# @listener if Mix.env() == :test, do: Finitomata.ExUnit.Listener
# use Finitomata, fsm: @fsm, listener: @listener

Everything else (setup_finitomata/1, test_path/3, assert_transition/3, assert_state/2, assert_payload/1) works exactly the same — just drop import Mox. See Finitomata.ExUnit.Listener for details.

Matching the payload

assert_payload/1 matches the carried payload with the ~> operator, whose left-hand side is a (possibly nested) path resolved with get_in/2. The payload therefore has to implement the Access behaviour — the easiest way is to declare it with defstate/1 (which builds an Estructura.Nested), or to use a struct that derives Access. A plain map works out of the box.

Asserting failures

A failed transition (a soft event?, an {:error, _} from on_transition/4, or a raise) does not notify the listener, so there is nothing to assert_receive. Use assert_no_transition/3 to assert that the FSM stayed put and to inspect the error:

assert_no_transition ctx, :reject? do
  assert %Finitomata.Error{reason: :rejected} = last_error
  assert :started = state.current
end

Inside the block, state (the whole Finitomata.State.t/0), payload, and last_error are bound for plain assertions.

Timer transitions

In test_path/3, the :_ clause stands for a timer tick (Finitomata.timer_tick/2); the payload is read straight from the running FSM, so timer-driven on_timer/2 callbacks can be asserted just like regular transitions:

test_path "ticking", _ctx do
  :_ ->
    assert_state :processing do
      assert_payload do: processing ~> true
    end
end

Configurable timeouts

The assert_receive timeout used while awaiting transition notifications (default 1_000 ms), the refute_receive timeout used by assert_no_transition/3 (default 100 ms), and the message-flush timeout (default 100 ms) are all configurable:

config :finitomata,
  ex_unit_assert_receive_timeout: 5_000,
  ex_unit_refute_receive_timeout: 200,
  ex_unit_flush_timeout: 200

Property-based testing

event_generator/1 returns a StreamData generator over the FSM’s events, which can be combined with ExUnitProperties to fuzz event sequences and assert global invariants (for instance, that the FSM never crashes and always rests in a valid state).

Summary

Functions

Asserts that event_payload does not transition the FSM defined by the test context (set up with setup_finitomata/1), and exposes the resulting error for inspection.

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.

Returns a StreamData generator yielding the externally-triggerable events of the FSM implemented by impl, for property-based fuzzing of event sequences.

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

assert_no_transition(ctx, event_payload, list)

(macro)

Asserts that event_payload does not transition the FSM defined by the test context (set up with setup_finitomata/1), and exposes the resulting error for inspection.

A failed transition (a soft event?, an {:error, _} returned from on_transition/4, or a raise) does not notify the listener, so it cannot be awaited with assert_receive/3. This macro drives the event, asserts that no transition notification arrives (within Finitomata.ExUnit.refute_receive_timeout/0), and binds state (the whole Finitomata.State.t/0), payload, and last_error for plain assertions in the block:

test "rejects", ctx do
  assert_no_transition ctx, :reject? do
    assert %Finitomata.Error{reason: :rejected} = last_error
    assert :started = state.current
  end
end

assert_payload(do_block)

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

assert_state(state, do_block \\ [])

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

assert_transition(ctx, event_payload, list)

(macro)

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

assert_transition(id \\ nil, impl, name, event_payload, list)

(macro)
This macro is deprecated. Use `assert_transition/3` instead.

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

event_generator(impl)

Returns a StreamData generator yielding the externally-triggerable events of the FSM implemented by impl, for property-based fuzzing of event sequences.

The internal :__start__/:__end__ events are excluded.

property "never crashes" do
  check all events <- list_of(Finitomata.ExUnit.event_generator(MyFSM), min_length: 1) do
    # drive `events` against a running FSM and assert your invariants
  end
end

init_finitomata(id \\ nil, impl, name, payload, options \\ [], mocks \\ [])

(macro)
This macro is deprecated. Use `setup_finitomata/1` instead.

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 — a Finitomata instance, carrying multiple _FSM_s
  • impl — the module implementing FSM (having use Finitomata clause)
  • name — the name of the FSM
  • payload — the initial payload for this FSM
  • options — the options to control the test, such as

Once called, this macro will start Finitomata.Suprevisor with the id given and start the FSM, ensuring it has entered Finitomata.Transition.entry/2 state.

When the FSM uses listener: Finitomata.ExUnit.Listener the test process is registered to receive transition notifications directly (no Mox involved). Otherwise a mox for impl is defined (unless already defined), Mox.allow/3-ed to be called from the FSM, and stubbed/expected on after_transition/3 to send {:on_transition, id, state, payload} to the test process.

setup_finitomata(list)

(macro)

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-empty keyword/0) - Required. The FSM declaration to be used in tests.

    • :id (term/0) - The ID of the Finitomata tree. The default value is nil.

    • :implementation - Required. The implementatoin of Finitomata (the module with use Finitomata.)

    • :name (String.t/0) - The name of the Finitomata 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 [].

  • :mocks (list of atom/0) - Additional mocks to be passed to the test. The default value is [].

  • :context (keyword/0) - The additional context to be passed to actual ExUnit.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

  

test_path(test_name, ctx \\ quote do _ end, list)

(macro)

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