Examples
Module mocks via hooks, callbacks, and asserts
defmodule Channel do
use Hook # 1: import hook/1 macro
def events_from_database(), do: # ...
def flush() do
:ok =
events_from_database()
|> Enum.map(&encode/1)
|> Enum.join()
# 2: hook a term
|> hook(Mqtt).publish()
end
def submit(payload), do: # ...
end
defmodule ChannelTest do
test "submitted events are published as single message" do
payload_1 = "1"
payload_2 = "2"
payload = Enum.join([payload_1, payload_2]
# 3: define a callback
Hook.callback(Mqtt, :publish, fn ^payload -> :ok end)
:ok = Channel.submit(event_1)
:ok = Channel.submit(event_2)
:ok = Channel.flush()
# 4: check for unresolved callbacks
Hook.assert()
end
end
Exceptions
Omitting #3 above would cause:
** (RuntimeError) Hook: failed to resolve a mapping for Mqtt
(hook 0.1.0) lib/hook/server.ex:210: Hook.Server.raise_no_mapping_found/1
This is because the hook created at #2 does not Mqtt
to another value, rather, it only allows
the resolution of a mapping for Mqtt
to be delayed until runtime. The callback at #3 is actually
what ensures a mapping exists for Mqtt
that will result in function calls on it being traced so
that assertions may be made.
Altering #3 so that the function name :publish
was something else, say :publish2
, would cause:
** (RuntimeError) Hook: failed to resolve a Mqtt.publish/1 callback for #PID<0.225.0>
(hook 0.1.0) lib/hook/callbacks.ex:111: Hook.Callbacks.raise_no_callback_found/4
(hook 0.1.0) lib/hook/server.ex:142: Hook.Server.handle_call/3
We no longer get the previous exception because a mapping does exist for Mqtt, we did define a callback for it, but the callback we defined did not match what the code was calling.
Note: This exception helps find bugs in the code you are testing that would cause it to call a function on a hooked module more times than you have defined callbacks for it to resolve.
A bug in your code that caused the callback to never be resolved would cause:
** (RuntimeError) Hook: unresolved callbacks for #PID<0.672.0>: {1, Mqtt.publish/1}
Either you are defining more callbacks than intended or you're not calling the function in question as many times as intended.
Callbacks
Leverage multiple clauses.
Hook.callback(System, :cmd, fn
"joy", ["--flag"] -> {"", 0}
"joy", args -> {"error: --flag required", 1}
end)
Define a callback multiple times like this.
Hook.callback(System, :cmd, fn cmd, args -> {"joy", 0} end, count: 2)
Or separately.
Hook.callback(System, :cmd, fn cmd, args -> {"joy", 0} end)
Hook.callback(System, :cmd, fn cmd, args -> {"joy", 0} end)
Either of those examples could be consumed twice. :count
may also be passed :infinity
which is
a special case.
Infinity and beyond
Callbacks with an infinity count can be consumed infinitely. Only one infinity count can be defined per module and function at a time. The most recent definition persists. Infinity callbacks are only consumed once all other non-infinity callbacks are exhausted.
Assertions
Hook.callback(System, :cmd, fn _, _ -> {"joy", 0} end)
Hook.assert()
Without the assert line, the above snippet would execute without fail. Adding the assert line causes the following exception.
** (RuntimeError) Hook: unresolved callbacks for #PID<0.672.0>: {1, Mqtt.publish/1}
Module resolution
This is a simple example to demonstrate a common pattern of wiring things together. The
App.MqttMock
could have a much more complex implementation.
defmodule App.MqttMock do
require Logger
def publish(payload) do
Logger.debug("published #{inspect payload}")
:ok
end
end
defmodule App.Channel do
# 1: import hook/1 macro
use Hook
def events_from_database(), do: # ...
def submit(payload), do: # ...
def flush() do
:ok =
events_from_database()
|> Enum.map(&encode/1)
|> Enum.join()
# 2: hook a term
|> hook(Mqtt).publish()
end
end
defmodule ChannelTest do
test "flushing multiple events" do
Hook.put(Mqtt, App.MqttMock)
:ok = Channel.submit("1")
:ok = Channel.submit("2")
assert :ok = Channel.flush()
end
end