Search documentation of Finitomata

# View Source Finitomata.ExUnit (Finitomata v0.21.2)

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

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/2`, and `assert_receive/3` is permitted to fully isolate the FSM execution from side effects.

# Summary

## Functions

assert_payload(do_block)

Asserts the payload within `test_path/3` and `assert_transition/3`.

assert_state(state, do_block \\ [])

Asserts the state within `test_path/3` context.

assert_transition(ctx, event_payload, list)

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`.

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

Convenience macro to assert a transition initiated by `event_payload` argument on the FSM defined by first three arguments.

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

This macro initiates the FSM implementation specified by arguments passed.

setup_finitomata(list)

Setups `Finitomata` for testing in the case and/or in `ExUnit.Case.describe/2` block.

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

Convenience macro to test the whole Finitomata path, from starting to ending state.

# Functions

Link to this function

# assert_payload(do_block)

View Source

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

Link to this function

# assert_state(state, do_block \\ [])

View Source

    @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

Link to this macro

# assert_transition(ctx, event_payload, list)

View Source (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

Link to this macro

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

View Source (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

Link to this macro

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

View Source (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
  - `transition_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.

Link to this macro

# setup_finitomata(list)

View Source (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 `[]`.

    - `:transition_count` (`non_neg_integer/0`) - The expected by `Mox` number of transitions to handle.

- `:context` (`keyword/0`) - The additional context to be passed to actual `ExUnit.Callbacks.setup/2` call.

Example:

    describe "MyFSM tests" do
      setup_finitomata do
        parent = self()

        [
          fsm: [implementation: MyFSM, payload: %{}],
          context: [parent: parent]
        ]
      end

      …

Link to this macro

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

View Source (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