Mocks and Explicit Contracts in Nerves

If you are not super new to Elixir, you may have read this blog post by José Valim. If you haven’t read it, you may want to check it out. This post references it frequently.

Nerves puts a lot of focus into spending as much time developing your application on your host machine. This means you can rapidly develop your application, write tests, etc. When you feel it is ready you can then burn your firmware to a device and it will just work. This has an issue though.

Sometimes your application may interact with something in the real world that your development pc probably won’t have access to. Enter: a mock. Elixir makes this really easy to implement. You simply define a generic behaviour that an implementation must follow. This allows your host machine to have a single implementation that will always work, not work at all, throw an exception, etc.

Case study: ElixirALE

José’s post above had a small example on how one would “mock” an external API. We can do the same thing, but for Nerves.

Imagine you want to do a simple task: turn a light on. An Elixir application that can accomplish this is ElixirALE. It has a simple API. Eventually you will do something like:

# Start a GPIO GenServer
{:ok, pid} = ElixirAle.GPIO.start_link(18, :output)
# Turn the pin _ON_
:ok = GPIO.write(pid, 1)

And all is great. But you want to do your development on your PC right? Well the light isn’t connected to your PC at all? One common practice as José points out is to mock (the verb!) the GPIO GenServer.

mock(ElixirAle.GPIO, :start_link, to_return: {:ok, pid})
mock(ElixirAle.GPIO, :write, to_return: :ok)

But there is a better way! Let’s define a more usable way of setting this up. What do you really want to do? You don’t specifically want to toggle pin 18, you want to turn a light on. Lets write a behaviour for this.

defmodule MyApp.Light do

  @opaque private :: term

  @doc "initialize the light"
  @callback init(opts :: Keyword.t()) :: {:ok, private} | :error

  @doc "Callback to turn the light on"
  @callback on(private) :: :ok | :error

  @doc "Callback to turn the light off"
  @callback off(private) :: :ok | :error

  use GenServer

  @args Application.get_env(:my_app, __MODULE__)

  def on(pid \\ __MODULE__) do
    GenServer.cast(pid, :on)
  end

  def off(pid \\ __MODULE__) do
    GenServer.cast(pid, :off)
  end

  def start_link(args \\ @args, opts \\ [name: __MODULE__]) do
    GenServer.start_link(__MODULE__, args, opts)
  end

  def init(args) do
    impl = Keyword.fetch!(args, :implementation)
    {:ok, priv} = impl.init(args)
    {:ok, %{impl: impl, priv: priv}}
  end

  def handle_cast(:on, state) do
    :ok = state.impl.on(state.priv)
  end

  def handle_cast(:off, state) do
    :ok = state.impl.off(state.priv)
  end
end

The @callback lines are the important things here. This defines a @behaviour that new implementation can follow. Lets start with our development host implementation that simply logs the changes to the console.

defmodule MyApp.HostLightImpl do
  @behaviour MyApp.Light
  require Logger

  @impl MyApp.Light
  def init(_) do
    {:ok, :off}
  end

  @impl MyApp.Light
  def off(:off), do: :ok
  def off(:on) do
    Logger.debug "changing light from :on to :off"
    :ok
  end

  @impl MyApp.Light
  def on(:on), do: :ok
  def on(:off) do
    Logger.debug "changing light from :off to :on"
    :ok
  end
end

And finally, lets build the real implementation.

defmodule MyApp.ElixirAleLightImpl do
  @behaviour MyApp.Light
  require Logger

  @impl MyApp.Light
  def init(args) do
    pin = Keyword.fetch!(args, :pin)
    {:ok, pid} = ElixirAle.GPIO.start_link(pin, :output)
    {:ok, %{pid: pid, pin: pin, state: :off}}
  end

  @impl MyApp.Light
  def off(%{state: :off}), do: :ok
  def off(:on) do
    Logger.debug "changing light from :on to :off"
    ElixirAle.GPIO.write(pid, 1)
  end

  @impl MyApp.Light
  def on(%{state: :on}), do: :ok
  def on(:off) do
    Logger.debug "changing light from :off to :on"
    ElixirAle.GPIO.write(pid, 0)
  end
end

Conditional Implementation and Compilation

Now that there are two different implementations, you will need to decide how/when each of them are compiled and used. Deciding which one to use can be as simple as using Mix.Config.

use Mix.Config

case Mix.Project.config()[:target] do
  "host" ->
    config :my_app, MyApp.Light, [
      implementation: MyApp.HostLightImpl
    ]
  "rpi0" ->
    config :my_app, MyApp.Light, [
      implementation: MyApp.ElixirAleLightImpl,
      pin: 18
    ]
end

The last thing you may have issues with is loading the dependency. In your mix.exs file, you will probably have:

def deps("host") do
  []
end

def deps(_target) do
  [
    {:elixir_ale, "~> 1.0"}
  ]
end

This means the MyApp.ElixirAleLightImpl module will give compiler warnings or even errors when compiling on the host environment. What I like to do to solve this is create a directory called platform or similar in the root of my mix.exs and tweak the elixirc_paths option.

  def project() do
    [
      app: :my_app,
      target: @target
      # ...
      elixirc_paths: elixirc_paths(@target), # Add this line.
      # ...
    ]
  end

  def elixirc_paths("host"), do: ["lib", Path.join("platform", "host")]
  def elxiirc_paths(_target), do: ["lib", Path.join("platform", "target")]

Now you can store the MyApp.ElixirAleLightImpl file inside the platform/target dir, and MyApp.HostLightImpl in the platform/host dir.

Summing Up

You can keep your Nerves application concerns separated and clean by using Elixir @behaviours and explicit contracts. Something not discussed in depth in the short post is testing with Nerves. This deserves a post all in it’s own, but mocks and contracts as described here is a huge part of writing tests for Nerves devices.

comments powered by Disqus