ExGram.Test (ex_gram v0.64.0)

Copy Markdown View Source

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.Test

Start 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
end

use ExGram.Test

Calling use ExGram.Test in your test module registers a setup callback that:

  • Calls set_from_context/1 to automatically pick private or global mode based on whether the test is async: true or async: false.
  • Calls verify_on_exit!/1 so expectations are verified automatically when the test exits.

Options:

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(owner_pid, allowed_pid)

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()

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(callback)

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(action, response)

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(action, n, response)

Expect a response for a specific action, consumed after N calls.

Example

ExGram.Test.expect(:send_message, 3, %{message_id: 1, text: "ok"})

get_calls()

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

handle_event_allow_pid(list, measurements, metadata, map)

push_update(bot_name, update)

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_from_context(context)

Set the adapter to private or global mode depending on the current test context.

set_global()

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_private()

Set the adapter to private mode (per-process isolation).

This is the default mode.

start_bot(context, bot_module, opts \\ [])

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 call get_me on startup, defaults to false (skips the API call)
  • :setup_commands - Whether to register commands on startup, defaults to false
  • :handler_mode - How the dispatcher executes the handler. :sync (default) runs the handler inline so push_update/2 blocks until it completes. :async spawns 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(callback)

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(action, response)

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_error(action, error)

Stub an error response for a specific action.

Example

error = %ExGram.Error{code: 400, message: "Bad Request"}
ExGram.Test.stub_error(:send_message, error)

unique_name_from_context(prefix, context)

verify!()

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!(pid)

Verify expectations for a specific process.

Example

ExGram.Test.verify!(pid)

verify_on_exit!(context)

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