Skip to content

Testing

ankka services are tested at two levels, and both are real.

  • Unit tests run one component with no actor system, no cluster, no database and no sidecar. A handler returns an effect, which is a plain value, so the test kit can apply it and show what it would have done. These run in milliseconds. Inputs, events, state and replies still pass through the component's own serialisers, so a type the codec cannot encode fails here rather than on the first deployment.
  • Integration tests start the whole service against a throwaway Postgres in Docker, and drive it through the component client or over HTTP. Restarting the service inside a test drops everything held in memory, so a test can prove that state was persisted rather than cached.

Docker is the only requirement for integration tests. No model API key is needed at either level: agents are tested against a scripted model.

Unit testing an entity in Scala

EventSourcedTestKit.of(companion, entityId) hosts one entity instance. call runs a handler and returns a result holding the events it persisted, its reply or its refusal:

val kit    = EventSourcedTestKit.of(ShoppingCartEntity, "cart-1")
val result = kit.call(ShoppingCartEntity.addItem)(LineItem("p1", "Widget", 2))

assertEquals(result.replyValue, Done)
assertEquals(result.events, Vector(ItemAdded(LineItem("p1", "Widget", 2))))
assertEquals(kit.currentState.totalQuantity, 2)
On the result Meaning
replyValue The reply; fails the test if the command was refused.
events The events this call persisted.
eventOfType[T] The one event of type T.
persisted Whether any event was persisted.
isError, error, errorMessage The refusal, with its ErrorCode.
retention A deletion or expiry the call requested.
On the kit Meaning
currentState The state after every call so far.
allEvents Every event persisted so far, in order.
isDeleted Whether the entity has been deleted.

A refused command persists nothing, and the kit shows exactly that: result.events is empty and the state is unchanged. KeyValueEntityTestKit.of(companion, entityId) does the same for a key value entity, with changed on the result instead of events:

val kit    = KeyValueEntityTestKit.of(ProfileEntity, "user-1")
val result = kit.call(ProfileEntity.register)(Profile("Ada", "ada@example.com", 1))
assert(result.changed)
assertEquals(kit.currentState, Profile("Ada", "ada@example.com", 1))

Workflows, views, consumers, timed actions and agents are tested in Scala through the integration test kit, because what matters about them — transitions and recovery, projection, delivery, the agent loop — is the runtime's behaviour.

Integration testing in Scala

AnkkaTestKit.start(descriptors, extensions) starts Postgres in Docker, applies the runtime's schema, and hosts the components with the given extensions. It returns once the service's node has joined its cluster, so a test's first call cannot race startup.

class ShoppingCartIntegrationSuite extends munit.FunSuite:
  private var testKit: AnkkaTestKit = null

  override def beforeAll(): Unit = testKit = AnkkaTestKit.start(ShoppingCartEntity.descriptor)
  override def afterAll(): Unit  = if testKit != null then testKit.stop()

  private def cart(id: String) = testKit.componentClient.forEventSourcedEntity(EntityId(id))

  test("state survives losing every entity from memory") {
    cart("c1").call(ShoppingCartEntity.addItem).invoke(LineItem("p1", "Widget", 5))
    val before = cart("c1").call(ShoppingCartEntity.getCart).invoke()

    testKit.restartService()

    assertEquals(cart("c1").call(ShoppingCartEntity.getCart).invoke(), before)
  }

restartService() stops the service and starts a new one against the same database, so every entity must rebuild itself from the journal. A test that passes across a restart has proved durability. One that does not restart has only proved that something was in memory.

The schema the test kit applies is the same one local development and the platform use, so a test cannot pass against a schema a deployment does not have.

HTTP in an integration test

Serve endpoints on a free loopback port, never the default 9000. A developer running a service locally while running the tests would otherwise see Address already in use:

val server  = HttpServer.at("127.0.0.1", 0)(clients => ShoppingCartEndpoint(clients.componentClient))
val testKit = AnkkaTestKit.start(Seq(ShoppingCartEntity.descriptor), Seq(server))
val baseUrl = s"http://127.0.0.1:${server.boundPort.get}"

Timers and projections in an integration test

Register the extension the component needs, as the service itself would: TimerRuntime for timed actions, ProjectionRuntime() for views and consumers. A shorter timer poll interval keeps a timer test fast, as in Timers. Views and consumers see changes after the write returns, so assert on them by retrying until the expected value appears, and retry on the value that changes rather than on the mere presence of a row.

Testing agents with a scripted model

TestModelProvider answers from a script, in order, and fails loudly when the script runs out. A test whose model quietly returned a default would no longer be testing what it says.

val model = TestModelProvider()
  .expectToolCall("get_weather", Json.obj("location" -> Json.str("Lisbon")))
  .expectText("Lisbon is 18C and sunny.")
Method Scripts
expectText(text) A plain reply.
expectToolCall(name, arguments) A turn in which the model calls one tool.
expectParallelToolCalls(calls*) A turn calling several tools at once.
expectRefusal(reason) The model declining.
whenUserSays(substring)(reply) A standing rule, used once the ordered script is exhausted.

requests, lastRequest and callCount show what the model was sent, so a test can assert that a tool result or a piece of context reached it. reset() clears the script between tests. Hand the provider to the runtime as its default model:

override def beforeAll(): Unit =
  testKit = AnkkaTestKit.start(
    Seq(
      PreferencesEntity.descriptor,
      PlannerWorkflow.descriptor,
      SelectorAgent.descriptor,
      WeatherAgent.descriptor,
      ActivityAgent.descriptor,
      BudgetAgent.descriptor,
      SummaryAgent.descriptor
    ) ++ AgentRuntime.descriptors,
    Seq(AgentRuntime.withDefaultModel(model))
  )

override def afterAll(): Unit = if testKit != null then testKit.stop()

override def beforeEach(context: BeforeEach): Unit = model.reset()

Assert on coordination and state, not on prose: which tools ran with which arguments, which agents contributed, what reached memory. Multi-agent orchestration shows a full example.

Two rules keep scripted-model tests honest:

  • One provider per consumer of it. Compaction runs asynchronously, as a consumer. If the agent and the summariser share one scripted provider, which of them takes the next scripted response is a race. Give each its own provider.
  • Let a workflow finish before the test ends. A workflow left mid-flight keeps taking responses from a shared script, starving the next test.

Unit testing in Python

ankka.testkit holds a unit test kit for every component kind, and none of them needs a sidecar. Calls take the handler's wire name:

from ankka.testkit import EventSourcedTestKit

kit = EventSourcedTestKit.of(ShoppingCartEntity, "c1")
assert kit.call("add-item", LineItem("p1", "Pen", 2)).events == (ItemAdded(LineItem("p1", "Pen", 2)),)
assert kit.call("get-cart").reply.items[0].name == "Pen"
Kit Drives
EventSourcedTestKit.of(Entity, id) commands; the result has events, reply, error, persisted, retention
KeyValueTestKit.of(Entity, id) commands on a key value entity
WorkflowTestKit.of(Workflow, id) call a command, run_step a step, run_until_end to follow transitions
ViewTestKit.of(View) on_change(key, event), on_delete(key), then get(key) for the row
ConsumerTestKit.of(Consumer) on_message(message, subject), on_delete(subject)
TimedActionTestKit.of(Action) call(name, input)
AgentTestKit.of(Agent, session, model) a handler plus the loop the sidecar would run, against a ScriptedModel
EndpointTestKit.of(Endpoint, *args) get, post, put, delete against the routes, returning a Response

A component's client calls are refused inside a unit test kit, because there is nothing to call. Test a component that calls others at the integration level.

A workflow's commands and steps can be run by hand:

def test_checkout_workflow_declares_its_recovery() -> None:
    kit = WorkflowTestKit.of(CheckoutWorkflow, "c1")
    started = kit.call("start", "fail")
    assert started.transition is not None and started.transition.step == "reserve"
    assert kit.state.status == "reserving" and kit.state.mode == "fail"
    assert kit.call("start", "ok").error is not None
    assert kit.run_step("compensate").next == End()
    assert kit.state.status == "compensated"
    settings = CheckoutWorkflow.to_component().workflow.settings
    assert {s.step: s.recovery.failover_to for s in settings.steps} == {"charge": "compensate"}

AgentTestKit runs the handler, then the loop the sidecar would run, against a ScriptedModel: tools run in-process with the scripted arguments, and guardrails are checked. ScriptedModel fails loudly when it runs out, like TestModelProvider:

def test_assistant_plans_and_the_tool_reads_the_cart() -> None:
    from ankka.testkit import AgentTestKit, ScriptedModel
    from examples.shopping_cart.assistant import CartAssistant

    model = ScriptedModel().expect_tool_call("lookup", {"cartId": "c9"}).expect_text("Your cart is empty.")
    answer = AgentTestKit.of(CartAssistant, "s1", model).call("ask", "what is in cart c9?")
    assert answer.plan.tool_names == ("lookup",) and answer.plan.guardrail_names == ("no-secrets",)
    assert answer.reply == "Your cart is empty."
    # The tool ran in this process — the unit testkit's client answers nothing, so it reports that.
    assert answer.tool_results and answer.tool_results[0].startswith("error:")

Integration testing in Python

ankka.testkit.integration.AnkkaTestKit starts Postgres and the real sidecar image in Docker, serves your components from the test process, and drives the routes through the sidecar with kit.http, an httpx client pointed at it. restart() replaces the sidecar against the same database, so the next read has to rebuild from the journal:

@pytest.mark.slow
async def test_cart_through_the_sidecar_survives_a_restart() -> None:
    service = Ankka.service().register(ShoppingCartEntity).register(ShoppingCartEndpoint)
    async with await AnkkaTestKit.start(service) as kit:
        assert (await kit.http.post("/carts/c1/items", json=PEN_JSON)).status_code == 204
        assert (await kit.http.post("/carts/c1/items", json=INK_JSON)).status_code == 204
        cart = (await kit.http.get("/carts/c1")).json()
        assert cart == {"cartId": "c1", "items": [PEN_JSON, INK_JSON], "checkedOut": False}
        assert (await kit.http.get("/carts/c1/total")).json() == 3
        # A refusal reaches the caller as its status.
        assert (await kit.http.post("/carts/c1/items", json={**PEN_JSON, "quantity": 0})).status_code == 400
        assert (await kit.http.delete("/carts/c1/items/nope")).status_code == 404

        await kit.restart()
        assert (await kit.http.get("/carts/c1")).json()["items"] == [PEN_JSON, INK_JSON]

        checked = (await kit.http.post("/carts/c1/checkout")).json()
        assert checked["checkedOut"] is True
        # Deleted after the checkout, as the Scala cart: the id is fresh again.
        assert (await kit.http.get("/carts/c1")).json() == {"cartId": "c1", "items": [], "checkedOut": False}

Mark such tests @pytest.mark.slow and run them with uv run pytest -m slow; uv run pytest runs the unit tests alone.

To test an agent through the real sidecar, script the sidecar's model with ANKKA_MODEL_SCRIPT, passed through env. The script is a JSON array of turns — {"text": ...}, {"tool": name, "arguments": {...}}, {"tools": [...]} for several at once, {"refusal": ...} — consumed in order, plus standing rules of the form {"when": "<substring of the user's message>", "text": ...} used once the turns run out:

SCRIPT = json.dumps(
    [
        {"tool": "lookup", "arguments": {"cartId": "a1"}},
        {"text": "Your cart holds 2 x Pen and 1 x Ink."},
        {"text": "Streamed answer here"},
        {"text": "the key is sk-000"},
    ]
)


@pytest.mark.slow
async def test_assistant_through_the_sidecar_with_a_scripted_model() -> None:
    """The loop runs in the sidecar against its scripted model; the tool runs here and reads the
    cart through the client; tokens stream back as SSE; the guardrail here blocks a leak."""
    async with await AnkkaTestKit.start(service(), env={"ANKKA_MODEL_SCRIPT": SCRIPT}) as kit:
        assert (await kit.http.post("/carts/a1/items", json=PEN_JSON)).status_code == 204
        assert (await kit.http.post("/carts/a1/items", json=INK_JSON)).status_code == 204
        # A str body and a str reply are text/plain, as a Scala endpoint's String is.
        asked = await kit.http.post("/carts/ask/s1", content="what is in cart a1?", headers={"content-type": "text/plain"})
        assert asked.status_code == 200, asked.text
        assert asked.text == "Your cart holds 2 x Pen and 1 x Ink."
        async with kit.http.stream("GET", "/carts/chat/s2?q=hello") as r:
            body = "".join([chunk async for chunk in r.aiter_text()])
        frames = [line[len("data:") :].strip() for line in body.splitlines() if line.startswith("data:")]
        assert [json.loads(f) for f in frames] == ["Streamed", " answer", " here"], f"body: {body!r}\n{kit.sidecar_logs()[-2500:]}"
        leaked = await kit.http.post("/carts/ask/s3", content="key?", headers={"content-type": "text/plain"})
        assert leaked.status_code == 403, leaked.text

ANKKA_MODEL_SCRIPT may also name a file holding the script. A sidecar with an ANTHROPIC_API_KEY uses the real model even when a script is set.