Testing
Copy MarkdownAnubis components are plain modules with pure callbacks, so most of your test suite needs no running server, no transport, and no mocks. This guide covers unit testing components, testing server callbacks, and exercising the full HTTP stack when you want an integration check.
Testing tools
A tool's execute/2 takes validated params and a frame, and returns a tagged tuple. Call it directly:
defmodule MyApp.ProductSearchTest do
use ExUnit.Case, async: true
alias Anubis.Server.{Frame, Response}
test "returns matching products as json" do
frame = %Frame{}
assert {:reply, %Response{} = response, %Frame{}} =
MyApp.ProductSearch.execute(%{query: "lamp", limit: 10}, frame)
assert response.type == :tool
refute response.isError
assert [%{"type" => "text", "text" => text}] = response.content
assert text =~ "lamp"
end
test "reports a tool error when the catalog is down" do
frame = %Frame{}
assert {:reply, %Response{isError: true}, %Frame{}} =
MyApp.ProductSearch.execute(%{query: "trigger-outage", limit: 10}, frame)
end
endTwo details to keep in mind. First, pass params as an atom-keyed map with defaults already filled in, since that is what the validation layer hands to execute/2 at runtime. Second, assert on the Response struct fields: content is the list of content items, isError flags tool-level failures, and structured_content holds structured output if you set it.
When a test cares about the exact wire format instead of the struct, convert it:
assert %{"content" => [%{"type" => "text"}], "isError" => false} =
Response.to_protocol(response)Frames in tests
Components that read assigns get a frame built the same way the server builds one:
alias Anubis.Server.Frame
frame = Frame.assign(%Frame{}, %{user: %{id: 1, role: :admin}})
assert {:reply, response, _frame} = MyApp.SecureTool.execute(%{}, frame)For components that read request context, such as auth claims or headers, populate frame.context:
frame = %Frame{context: %Anubis.Server.Context{session_id: "test-session"}}Testing schemas
The schema block compiles into a JSON Schema exposed through input_schema/0. Asserting on it pins down the contract your clients see:
test "search schema requires a query" do
schema = MyApp.ProductSearch.input_schema()
assert "query" in schema["required"]
assert schema["properties"]["limit"]["type"] == "integer"
endThis catches accidental schema changes in review, the same way a changeset test catches a dropped validation.
Testing resources and prompts
The pattern is identical, only the callback differs:
test "config resource serves the current environment" do
assert {:reply, response, _frame} = MyApp.ConfigResource.read(%{}, %Frame{})
assert response.contents["text"] =~ "environment"
end
test "bug report prompt renders the title" do
params = %{title: "crash on save", severity: "high"}
assert {:reply, response, _frame} = MyApp.BugReportPrompt.get_messages(params, %Frame{})
assert [%{"role" => "user", "content" => content}] = response.messages
assert content =~ "crash on save"
endTesting server callbacks
Callbacks like init/2 and handle_info/2 are functions on your server module and test the same way:
test "init loads categories into assigns" do
assert {:ok, frame} = MyApp.Server.init(%{"name" => "test-client"}, %Frame{})
assert frame.assigns.allowed_categories != []
endIntegration testing over HTTP
Unit tests cover your logic; an integration test proves the whole pipe, from HTTP request through validation to your component and back. Start the server in the test and drive the plug with Plug.Test:
defmodule MyApp.ServerIntegrationTest do
use ExUnit.Case
import Plug.Conn
import Plug.Test
alias Anubis.Server.Transport.StreamableHTTP
@plug_opts StreamableHTTP.Plug.init(server: MyApp.Server)
setup do
start_supervised!({MyApp.Server, transport: {:streamable_http, start: true}})
:ok
end
defp post_mcp(body, headers \\ []) do
conn =
conn(:post, "/", JSON.encode!(body))
|> put_req_header("content-type", "application/json")
|> put_req_header("accept", "application/json, text/event-stream")
headers
|> Enum.reduce(conn, fn {k, v}, c -> put_req_header(c, k, v) end)
|> StreamableHTTP.Plug.call(@plug_opts)
end
test "initializes and calls a tool" do
init_conn =
post_mcp(%{
"jsonrpc" => "2.0",
"id" => 1,
"method" => "initialize",
"params" => %{
"protocolVersion" => "2025-06-18",
"clientInfo" => %{"name" => "test", "version" => "1.0.0"},
"capabilities" => %{}
}
})
assert init_conn.status == 200
[session_id] = get_resp_header(init_conn, "mcp-session-id")
post_mcp(
%{"jsonrpc" => "2.0", "method" => "notifications/initialized"},
[{"mcp-session-id", session_id}]
)
call_conn =
post_mcp(
%{
"jsonrpc" => "2.0",
"id" => 2,
"method" => "tools/call",
"params" => %{"name" => "product_search", "arguments" => %{"query" => "lamp"}}
},
[{"mcp-session-id", session_id}]
)
assert call_conn.status == 200
assert call_conn.resp_body =~ "lamp"
end
endThe start: true option forces the HTTP transport to boot even though no Phoenix endpoint is serving during tests. One integration test per server is usually enough; the per-component behavior belongs in the unit tests above.
Next steps
- Building a Server documents the callbacks tested here.
- Recipes includes error handling patterns worth covering in your suite.