Test plugins and events

v0.1.14

A plugin is a module with a call function, so some of its tests need no library at all. A test that goes through Hook.call/3, the registry or a lifecycle call needs the processes of Mishka Installer to be running, and the library does not start them in a test run by default. This guide shows how to test a plugin alone, how to find out what your build started, how to start the rest for one test or for the whole run, how to wait until it is ready, and how to start a plugin and call its event.

Before you begin

  • Add {:mishka_installer, "~> 0.1.14"} to the project, and know the event and the plugin you want to test. See Write a plugin for an event.
  • The examples use ExUnit and this plugin, which adds a greeting to a user map on the event after_register:
elixir
defmodule MyApp.Plugins.Greeting do
  use MishkaInstaller.Event.Hook, event: "after_register", queue: false

  @impl true
  def call(%{name: name} = user), do: {:reply, Map.put(user, :greeting, "Hello, #{name}")}
  def call(state), do: {:reply, state}
end

Test the plugin without the library

call/1 and call/2 are plain functions. Call them directly when you only need the plugin's own logic:

elixir
test "call/1 is a plain function" do
  assert MyApp.Plugins.Greeting.call(%{name: "Ada"}) ==
           {:reply, %{name: "Ada", greeting: "Hello, Ada"}}
end

This test needs no event, no Mnesia and no registration. The rest of this guide is for tests that go through the event.

What the library starts in a test run

The library chooses its processes when it compiles. If its environment is :test, its application starts only Phoenix.PubSub, named MishkaInstaller.PubSub. Its tables use RAM copies, and the periodic table check does not run. In any other environment it starts the PubSub and three more processes, in this order:

  1. MishkaInstaller.Installer.CompileHandler, the install queue and the replay of installed libraries on boot.
  2. MishkaInstaller.Event.EventHandler, the queue that builds the event modules.
  3. MishkaInstaller.MnesiaRepo, the store.

MishkaInstaller.Application.supervised_children/0 returns that list of three, or [] when only the PubSub runs. Print it in your test environment to see which case you are in.

Nothing raises when the three processes are missing. The registry does not exist, no plugin registers and no event module is built, so Hook.call/3 answers the data it was given. A test that expects the plugin to change the data fails on its assertion. A test that only checks that nothing broke passes without testing anything.

project_env versus MIX_ENV

The library reads its environment with MishkaInstaller.__information__().env. The first value that is set wins:

  1. The MIX_ENV variable of the operating system.
  2. config :mishka_installer, project_env: env.
  3. The Mix environment at the time the library compiled, or :prod when Mix is not loaded.

The library reads it twice. When it compiles, the answer picks the children of its application and the storage of its tables. At runtime, the answer names the default .mnesia/<env> folder and the default extensions path. The compile-time answer is part of the compiled library. Changing the variable or the setting later changes __information__/0 and leaves the children as they were, until the library compiles again.

These are the results of three runs of mix test in a host project that depends on mishka_installer from Hex:

How the library was compiled__information__().envStarted by the library
mix test, no project_env:prodThe PubSub and the three processes.
mix test, project_env: :dev:devThe PubSub and the three processes.
MIX_ENV=test mix test, with or without project_env:testThe PubSub only.

Mix compiles a dependency from Hex in the :prod environment, so a plain mix test gives the library :prod and its full tree. A MIX_ENV=test in the environment of the build reaches the dependency and wins over project_env. A shell, a Dockerfile or a CI job often sets it. The same code then starts the tree on one machine and not on another, and the build in _build keeps the answer of the run that made it. Delete _build/test/lib/mishka_installer to compile the library again.

So do not depend on either value to get a running tree in a test. Start what the library left out. The next two sections do it for one test and for the whole run, and both work whichever value the library got.

Start the processes for one test

This is the setup of the library's own tests. It works when supervised_children/0 returns [], since a second start of a process that already runs fails. Put it in the setup of every test module that calls events:

elixir
alias MishkaInstaller.Event.{Event, EventHandler, ModuleStateCompiler}
alias MishkaInstaller.MnesiaRepo

@event "after_register"

setup do
  :persistent_term.put(:compile_status, "ready")

  mnesia_dir =
    Path.join(System.tmp_dir!(), "my-app-mnesia-#{System.unique_integer([:positive])}")

  Application.put_env(:mishka_installer, MnesiaRepo,
    mnesia_dir: mnesia_dir,
    essential: [Event]
  )

  MishkaInstaller.subscribe("mnesia")
  MishkaInstaller.subscribe("event")

  start_supervised!(EventHandler)
  start_supervised!(MnesiaRepo)

  assert_receive %{status: :synchronized, channel: "mnesia"}, 5_000
  assert_receive %{status: :event_status, channel: "mnesia", data: "ready"}, 5_000

  on_exit(fn ->
    ModuleStateCompiler.purge(@event)
    File.rm_rf!(mnesia_dir)
  end)

  :ok
end

What each part does:

  • The compile flag. CompileHandler sets :compile_status to "ready" after it replays the installed libraries. The event handler waits for that flag before it starts the stored plugins. This test does not start CompileHandler, so it sets the flag itself.
  • A temporary Mnesia directory. MnesiaRepo stops the node's Mnesia, points it at mnesia_dir and creates a schema there, unless Mnesia already runs on that directory with the essential tables. A new directory for each test gives each test an empty registry. Remove it when the test ends.
  • The essential tables. essential: [Event] creates only the plugin registry. A plugin test needs nothing more. Leave the key out to create the install table too.
  • Subscribe first. MnesiaRepo broadcasts :synchronized while it starts, and the event handler broadcasts :event_status when it has started the events. A message sent before you subscribe is lost, so subscribe before you start the processes. The PubSub messages documentation lists every message.
  • The order. The event handler subscribes to the Mnesia messages when it starts, so it starts before MnesiaRepo. A test that replays installed libraries starts CompileHandler before both, and then needs no compile flag.
  • Two waits. :synchronized means the tables are ready. :event_status with "ready" means the event handler finished its boot pass: it unregistered the plugins whose code is gone and started every stored plugin. The ready signal can come again when the store announces itself again, for example after MnesiaRepo restarts. The documentation on the ready signal says when.
  • Purge the event module. The compiled module of an event is code loaded in the VM. It outlives your Mnesia directory, so a later test with an empty registry would still run the chain of this one on its first call. ModuleStateCompiler.purge/1 removes it.

start_supervised! ends the processes with the test.

Start a plugin and call its event

A plugin that calls use MishkaInstaller.Event.Hook is a GenServer with restart: :transient, so start_supervised! starts it and stops it with the test:

elixir
test "the plugin changes the data once it is started" do
  start_supervised!(MyApp.Plugins.Greeting)
  assert_receive %{status: :purge_create, channel: "event", data: @event}, 5_000

  assert Event.get(:name, MyApp.Plugins.Greeting).status == :started
  assert Hook.call(@event, %{name: "Ada"}) == %{name: "Ada", greeting: "Hello, Ada"}
end

start_supervised! returns when the process is up. The plugin registers and starts afterwards, in its own process. Wait for :purge_create with the name of the event: the library sends it after the module of the event is built.

Before a plugin registers, the event has no chain, and a call answers its input:

elixir
test "a call before the plugin registers returns its input" do
  assert Hook.call(@event, %{name: "Ada"}) == %{name: "Ada"}
end

Hook.call/3 runs the compiled module of the event when there is one, and returns the data when there is not. A test that asserts a plugin's effect should wait for the chain first, as above.

Wait for the rebuild with queue: false

A lifecycle call writes the registry and then asks the event handler to rebuild the chain. With the default queue: true the rebuild runs after the call returns, so the next Hook.call/3 can still see the old chain. A plugin declared with queue: false rebuilds the chain before its own start/0, stop/0, enable/0, restart/0 and unregister/0 return:

elixir
test "stop/0 takes the plugin out of the chain at once" do
  start_supervised!(MyApp.Plugins.Greeting)
  assert_receive %{status: :purge_create, channel: "event", data: @event}, 5_000

  assert {:ok, %{status: :stopped}} = MyApp.Plugins.Greeting.stop()
  assert Hook.call(@event, %{name: "Ada"}) == %{name: "Ada"}

  assert {:ok, %{status: :restarted}} = MyApp.Plugins.Greeting.enable()
  assert Hook.call(@event, %{name: "Ada"}) == %{name: "Ada", greeting: "Hello, Ada"}
end

If you do not want queue: false in the plugin's code, call the registry functions with false: Event.stop(:name, MyApp.Plugins.Greeting, false). See Manage plugins while the app runs.

Keep the processes for the whole run

An application can supervise the processes for the whole test run, as Mishka CMS does. It supervises whichever of the three the library left out, ahead of its own plugins, in every environment:

elixir
defmodule MyApp.Application do
  use Application

  @installer_children [
    MishkaInstaller.Installer.CompileHandler,
    MishkaInstaller.Event.EventHandler,
    MishkaInstaller.MnesiaRepo
  ]

  def installer_children,
    do: @installer_children -- MishkaInstaller.Application.supervised_children()

  @impl true
  def start(_type, _args) do
    children = installer_children() ++ [MyApp.Plugins.Greeting]
    Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
  end
end

Give the store a directory that is new on every run. The tables are disc copies unless the library compiled as :test, so an old directory would bring the plugin records of the last run back:

elixir
# config/test.exs
config :mishka_installer, MishkaInstaller.MnesiaRepo,
  mnesia_dir: Path.join(System.tmp_dir!(), "my_app-mnesia-#{System.system_time(:millisecond)}")

The application has started before test_helper.exs runs, so a test cannot subscribe in time. Wait on the state that the library keeps. Once the event handler finished its boot pass, :persistent_term holds :event_status as "ready", and nothing clears it. The documentation on telling that the library is ready compares this flag with the telemetry event and the message. Then wait for your plugin to appear in the chain of its event:

elixir
# test/test_helper.exs
defmodule MyApp.InstallerReady do
  alias MishkaInstaller.Event.ModuleStateCompiler

  def wait(plugin, event, timeout \\ 10_000) do
    deadline = System.monotonic_time(:millisecond) + timeout
    wait_until(plugin, event, deadline)
  end

  defp wait_until(plugin, event, deadline) do
    cond do
      ready?(plugin, event) ->
        :ok

      System.monotonic_time(:millisecond) > deadline ->
        raise "#{inspect(plugin)} never reached #{event}"

      true ->
        Process.sleep(50)
        wait_until(plugin, event, deadline)
    end
  end

  defp ready?(plugin, event) do
    module = ModuleStateCompiler.module_event_name(event)

    :persistent_term.get(:event_status, nil) == "ready" and Code.ensure_loaded?(module) and
      function_exported?(module, :initialize, 0) and
      Enum.any?(module.initialize().plugins, &(&1.name == plugin))
  end
end

MyApp.InstallerReady.wait(MyApp.Plugins.Greeting, "after_register")
ExUnit.start()

Every test now shares one registry. A plugin that a single test needs can still be started with start_supervised!. Remove its record when the test ends, so the next test starts without it:

elixir
start_supervised!(MyApp.Plugins.Farewell)
MyApp.InstallerReady.wait(MyApp.Plugins.Farewell, "after_delete")
on_exit(fn -> MishkaInstaller.Event.Event.unregister(:name, MyApp.Plugins.Farewell, false) end)

Run these tests with async: false

Every module that starts these processes, or calls an event whose plugins another module starts, must use use ExUnit.Case, async: false. ExUnit runs such modules one at a time. The reasons are all global:

  • The three processes register under their module names. A second start fails with {:error, {:already_started, pid}}.
  • There is one Mnesia for each node, and MnesiaRepo moves it to the directory of the test that started it.
  • The store is configured with Application.put_env/3, which every test sees.
  • :compile_status and :event_status live in :persistent_term.
  • The compiled module of an event is shared by every test that uses the event name.

Example: a complete test file

This file starts the two processes for each test, so it needs a library that started only its PubSub. It passes in the library's own repository, and in a host project that builds with MIX_ENV=test. In a host project built any other way, start_supervised! fails with already_started, and the whole-run setup above applies. The plugin is the one from the start of this guide:

elixir
defmodule MyApp.Plugins.GreetingTest do
  use ExUnit.Case, async: false

  alias MishkaInstaller.Event.{Event, EventHandler, Hook, ModuleStateCompiler}
  alias MishkaInstaller.MnesiaRepo
  alias MyApp.Plugins.Greeting

  @event "after_register"

  setup do
    :persistent_term.put(:compile_status, "ready")

    mnesia_dir =
      Path.join(System.tmp_dir!(), "my-app-mnesia-#{System.unique_integer([:positive])}")

    Application.put_env(:mishka_installer, MnesiaRepo,
      mnesia_dir: mnesia_dir,
      essential: [Event]
    )

    MishkaInstaller.subscribe("mnesia")
    MishkaInstaller.subscribe("event")

    start_supervised!(EventHandler)
    start_supervised!(MnesiaRepo)

    assert_receive %{status: :synchronized, channel: "mnesia"}, 5_000
    assert_receive %{status: :event_status, channel: "mnesia", data: "ready"}, 5_000

    on_exit(fn ->
      ModuleStateCompiler.purge(@event)
      File.rm_rf!(mnesia_dir)
    end)

    :ok
  end

  test "call/1 is a plain function" do
    assert Greeting.call(%{name: "Ada"}) == {:reply, %{name: "Ada", greeting: "Hello, Ada"}}
  end

  test "a call before the plugin registers returns its input" do
    assert Hook.call(@event, %{name: "Ada"}) == %{name: "Ada"}
  end

  test "the plugin changes the data once it is started" do
    start_supervised!(Greeting)
    assert_receive %{status: :purge_create, channel: "event", data: @event}, 5_000

    assert Event.get(:name, Greeting).status == :started
    assert Hook.call(@event, %{name: "Ada"}) == %{name: "Ada", greeting: "Hello, Ada"}
  end

  test "queue: false leaves the chain rebuilt when stop/0 returns" do
    start_supervised!(Greeting)
    assert_receive %{status: :purge_create, channel: "event", data: @event}, 5_000

    assert {:ok, %{status: :stopped}} = Greeting.stop()
    assert Hook.call(@event, %{name: "Ada"}) == %{name: "Ada"}

    assert {:ok, %{status: :restarted}} = Greeting.enable()
    assert Hook.call(@event, %{name: "Ada"}) == %{name: "Ada", greeting: "Hello, Ada"}
  end

  test "each test starts from an empty registry" do
    assert Event.get() == []
    assert Process.whereis(Greeting) == nil
  end
end

In a host project that supervises the processes itself, drop the setup and wait in test_helper.exs instead.