A watch you have never seen fire is a watch you are guessing about. Kepler is built to be tested directly: you can control the clock, control the tick, and receive events in the test process.
The setup
Start a Kepler per test with explicit configuration, a callback sink pointed at the test process, and a tick long enough that it never fires on its own.
defmodule MyApp.WatchesTest do
use ExUnit.Case, async: false
setup do
start_supervised!(
{Kepler.Supervisor,
watches: MyApp.Watches,
tick: 60_000,
sinks: [test: {Kepler.Sink.Callback, handler: &send(self(), {:kepler, &1})}]}
)
:ok
end
endThree things are doing work here:
start_supervised!ties Kepler's lifetime to the test, so each test gets a clean install with fresh counters.tick: 60_000means the only evaluation that happens is the one you ask for withKepler.tick/0. No sleeping, no polling, no flakiness.- The callback sink delivers the event to your test process, so
assert_receiveis all you need.
async: false is required. Kepler's processes are named, so only one runs per
node, and telemetry handler attachment is global.
Your application's Kepler is in the way
If :kepler is configured in config/test.exs, the application-started tree
already holds the named processes and start_supervised! will fail with
:already_started. Stop it once, in test/test_helper.exs:
Application.stop(:kepler)
ExUnit.start()Alternatively set config :kepler, enabled: false in config/test.exs — but
that still occupies the process names, so stopping the application is the
cleaner option when you want per-test installs.
Testing a telemetry watch
test "checkout latency fires when p99 crosses the threshold" do
for _i <- 1..50 do
:telemetry.execute(
[:my_app, :checkout, :stop],
%{duration: System.convert_time_unit(3_000, :millisecond, :native)},
%{}
)
end
Kepler.tick()
assert_receive {:kepler, %Kepler.Event{watch: :checkout_latency, state: :firing} = event}
assert event.context.measurement.value >= 3_000
assert event.context.measurement.unit == :millisecond
endEmit through :telemetry.execute/3, exactly as your application does — that
exercises the real handler, the real counters, and the real unit conversion.
If your code emits the event through a wrapper, call the wrapper.
Assert the negative too. It is the assertion that catches a threshold you got backwards:
test "stays quiet below the threshold" do
for _i <- 1..50 do
:telemetry.execute([:my_app, :checkout, :stop], %{duration: 5}, %{})
end
Kepler.tick()
refute_receive {:kepler, %Kepler.Event{watch: :checkout_latency}}, 100
endTesting a process watch
test "the export backlog fires when the queue builds up" do
worker = spawn(fn -> Process.sleep(:infinity) end)
Process.register(worker, MyApp.ExportWorker)
on_exit(fn -> await_exit(worker) end)
for _i <- 1..5, do: send(MyApp.ExportWorker, :work)
Kepler.tick()
assert_receive {:kepler, %Kepler.Event{watch: :export_backlog}}
end
defp await_exit(pid) do
ref = Process.monitor(pid)
Process.exit(pid, :kill)
assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, 1_000
endA process that never receives accumulates a mailbox you can measure. Wait for
it to actually die in teardown — Process.exit/2 is asynchronous and the
registered name is only freed once the process is gone, so the next test will
race it otherwise.
Testing sustained:
sustained: is measured against a monotonic clock, so a watch declared with
sustained: :timer.seconds(30) genuinely takes thirty seconds of ticks to fire.
Do not sleep for it. Test the two halves separately:
Test the state machine directly. Kepler.Trigger is pure, and the clock is
an argument:
test "holds for the full window before firing" do
opts = [sustained: 30_000, cooldown: 0, notify_resolved: false]
assert {:none, t} = Kepler.Trigger.step(Kepler.Trigger.new(), true, 0, opts)
assert {:none, t} = Kepler.Trigger.step(t, true, 29_999, opts)
assert {:fire, _t} = Kepler.Trigger.step(t, true, 30_000, opts)
endTest that your condition is right by asserting the watch reaches
:pending:
test "the condition becomes true" do
emit_slow_checkouts()
Kepler.tick()
latency = Enum.find(Kepler.status().watches, &(&1.name == :checkout_latency))
assert latency.state == :pending
endTogether those cover the whole behaviour without a clock in the test.
Inspecting rather than receiving
Kepler.status/0 reports every watch's current state and value, which is often
a better assertion than an event — it tells you why nothing fired.
test "the watch is seeing data at all" do
emit_some_events()
Kepler.tick()
backlog = Enum.find(Kepler.status().watches, &(&1.name == :export_backlog))
assert backlog.value != nil, "the source produced no data"
assert backlog.state == :idle
endA value of nil means the source produced nothing — usually a process that
is not registered under the name you declared, or a telemetry event name that
does not match what your application emits. That is the most common reason a
watch does not fire, and it is invisible from refute_receive alone.
Waiting for delivery
Sinks run in supervised tasks, so delivery is asynchronous. assert_receive
handles that for a callback sink. When you need every delivery finished — for
example before asserting on Kepler.status().emitter — use Kepler.drain/1:
Kepler.tick()
assert Kepler.drain() == :ok
assert Kepler.status().emitter.dropped == 0It returns {:error, :timeout} rather than raising if the queue does not
drain, because a wedged sink is a normal thing for Kepler to survive.
Testing a custom sink
Sinks are plain modules with two callbacks, so test them without Kepler running:
test "pages on a severity: :page event" do
{:ok, state} = MyApp.PagerSink.init(routing_key: "test")
event =
Kepler.Event.new(
Kepler.Watch.new(
name: :checkout_latency,
source: {:telemetry, [:my_app, :checkout, :stop]},
tier: 1,
measure: %{key: :duration, aggregate: {:percentile, 99}},
severity: :critical
),
:firing,
%{value: 2_431, prev: 1_980, delta: 451, rate: 451.0, window_ms: 1_000}
)
assert MyApp.PagerSink.deliver(event, state) == :ok
endIf your sink implements format/1, test the two halves separately — that is the
point of the split:
payload = MyApp.PagerSink.format(event)
assert payload.severity == "critical"
assert MyApp.PagerSink.deliver(payload, state) == :okFor the webhook sink, implement a Kepler.Transport that records instead of
sending, and pass it as transport:. That covers your headers, signing, and
payload without a socket.
Testing a crash watch
Crash a real process. GenServer.start/2 rather than start_link/2, so the
crash does not take the test with it:
defmodule CrashingWorker do
use GenServer
def start(opts \\ []), do: GenServer.start(__MODULE__, opts)
@impl true
def init(_opts) do
Logger.metadata(request_id: "req-42")
{:ok, %{pending: 3}}
end
@impl true
def handle_cast(:boom, _state), do: raise("kaboom")
end
test "a crash carries the process's state and the request that caused it" do
{:ok, pid} = CrashingWorker.start()
ref = Process.monitor(pid)
capture_log(fn ->
GenServer.cast(pid, :boom)
assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, 2_000
end)
assert_receive {:kepler, %Kepler.Event{watch: :process_crash} = event}, 2_000
assert event.context.enriched.process_state == %{pending: 3}
assert event.context.enriched.last_message == {:"$gen_cast", :boom}
assert event.context.enriched.request_context.request_id == "req-42"
assert %RuntimeError{} = event.context.enriched.reason
endcapture_log/1 keeps the crash report out of your test output. It does not stop
Kepler seeing it — Kepler has its own :logger handler.
One crash produces three OTP reports, so assert that it fires once:
assert_receive {:kepler, %Kepler.Event{watch: :process_crash}}, 2_000
refute_receive {:kepler, %Kepler.Event{watch: :process_crash}}, 200If a supervisor_report: watch never fires in your tests, check
config :logger, handle_sasl_reports: true — see
crash attribution.
Testing a system monitor watch
Send the monitor message directly rather than trying to provoke the VM into a long garbage collection:
test "a long GC fires" do
send(Kepler.SystemMonitor, {:monitor, self(), :long_gc, [timeout: 900]})
assert_receive {:kepler, %Kepler.Event{watch: :long_gc} = event}
assert event.context.tier == 0
assert event.context.detail[:timeout] == 900
endThis exercises the real routing, including per-watch threshold matching.
Restore the node's system monitor in teardown — there is only one, and taking it changes global state for the rest of the run:
setup do
previous = :erlang.system_monitor()
on_exit(fn -> restore(previous) end)
:ok
endWatching Kepler itself
Kepler emits its own telemetry, which is useful in tests and in production:
| Event | Measurements | Metadata |
|---|---|---|
[:kepler, :tick] | :duration_us, :watches, :events | :share, :level |
[:kepler, :delivery, :stop] | :duration | :watch, :sink, :module, :result |
[:kepler, :event, :dropped] | :count | :watch, :severity, :reason |
:telemetry.attach("test", [:kepler, :event, :dropped], fn _e, m, meta, pid ->
send(pid, {:dropped, m, meta})
end, self())Compile-time failures need no test
Unknown measurements, unknown identifiers in conditions, duplicate watch names, and cycles between conditions are all build failures. You do not need tests for them — you cannot ship a module containing them.
If you are writing a library that generates Kepler declarations and want to
assert on those failures, Kepler.CompileError is the exception, and
Code.compile_string/2 is the way to trigger it.