Testing Twilio SMS and Voice APIs: Sandbox, Recording, and Failure Simulation
Twilio provides test credentials that accept API calls without sending real messages, specific magic phone numbers for simulating delivery failures, and a webhook testing workflow for voice and SMS. This guide covers unit testing Twilio integrations with mocks, integration testing with test credentials, and testing TwiML responses.
Twilio integrations fail in ways that are hard to catch without testing: messages that look sent but weren't delivered, voice calls that drop mid-flow, webhooks that arrive out of order. Testing prevents these failures from becoming production incidents.
Test Credentials vs Real Credentials
Twilio provides two types of test credentials:
Test credentials (AC test Account SID + test Auth Token): Accept API calls, validate your code, but don't actually send messages or make calls. Use these for unit and integration tests.
Real credentials with magic numbers: Use your real Account SID and Auth Token, but Twilio recognizes specific test phone numbers and simulates behaviors like failure without charging you.
# conftest.py
import os
import pytest
@pytest.fixture
def twilio_test_client():
"""Returns a Twilio client using test credentials."""
from twilio.rest import Client
account_sid = os.environ.get("TWILIO_TEST_ACCOUNT_SID", "ACtest123")
auth_token = os.environ.get("TWILIO_TEST_AUTH_TOKEN", "test_token_123")
# Verify test credentials are being used
assert account_sid.startswith("AC"), "Must use valid Account SID"
return Client(account_sid, auth_token)Unit Testing with Mocks
For fast CI tests that don't hit Twilio at all, mock the Twilio client:
from unittest.mock import patch, MagicMock
from app.notifications import send_sms_notification
def test_sms_notification_calls_twilio_correctly():
"""Verify correct parameters are passed to Twilio."""
mock_message = MagicMock()
mock_message.sid = "SM1234567890abcdef"
mock_message.status = "queued"
with patch("twilio.rest.Client") as mock_client_class:
mock_client = MagicMock()
mock_client.messages.create.return_value = mock_message
mock_client_class.return_value = mock_client
result = send_sms_notification(
to="+15551234567",
body="Your order has shipped!"
)
# Verify Twilio was called with correct parameters
mock_client.messages.create.assert_called_once_with(
to="+15551234567",
from_="+15559876543", # Your Twilio number
body="Your order has shipped!"
)
assert result["sid"] == "SM1234567890abcdef"
assert result["status"] == "queued"
def test_sms_notification_handles_twilio_error():
"""Verify Twilio errors are handled gracefully."""
from twilio.base.exceptions import TwilioRestException
with patch("twilio.rest.Client") as mock_client_class:
mock_client = MagicMock()
mock_client.messages.create.side_effect = TwilioRestException(
msg="Invalid phone number",
uri="https://api.twilio.com/...",
method="POST",
status=400,
code=21211
)
mock_client_class.return_value = mock_client
result = send_sms_notification(
to="+1000", # Invalid number
body="Test message"
)
assert result["error"] is True
assert "Invalid phone number" in result["message"]Integration Testing with Twilio Test Credentials
Twilio's test credentials accept real API calls:
import pytest
from twilio.rest import Client
from twilio.base.exceptions import TwilioRestException
TWILIO_TEST_NUMBERS = {
"success": "+15005550006", # Always succeeds
"invalid": "+15005550001", # Invalid number error
"cant_route": "+15005550002", # Can't route error
"no_international": "+15005550003", # No international permission
"blacklisted": "+15005550004", # Number on blacklist
"incapable": "+15005550009", # From number incapable of SMS
}
@pytest.fixture
def twilio_client():
return Client(
os.environ["TWILIO_TEST_ACCOUNT_SID"],
os.environ["TWILIO_TEST_AUTH_TOKEN"]
)
def test_send_sms_success(twilio_client):
"""Test successful SMS send with test credentials."""
message = twilio_client.messages.create(
to=TWILIO_TEST_NUMBERS["success"],
from_="+15005550006",
body="Integration test message"
)
assert message.sid is not None
assert message.status in ("queued", "sent")
assert message.error_code is None
def test_send_sms_invalid_number(twilio_client):
"""Test that invalid numbers return correct error."""
with pytest.raises(TwilioRestException) as exc_info:
twilio_client.messages.create(
to=TWILIO_TEST_NUMBERS["invalid"],
from_="+15005550006",
body="Test"
)
assert exc_info.value.code == 21211, \
"Invalid phone number should return Twilio error code 21211"
def test_send_sms_blacklisted_number(twilio_client):
"""Test that blacklisted numbers are rejected."""
with pytest.raises(TwilioRestException) as exc_info:
twilio_client.messages.create(
to=TWILIO_TEST_NUMBERS["blacklisted"],
from_="+15005550006",
body="Test"
)
# Twilio error code 21610: message to this number is not currently supported
assert exc_info.value.code in (21610, 21614)Testing TwiML Responses
TwiML (Twilio Markup Language) controls call and SMS behavior. Test that your TwiML generation is correct:
from xml.etree import ElementTree as ET
from app.twiml import generate_welcome_call_twiml, generate_ivr_menu_twiml
def test_welcome_call_twiml_structure():
"""Verify welcome call TwiML has correct structure."""
twiml = generate_welcome_call_twiml(customer_name="Alice")
# Parse and validate XML
root = ET.fromstring(twiml)
assert root.tag == "Response", "Root element must be Response"
children = list(root)
say_elements = [c for c in children if c.tag == "Say"]
assert len(say_elements) >= 1, "Must have at least one Say element"
assert "Alice" in say_elements[0].text, "Greeting must include customer name"
def test_ivr_menu_twiml_gathers_input():
"""IVR menu must gather DTMF input."""
twiml = generate_ivr_menu_twiml()
root = ET.fromstring(twiml)
# Must have a Gather element for DTMF input
gather_elements = root.findall("Gather")
assert len(gather_elements) == 1, "IVR menu must have exactly one Gather"
gather = gather_elements[0]
assert gather.get("numDigits") == "1", "IVR menu should collect single digit"
assert gather.get("action") is not None, "Gather must have action URL"
assert gather.get("timeout") is not None, "Gather must have timeout"
# Gather must contain a Say element
say_in_gather = gather.find("Say")
assert say_in_gather is not None
assert "Press 1" in say_in_gather.text or "options" in say_in_gather.text.lower()
def test_twiml_conference_room():
"""Conference room TwiML must include conference verb."""
twiml = generate_conference_twiml(room_name="support-room-123")
root = ET.fromstring(twiml)
dial = root.find("Dial")
assert dial is not None, "Conference TwiML must have Dial verb"
conference = dial.find("Conference")
assert conference is not None
assert conference.text == "support-room-123"
assert conference.get("muted") in ("false", None), "Participant should not be muted by default"Testing Voice Call Webhooks
When Twilio calls your webhook, it sends form-encoded POST data. Test your webhook handler:
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def create_twilio_call_webhook_data(call_status: str = "in-progress", **extra) -> dict:
"""Create fake Twilio webhook POST data."""
return {
"CallSid": "CA1234567890abcdef",
"AccountSid": "AC1234567890abcdef",
"From": "+15551234567",
"To": "+15559876543",
"CallStatus": call_status,
"Direction": "inbound",
"ApiVersion": "2010-04-01",
**extra
}
def test_inbound_call_webhook_returns_twiml():
"""Inbound call webhook must return valid TwiML."""
data = create_twilio_call_webhook_data()
response = client.post("/webhooks/twilio/voice", data=data)
assert response.status_code == 200
assert "application/xml" in response.headers["content-type"]
# Parse and validate TwiML
root = ET.fromstring(response.text)
assert root.tag == "Response"
def test_ivr_digit_1_routes_to_sales():
"""Pressing 1 in IVR should route to sales queue."""
data = create_twilio_call_webhook_data(
call_status="in-progress",
Digits="1", # Caller pressed 1
)
response = client.post("/webhooks/twilio/voice/ivr", data=data)
assert response.status_code == 200
root = ET.fromstring(response.text)
# Should dial the sales number or enqueue
dial_or_enqueue = root.find("Dial") or root.find("Enqueue")
assert dial_or_enqueue is not None, "Pressing 1 must route to a queue or number"
def test_call_completed_webhook_updates_record():
"""Completed call webhook must update the call record."""
data = create_twilio_call_webhook_data(
call_status="completed",
CallDuration="45",
RecordingUrl="https://api.twilio.com/recordings/RE123"
)
response = client.post("/webhooks/twilio/voice/status", data=data)
assert response.status_code == 200
# Verify the call record was updated in the database
from app.database import get_call_record
call = get_call_record("CA1234567890abcdef")
assert call.status == "completed"
assert call.duration == 45Testing SMS Message Recording
def test_inbound_sms_is_stored():
"""Inbound SMS webhook must store the message."""
data = {
"MessageSid": "SM1234567890abcdef",
"AccountSid": "AC1234567890abcdef",
"From": "+15551234567",
"To": "+15559876543",
"Body": "Hello, I need help with my order",
"NumMedia": "0"
}
response = client.post("/webhooks/twilio/sms", data=data)
assert response.status_code == 200
from app.database import get_inbound_messages
messages = get_inbound_messages(from_number="+15551234567")
assert any(m.body == "Hello, I need help with my order" for m in messages)
def test_inbound_sms_with_media():
"""SMS with media attachment must store media URLs."""
data = {
"MessageSid": "SM_media_test",
"From": "+15551234567",
"To": "+15559876543",
"Body": "Check out this photo",
"NumMedia": "1",
"MediaUrl0": "https://api.twilio.com/media/ME123",
"MediaContentType0": "image/jpeg"
}
response = client.post("/webhooks/twilio/sms", data=data)
assert response.status_code == 200
from app.database import get_inbound_messages
messages = get_inbound_messages(sid="SM_media_test")
assert len(messages) == 1
assert "https://api.twilio.com/media/ME123" in messages[0].media_urlsTesting Delivery Failure Handling
def test_app_retries_on_delivery_failure():
"""App must retry SMS delivery on temporary failure."""
call_count = {"count": 0}
def mock_create(**kwargs):
call_count["count"] += 1
if call_count["count"] < 3:
from twilio.base.exceptions import TwilioRestException
raise TwilioRestException(
msg="Temporary failure",
uri="...", method="POST", status=500, code=30006
)
# Succeed on 3rd attempt
m = MagicMock()
m.sid = "SM_success"
m.status = "queued"
return m
with patch("twilio.rest.Client") as mock_client_class:
mock_client = MagicMock()
mock_client.messages.create.side_effect = mock_create
mock_client_class.return_value = mock_client
result = send_sms_with_retry(to="+15551234567", body="Test", max_retries=3)
assert result["sid"] == "SM_success"
assert call_count["count"] == 3
def test_app_does_not_retry_permanent_failures():
"""Invalid number errors must not be retried."""
from twilio.base.exceptions import TwilioRestException
with patch("twilio.rest.Client") as mock_client_class:
mock_client = MagicMock()
mock_client.messages.create.side_effect = TwilioRestException(
msg="Invalid number",
uri="...", method="POST", status=400, code=21211
)
mock_client_class.return_value = mock_client
result = send_sms_with_retry(to="+1000", body="Test", max_retries=3)
# Should have tried only once — invalid numbers don't benefit from retry
assert mock_client.messages.create.call_count == 1
assert result["error"] is TrueCI/CD Integration
# .github/workflows/twilio-tests.yml
name: Twilio Integration Tests
on:
push:
paths:
- 'app/notifications/**'
- 'app/webhooks/**'
- 'app/twiml/**'
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install twilio pytest fastapi httpx
- run: pytest tests/unit/twilio/ -v
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- run: pip install twilio pytest
- run: pytest tests/integration/twilio/ -v
env:
TWILIO_TEST_ACCOUNT_SID: ${{ secrets.TWILIO_TEST_ACCOUNT_SID }}
TWILIO_TEST_AUTH_TOKEN: ${{ secrets.TWILIO_TEST_AUTH_TOKEN }}Conclusion
Testing Twilio integrations requires three approaches in combination: unit tests with mocked clients for fast feedback on business logic, integration tests with Twilio test credentials for real API validation, and webhook handler tests for call and SMS event processing. Twilio's test magic numbers for different failure scenarios let you verify that your error handling works without sending real messages. The result is a Twilio integration you can refactor with confidence.