Testing Twilio SMS and Voice APIs: Sandbox, Recording, and Failure Simulation

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 == 45

Testing 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_urls

Testing 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 True

CI/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.

Read more