Twilio.Test provides process-scoped HTTP stubs via NimbleOwnership, so your tests can run with async: true without interference.

Setup

Start the test stub server in your test/test_helper.exs:

Twilio.Test.start()
ExUnit.start()

Stubbing Requests

Use Twilio.Test.stub/1 to define how the HTTP layer responds. The stub function receives (method, url, headers, body) and returns a {status, headers, body} tuple:

defmodule MyApp.NotifierTest do
  use ExUnit.Case, async: true

  test "sends an SMS" do
    Twilio.Test.stub(fn _method, _url, _headers, _body ->
      {201, [], ~s({"sid": "SMxxx", "status": "queued", "body": "Hello!"})}
    end)

    client = Twilio.client("ACtest", "token")

    {:ok, msg} = Twilio.Api.V2010.MessageService.create(client, %{
      "To" => "+15551234567",
      "From" => "+15559876543",
      "Body" => "Hello!"
    })

    assert msg.sid == "SMxxx"
    assert msg.status == "queued"
  end
end

Asserting on Request Parameters

The stub receives the full request, so you can assert on the URL, body, headers, or method:

test "sends correct params" do
  Twilio.Test.stub(fn method, url, _headers, body ->
    assert method == "POST"
    assert url =~ "/Messages.json"

    params = URI.decode_query(body)
    assert params["To"] == "+15551234567"
    assert params["Body"] == "Hello!"

    {201, [], ~s({"sid": "SMxxx"})}
  end)

  client = Twilio.client("ACtest", "token")
  {:ok, _} = Twilio.Api.V2010.MessageService.create(client, %{
    "To" => "+15551234567",
    "Body" => "Hello!"
  })
end

Simulating Errors

Return non-200 status codes to test error handling:

test "handles invalid number" do
  Twilio.Test.stub(fn _method, _url, _headers, _body ->
    {400, [],
     ~s({"code": 21211, "message": "The 'To' number is not a valid phone number.", "status": 400})}
  end)

  client = Twilio.client("ACtest", "token")
  {:error, err} = Twilio.Api.V2010.MessageService.create(client, %{
    "To" => "not-a-number",
    "From" => "+15559876543",
    "Body" => "Test"
  })

  assert err.code == 21211
  assert err.message =~ "not a valid phone number"
end

Testing Pagination

Stub multiple pages to test auto-paging behavior:

test "streams through pages" do
  call_count = :counters.new(1, [:atomics])

  Twilio.Test.stub(fn _method, url, _headers, _body ->
    count = :counters.get(call_count, 1)
    :counters.add(call_count, 1, 1)

    if count == 0 do
      {200, [], ~s({
        "messages": [{"sid": "SM001"}, {"sid": "SM002"}],
        "meta": {
          "key": "messages",
          "next_page_url": "#{url}?Page=1",
          "page": 0,
          "page_size": 2
        }
      })}
    else
      {200, [], ~s({
        "messages": [{"sid": "SM003"}],
        "meta": {
          "key": "messages",
          "next_page_url": null,
          "page": 1,
          "page_size": 2
        }
      })}
    end
  end)

  client = Twilio.client("ACtest", "token")
  items = client
  |> Twilio.Messaging.V1.MessageService.stream()
  |> Enum.to_list()

  assert length(items) == 3
end

Process Isolation

Stubs are scoped to the test process that defines them. This means:

  • async: true works — concurrent tests don't interfere with each other
  • No shared state — each test sets up its own stubs independently
  • Automatic cleanup — stubs are removed when the test process exits

Under the hood, Twilio.Test uses NimbleOwnership to associate stubs with the calling process. If your test spawns child processes that make Twilio calls, you can allow them to share the parent's stubs:

test "works in spawned processes" do
  Twilio.Test.stub(fn _method, _url, _headers, _body ->
    {200, [], ~s({"sid": "SMxxx", "status": "sent"})}
  end)

  task = Task.async(fn ->
    Twilio.Test.allow(self())
    client = Twilio.client("ACtest", "token")
    Twilio.Api.V2010.MessageService.fetch(client, "SMxxx")
  end)

  assert {:ok, msg} = Task.await(task)
  assert msg.sid == "SMxxx"
end

Testing Webhooks

Twilio.Webhook functions are pure — they don't make HTTP calls, so test them directly without stubs:

test "validates webhook signature" do
  auth_token = "12345"
  url = "https://myapp.com/twilio/webhook"
  params = %{"CallSid" => "CA123", "From" => "+14158675310"}

  signature = Twilio.Webhook.build_signature(url, params, auth_token)

  assert Twilio.Webhook.valid?(url, params, signature, auth_token)
  refute Twilio.Webhook.valid?(url, params, "wrong_signature", auth_token)
end

Testing TwiML

TwiML builders are also pure functions — assert on the generated XML:

test "generates voice response" do
  xml = Twilio.TwiML.VoiceResponse.new()
  |> Twilio.TwiML.VoiceResponse.say("Hello!", voice: "alice")
  |> Twilio.TwiML.VoiceResponse.to_xml()

  assert xml =~ ~s(<Say voice="alice">Hello!</Say>)
  assert xml =~ "<Response>"
end

Tips

  • Keep stubs minimal. Only include the fields your test actually checks. The deserializer handles missing fields gracefully.
  • Use async: true. The ownership model is designed for it.
  • Don't stub webhooks or TwiML. They are pure functions that don't make HTTP calls.
  • Stub the response format correctly. Twilio returns JSON with snake_case keys (e.g. "account_sid", "date_created").