“We’re sorry. An application error has occurred. Goodbye.”
Rage wells inside you familiar to the impulse hearing the dog laugh at you in Duck Hunt. You open the Twilio App Monitor and find the culprit – a fat fingered typo on your Dial verb.
Cue the sad trombone.
It happens to all of us. And when you’re iterating on your Twilio code, the wash-rinse-repeat cycle of changing code, deploying to a web server and manually testing on your phone can become tiresome quickly.
One technique I use to reduce this frustration is writing unit tests against my Twilio webhook endpoints as I work on my app. Utilizing a web framework’s test client to write these tests quickly helps keep my focus on the app I’m trying to write and the knucklehead errors that produce the Duck Hunt experience to a minimum.
What You’ll Learn
In this post, we’ll show that technique in action with Flask, my go-to framework for writing Twilio apps in Python.
- How to write a simple Twilio Conference endpoint with Flask
- Write a unit test with Nose for that endpoint
- Expand that unit test into a test case we can reuse for all our Twilio apps
- Then show how that reusable test case can be applied to more complex flows.
What You’ll Need
- Sign up for a free Twilio account
- Install the Twilio Python module
- Install the Flask web microframework
- Install the Nose testing framework
Let’s Cut Some Code
To start, we’ll open a text editor in our Python environment with the Twilio and Flask modules installed and clack out a simple app that will create a Twilio Conference room using the verb and the noun.
Here’s a quick cut in a file we’ll name app.py:
from flask import Flask
from twilio import twiml
app = Flask(__name__)
@app.route('/conference', methods=['POST'])
def voice():
response = twiml.Response()
with response.dial() as dial:
dial.conf("Rob's Blog Party")
return str(response)
if __name__ == "__main__":
app.debug = True
app.run(port=5000)
Now Let’s Test It
I think this code might be right, but let’s make sure by writing a quick unit test. To do this, we’ll open another file called test_app.py. In that file, we’ll import our app and define a unit test using unittest in the Python standard library. We’ll then use the Flask test client to make a test request to the app and see if the app throws an error.
import unittest
from app import app
class TestConference(unittest.TestCase):
def test_conference(self):
# Use Flask's test client for our test.
self.test_app = app.test_client()
# Make a test request to the conference app, supplying a fake From phone
# number
response = self.test_app.post('/conference', data={'From':
'+15556667777'})
# Assert response is 200 OK.
self.assertEquals(response.status, "200 OK")
We then run the unit test using Nose By issuing the following command, Nose will go through our unit test file, find all TestCase objects and execute each method prefixed with test_:
nosetests -v test_app.py
Oh biscuits – looks like we got a bug.
test_conference (test_intro.TestConference) ... FAIL
======================================================================
FAIL: test_conference (test_intro.TestConference)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/rspectre/workspace/test_post/test_intro.py", line 16, in test_conference
self.assertEquals(response.status, "200 OK")
AssertionError: '500 INTERNAL SERVER ERROR' != '200 OK'
-------------------- >> begin captured logging << --------------------
app: ERROR: Exception on /conference [POST]
Traceback (most recent call last):
File "/usr/local/lib/python2.7/dist-packages/flask/app.py", line 1504,
in wsgi_app response = self.full_dispatch_request()
File "/usr/local/lib/python2.7/dist-packages/flask/app.py", line 1264,
in full_dispatch_request rv = self.handle_user_exception(e)
File "/usr/local/lib/python2.7/dist-packages/flask/app.py", line 1262,
in full_dispatch_request rv = self.dispatch_request()
File "/usr/local/lib/python2.7/dist-packages/flask/app.py", line 1248,
in dispatch_request return self.view_functions[rule.endpoint](**req.view_args)
File "/home/rspectre/workspace/test_post/app.py", line 13,
in voice dial.conf("Rob's Blog Party")
AttributeError: 'Dial' object has no attribute 'conf'
--------------------- >> end captured logging << ---------------------
----------------------------------------------------------------------
Ran 1 test in 0.009s
FAILED (failures=1)
D’oh. The name of the TwiML noun for conferencing isn’t “Conf” but “Conference.” Let’s revisit our app.py file and correct the bug.
from flask import Flask
from twilio import twiml
# Define our app
app = Flask(__name__)
# Define an endpoint to use as the conference room
@app.route('/conference', methods=['POST'])
def voice():
response = twiml.Response()
with response.dial() as dial:
# Let's use the right attribute now.
dial.conference("Rob's Blog Party")
return str(response)
# Run the app in debug mode on port 5000
if __name__ == "__main__":
app.debug = True
app.run(port=5000)
Now with the Conference line corrected, we can rerun our tests with the same command as above:
rspectre@drgonzo:~/workspace/test_post$ nosetests -v test_app.py
test_conference (test_intro.TestConference) ... ok
----------------------------------------------------------------------
Ran 1 test in 0.010s
OK
Awesome. And we didn’t have to pick up our phones to figure out the error.
Now Let’s Make Sure This Code Does What We Want
Making sure the code doesn’t throw an error is a good first step, but we also want to make sure our Twilio app performs the way we intend. First we need to check that the app returns a response that Twilio can interpret, make sure it is creating a valid Dial verb and finally that the Dial points to the correct Conference room.
To help, we’ll use ElementTree, an XML parser from the Python standard library. This way we can interpret the TwiML response the same way Twilio would. Let’s look how we would add this to test_app.py:
import unittest
from app import app
# Import an XML parser
from xml.etree import ElementTree
class TestConference(unittest.TestCase):
def test_conference(self):
# Keep our previous test.
self.test_app = app.test_client()
response = self.test_app.post('/conference', data={'From': '+15556667777'})
self.assertEquals(response.status, "200 OK")
def test_conference_valid(self):
# Create a new test that validates our TwiML is doing what it should.
self.test_app = app.test_client()
response = self.test_app.post('/conference', data={'From': '+15556667777'})
# Parse the result into an ElementTree object
root = ElementTree.fromstring(response.data)
# Assert the root element is a Response tag
self.assertEquals(root.tag, 'Response',
"Did not find tag as root element " \
"TwiML response.")
# Assert response has one Dial verb
dial_query = root.findall('Dial')
self.assertEquals(len(dial_query), 1,
"Did not find one Dial verb, instead found: %i " %
len(dial_query))
# Assert Dial verb has one noun
dial_children = list(dial_query[0])
self.assertEquals(len(dial_children), 1,
"Dial does not go to one noun, instead found: %s" %
len(dial_children))
# Assert Dialing into a Conference noun
self.assertEquals(dial_children[0].tag, 'Conference',
"Dial is not to a Conference, instead found: %s" %
dial_children[0].tag)
# Assert Conference is Rob's Blog Party
self.assertEquals(dial_children[0].text, "Rob's Blog Party",
"Conference is not Rob's Blog Party, instead found: %s" %
dial_children[0].text)
Now run both tests using Nose:
rspectre@drgonzo:~/workspace/test_post$ nosetests -v test_app.py
test_conference (test_app.TestConference) ... ok
test_conference_valid (test_app.TestConference) ... ok
----------------------------------------------------------------------
Ran 2 tests in 0.011s
OK
Killer. Now we’re confident this app is doing what we want in addition to returning an appropriate response.
DRYing Up Our Tests For Reuse
It’s great that we know our new Twilio endpoint works without needing to test it manually, but Twilio apps rarely use a single webhook endpoint. As our application grows in complexity we can see these two tests are going to repeat a whole lot of code. Let’s see if we can refactor our tests into a generic test case we can use for any Twilio webhook endpoints we build in the future.
To do this, we’ll create a generic TwiMLTest class and leverage the built-in setUp() method to instantiate our Flask test client automatically with every test.
import unittest
from app import app
from xml.etree import ElementTree
class TwiMLTest(unittest.TestCase):
def setUp(self):
# Create a test app every test case can use.
self.test_app = app.test_client()
Great start – now let’s create a helper method that accepts a response and does the basic validation that it is working TwiML.
import unittest
from app import app
from xml.etree import ElementTree
class TwiMLTest(unittest.TestCase):
def setUp(self):
# Create a test app every test case can use.
self.test_app = app.test_client()
def assertTwiML(self, response):
# Check for error.
self.assertEquals(response.status, "200 OK")
# Parse the result into an ElementTree object
root = ElementTree.fromstring(response.data)
# Assert the root element is a Response tag
self.assertEquals(root.tag, 'Response',
"Did not find tag as root element " \
"TwiML response.")
Finally, instead of creating a new POST request with every test, let’s create two more helper methods that will create Twilio requests for calls and messages that we can easily extend with custom parameters. Let’s add a new class to test_app.py.
import unittest
from app import app
from xml.etree import ElementTree
class TwiMLTest(unittest.TestCase):
def setUp(self):
self.test_app = app.test_client()
def assertTwiML(self, response):
self.assertEquals(response.status, "200 OK")
root = ElementTree.fromstring(response.data)
self.assertEquals(root.tag, 'Response',
"Did not find tag as root element " \
"TwiML response.")
def call(self, url='/voice', to='+15550001111',
from_='+15558675309', digits=None, extra_params=None):
"""Simulates Twilio Voice request to Flask test client
Keyword Args:
url: The webhook endpoint you wish to test. (default '/voice')
to: The phone number being called. (default '+15550001111')
from_: The CallerID of the caller. (default '+15558675309')
digits: DTMF input you wish to test (default None)
extra_params: Dictionary of additional Twilio parameters you
wish to simulate, like QueuePosition or Digits. (default: {})
Returns:
Flask test client response object.
"""
# Set some common parameters for messages received by Twilio.
params = {
'CallSid': 'CAtesting',
'AccountSid': 'ACxxxxxxxxxxxxx',
'To': to,
'From': from_,
'CallStatus': 'ringing',
'Direction': 'inbound',
'FromCity': 'BROOKLYN',
'FromState': 'NY',
'FromCountry': 'US',
'FromZip': '55555'}
# Add simulated DTMF input.
if digits:
params['Digits'] = digits
# Add extra params not defined by default.
if extra_params:
params = dict(params.items() + extra_params.items())
# Return the app's response.
return self.test_app.post(url, data=params)
def message(self, body, url='/message', to="+15550001111",
from_='+15558675309', extra_params={}):
"""Simulates Twilio Message request to Flask test client
Args:
body: The contents of the message received by Twilio.
Keyword Args:
url: The webhook endpoint you wish to test. (default '/sms')
to: The phone number being called. (default '+15550001111')
from_: The CallerID of the caller. (default '+15558675309')
extra_params: Dictionary of additional Twilio parameters you
wish to simulate, like MediaUrls. (default: {})
Returns:
Flask test client response object.
"""
# Set some common parameters for messages received by Twilio.
params = {
'MessageSid': 'SMtesting',
'AccountSid': 'ACxxxxxxx',
'To': to,
'From': from_,
'Body': body,
'NumMedia': 0,
'FromCity': 'BROOKLYN',
'FromState': 'NY',
'FromCountry': 'US',
'FromZip': '55555'}
# Add extra params not defined by default.
if extra_params:
params = dict(params.items() + extra_params.items())
# Return the app's response.
return self.test_app.post(url, data=params)
Excellent – now we can refactor our original tests for the conference using the new helper methods, making the tests much shorter:
import unittest
from app import app
from xml.etree import ElementTree
class TwiMLTest(unittest.TestCase):
def setUp(self):
self.test_app = app.test_client()
def assertTwiML(self, response):
self.assertEquals(response.status, "200 OK")
root = ElementTree.fromstring(response.data)
self.assertEquals(root.tag, 'Response',
"Did not find tag as root element " \
"TwiML response.")
def call(self, url='/voice', to='+15550001111',
from_='+15558675309', digits=None, extra_params=None):
"""Simulates Twilio Voice request to Flask test client
Keyword Args:
url: The webhook endpoint you wish to test. (default '/voice')
to: The phone number being called. (default '+15550001111')
from_: The CallerID of the caller. (default '+15558675309')
digits: DTMF input you wish to test (default None)
extra_params: Dictionary of additional Twilio parameters you
wish to simulate, like QueuePosition or Digits. (default: {})
Returns:
Flask test client response object.
"""
# Set some common parameters for messages received by Twilio.
params = {
'CallSid': 'CAtesting',
'AccountSid': 'ACxxxxxxxxxxxxx',
'To': to,
'From': from_,
'CallStatus': 'ringing',
'Direction': 'inbound',
'FromCity': 'BROOKLYN',
'FromState': 'NY',
'FromCountry': 'US',
'FromZip': '55555'}
# Add simulated DTMF input.
if digits:
params['Digits'] = digits
# Add extra params not defined by default.
if extra_params:
params = dict(params.items() + extra_params.items())
# Return the app's response.
return self.test_app.post(url, data=params)
def message(self, body, url='/message', to="+15550001111",
from_='+15558675309', extra_params={}):
"""Simulates Twilio Message request to Flask test client
Args:
body: The contents of the message received by Twilio.
Keyword Args:
url: The webhook endpoint you wish to test. (default '/sms')
to: The phone number being called. (default '+15550001111')
from_: The CallerID of the caller. (default '+15558675309')
extra_params: Dictionary of additional Twilio parameters you
wish to simulate, like MediaUrls. (default: {})
Returns:
Flask test client response object.
"""
# Set some common parameters for messages received by Twilio.
params = {
'MessageSid': 'SMtesting',
'AccountSid': 'ACxxxxxxx',
'To': to,
'From': from_,
'Body': body,
'NumMedia': 0,
'FromCity': 'BROOKLYN',
'FromState': 'NY',
'FromCountry': 'US',
'FromZip': '55555'}
# Add extra params not defined by default.
if extra_params:
params = dict(params.items() + extra_params.items())
# Return the app's response.
return self.test_app.post(url, data=params)
class TestConference(TwiMLTest):
def test_conference(self):
response = self.call(url='/conference')
self.assertTwiML(response)
def test_conference_valid(self):
# Create a new test that validates our TwiML is doing what it should.
response = self.call(url='/conference')
# Parse the result into an ElementTree object
root = ElementTree.fromstring(response.data)
# Assert response has one Dial verb
dial_query = root.findall('Dial')
self.assertEquals(len(dial_query), 1,
"Did not find one Dial verb, instead found: %i " %
len(dial_query))
# Assert Dial verb has one noun
dial_children = list(dial_query[0])
self.assertEquals(len(dial_children), 1,
"Dial does not go to one noun, instead found: %s" %
len(dial_children))
# Assert Dialing into a Conference noun
self.assertEquals(dial_children[0].tag, 'Conference',
"Dial is not to a Conference, instead found: %s" %
dial_children[0].tag)
# Assert Conference is Rob's Blog Party
self.assertEquals(dial_children[0].text, "Rob's Blog Party",
"Conference is not Rob's Blog Party, instead found: %s" %
dial_children[0].text)
Perfect – let’s run our tests using Nose and see if we’re golden.
rspectre@drgonzo:~/workspace/test_post$ nosetests -v test_app.py
test_conference (test_app.TestConference) ... ok
test_conference_valid (test_app.TestConference) ... ok
----------------------------------------------------------------------
Ran 2 tests in 0.014s
OK
And all is good with the world.
Go Forth And Test
With our generic test case for Twilio apps, now writing tests is quick and simple. We wrote a quick Conferencing app, tested it using Nose, and then refactored those tests into a generic case you can use with all your apps. By using this test case, testing our Twilio apps built on Flask can be fast and painless, reducing both the amount of time spent manually testing with your phone and the number of times you have to hear the dreaded “Application Error” voice.
Because no likes to hear this on the other end of the phone.