ExGram testing conveniences.
This module provides a unified interface for all testing utilities, delegating to
ExGram.Adapter.Test and ExGram.Updates.Test.
Overview
ExGram ships with a test adapter that intercepts Telegram API calls, making it easy to test bots without hitting real servers. The adapter supports per-process isolation for async tests and provides stub/expect/verify semantics similar to Mox.
Quick Start
Configure your test environment in config/test.exs:
config :ex_gram,
token: "test_token",
adapter: ExGram.Adapter.Test,
updates: ExGram.Updates.TestStart the adapter in test_helper.exs:
{:ok, _} = ExGram.Adapter.Test.start_link()
ExUnit.start()The recommended way to use this module is via use ExGram.Test in your test module.
This sets up set_from_context and verify_on_exit! automatically:
defmodule MyBotTest do
use ExUnit.Case, async: true
use ExGram.Test
setup context do
{bot_name, _} = ExGram.Test.start_bot(context, MyApp.Bot)
{:ok, bot_name: bot_name}
end
test "sends welcome message", %{bot_name: bot_name} do
ExGram.Test.expect(:send_message, %{message_id: 1, text: "Welcome!"})
ExGram.Test.push_update(bot_name, build_update("/start"))
end
enduse ExGram.Test
Calling use ExGram.Test in your test module registers a setup callback that:
- Calls
set_from_context/1to automatically pick private or global mode based on whether the test isasync: trueorasync: false. - Calls
verify_on_exit!/1so expectations are verified automatically when the test exits.
Options:
:set_from_context- whether to callset_from_context/1in setup (default:true):verify_on_exit- whether to callverify_on_exit!/1in setup (default:true)
Process isolation and start_bot/3
start_bot/3 creates an isolated, uniquely named bot for the current test. It also
ensures the bot's Dispatcher and Updates worker processes are immediately allowed to
use the test's stubs, without any manual allow/2 call.
This works by subscribing to the [:ex_gram, :bot, :init, :start] and
[:ex_gram, :updates, :init, :start] telemetry events emitted synchronously during process
startup. When those events fire, the processes are automatically allowed under the calling
test's ownership. The telemetry handler is scoped to the specific bot_name so concurrent
async tests never cross-allow each other's processes. The handler is detached automatically
via on_exit when the test exits.
Stubbing
Stubs define responses that persist for all matching calls:
# Static response
ExGram.Test.stub(:send_message, %{message_id: 1, text: "ok"})
# Dynamic response based on request body
ExGram.Test.stub(:send_message, fn body ->
{:ok, %{message_id: 1, chat_id: body["chat_id"], text: "ok"}}
end)
# Catch-all for all actions
ExGram.Test.stub(fn action, body ->
case action do
:send_message -> {:ok, %{message_id: 1, text: "ok"}}
:get_me -> {:ok, %{id: 1, is_bot: true}}
end
end)Expectations
Expectations are consumed after being called and can be verified:
# Consumed after 1 call
ExGram.Test.expect(:send_message, %{message_id: 1, text: "ok"})
# Consumed after N calls
ExGram.Test.expect(:send_message, 3, %{message_id: 1, text: "ok"})
# Verify all expectations were met
ExGram.Test.verify!()Testing Bots
Start an isolated bot instance for each test with start_bot/3, then push updates
using push_update/2. By default start_bot/3 sets handler_mode: :sync, so
push_update/2 only returns after the bot's handler has fully executed - no sleeps
or polling needed.
{bot_name, _} = ExGram.Test.start_bot(context, MyApp.Bot)
ExGram.Test.expect(:send_message, %{message_id: 1, text: "Welcome!"})
update = %ExGram.Model.Update{
update_id: 1,
message: %ExGram.Model.Message{
message_id: 100,
chat: %ExGram.Model.Chat{id: 123, type: "private"},
text: "/start"
}
}
# Blocks until the handler completes; expectation is consumed when this returns
ExGram.Test.push_update(bot_name, update)See the Testing guide for more examples and patterns.
Summary
Functions
Allow a spawned process to access the current test's stubs and expectations.
Clean the current process's stubs, expectations, and recorded calls.
Expect a catch-all response with a callback, consumed after 1 call.
Expect a response for a specific action, consumed after 1 call.
Expect a response for a specific action, consumed after N calls.
Get all recorded API calls as a list of {verb, action, body} tuples.
Push a test update to a bot's dispatcher.
Set the adapter to private or global mode depending on the current test context.
Set the adapter to global mode (shared stubs across all processes).
Set the adapter to private mode (per-process isolation).
Start an isolated bot instance for a test.
Stub a catch-all response with a callback that receives action and body.
Stub a response for a specific action or path.
Stub an error response for a specific action.
Verify that all expectations have been met and no unexpected calls were made.
Verify expectations for a specific process.
Register an ExUnit callback that automatically verifies expectations on test exit.
Functions
Allow a spawned process to access the current test's stubs and expectations.
Example
test "spawned process can use stubs" do
ExGram.Test.stub(:send_message, %{message_id: 1, text: "ok"})
task = Task.async(fn ->
ExGram.send_message(123, "From task")
end)
ExGram.Test.allow(self(), task.pid)
{:ok, msg} = Task.await(task)
assert msg.message_id == 1
end
Clean the current process's stubs, expectations, and recorded calls.
Useful in setup blocks if you need to reset state:
Example
setup do
ExGram.Test.clean()
:ok
end
Expect a catch-all response with a callback, consumed after 1 call.
Example
ExGram.Test.expect(fn action, body ->
assert action == :send_message
{:ok, %{message_id: 1, text: "ok"}}
end)
Expect a response for a specific action, consumed after 1 call.
Examples
ExGram.Test.expect(:send_message, %{message_id: 1, text: "ok"})
ExGram.Test.expect(:send_message, fn body ->
assert body[:text] == "Hello"
{:ok, %{message_id: 1, text: "ok"}}
end)
Expect a response for a specific action, consumed after N calls.
Example
ExGram.Test.expect(:send_message, 3, %{message_id: 1, text: "ok"})
Get all recorded API calls as a list of {verb, action, body} tuples.
Example
calls = ExGram.Test.get_calls()
assert length(calls) == 2
{verb, action, body} = hd(calls)
assert verb == :post
assert action == :send_message
assert body["chat_id"] == 123
Push a test update to a bot's dispatcher.
Simulates an incoming update from Telegram. The bot's processes are already
allowed to use the test's stubs from start_bot/3, so no additional allow/2
call is needed before calling this function.
When the bot was started with handler_mode: :sync (the default from start_bot/3),
this call blocks until the bot's handler has fully executed, including all API calls.
Expectations are consumed and calls are recorded by the time this function returns,
so you can assert on results immediately after.
When the bot was started with handler_mode: :async, the update is enqueued and
this function returns before the handler runs.
Example
test "bot responds to /start", %{bot_name: bot_name} do
ExGram.Test.expect(:send_message, fn body ->
assert body[:text] =~ "Welcome"
{:ok, %{message_id: 1, chat: %{id: 123, type: "private"}, text: "Welcome!"}}
end)
update = %ExGram.Model.Update{
update_id: 1,
message: %ExGram.Model.Message{
message_id: 100,
date: 1_700_000_000,
chat: %ExGram.Model.Chat{id: 123, type: "private"},
text: "/start"
}
}
# With handler_mode: :sync (default), the handler has run by the time this returns
ExGram.Test.push_update(bot_name, update)
end
Set the adapter to private or global mode depending on the current test context.
Set the adapter to global mode (shared stubs across all processes).
Only use this for synchronous tests. Prefer allow/2 for async tests.
Example
setup do
ExGram.Test.set_global()
on_exit(fn -> ExGram.Test.set_private() end)
end
Set the adapter to private mode (per-process isolation).
This is the default mode.
Start an isolated bot instance for a test.
Creates a uniquely named bot process derived from the test name so that multiple
tests can run concurrently without colliding. The bot is started under a unique
module name and registered under a unique atom (bot_name), which is also the
name passed to push_update/2.
Returns {bot_name, module_name}, where bot_name is the Dispatcher's registered
name (the process that handles all updates) and module_name is the Supervisor's
name (typically not needed, but useful for debugging or stopping the bot).
Process isolation
start_bot/3 automatically allows the bot's Dispatcher and Updates worker processes
to use the calling test's stubs and expectations. This is done by subscribing to the
[:ex_gram, :bot, :init, :start] and [:ex_gram, :updates, :init, :start] telemetry
events, which fire synchronously during startup. The telemetry handler is scoped to this
bot's name, so concurrent tests never accidentally allow each other's processes. The
handler is detached automatically on test exit.
No manual allow/2 call is needed for the standard push_update/2 workflow.
Options
The following options are merged on top of the test defaults:
:method- Updates source, defaults to:test(ExGram.Updates.Test):token- Bot token, defaults to"test_token":get_me- Whether to callget_meon startup, defaults tofalse(skips the API call):setup_commands- Whether to register commands on startup, defaults tofalse:handler_mode- How the dispatcher executes the handler.:sync(default) runs the handler inline sopush_update/2blocks until it completes.:asyncspawns a new process (the production default) and returns immediately.:extra_info- Map of extra data passed to the bot's context. The test process PID is always injected as:test_pid.
Example
setup context do
{bot_name, _} = ExGram.Test.start_bot(context, MyApp.Bot)
{:ok, bot_name: bot_name}
end
test "responds to /start", %{bot_name: bot_name} do
ExGram.Test.expect(:send_message, %{message_id: 1, text: "Welcome!"})
ExGram.Test.push_update(bot_name, build_update("/start"))
end
Stub a catch-all response with a callback that receives action and body.
Example
ExGram.Test.stub(fn action, body ->
case action do
:send_message -> {:ok, %{message_id: 1, text: "ok"}}
:get_me -> {:ok, %{id: 1, is_bot: true}}
end
end)
Stub a response for a specific action or path.
The response can be a static value (wrapped in {:ok, value}) or a callback
that receives the request body.
Examples
ExGram.Test.stub(:send_message, %{message_id: 1, text: "ok"})
ExGram.Test.stub(:send_message, fn body ->
{:ok, %{message_id: 1, chat_id: body["chat_id"], text: "ok"}}
end)
Stub an error response for a specific action.
Example
error = %ExGram.Error{code: 400, message: "Bad Request"}
ExGram.Test.stub_error(:send_message, error)
Verify that all expectations have been met and no unexpected calls were made.
Raises an ExUnit.AssertionError if:
- Any expectations remain unfulfilled
- Any unexpected calls were made (calls without a stub or expectation)
Example
ExGram.Test.expect(:send_message, %{message_id: 1, text: "ok"})
ExGram.send_message(123, "Hello")
ExGram.Test.verify!() # Passes
Verify expectations for a specific process.
Example
ExGram.Test.verify!(pid)
Register an ExUnit callback that automatically verifies expectations on test exit.
Use this in your test setup for automatic verification:
Example
defmodule MyBotTest do
use ExUnit.Case, async: true
import ExGram.Test, only: [verify_on_exit!: 1]
setup :verify_on_exit!
test "my test" do
ExGram.Test.expect(:send_message, %{message_id: 1})
# Test code...
end
end