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
endAsserting 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!"
})
endSimulating 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"
endTesting 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
endProcess Isolation
Stubs are scoped to the test process that defines them. This means:
async: trueworks — 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"
endTesting 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)
endTesting 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>"
endTips
- 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_casekeys (e.g."account_sid","date_created").