Unit Testing Your Twilio App Using Python’s Flask and Nose

March 20, 2014
Written by

rob-spectre-lo-res

DuckHunt
It happens every time.  You fire up your editor.  The Twilio app spits off your fingers, deftly wielding the TwiML primitives you know to build your voice or SMS functionality.  You save. You commit. You deploy.  You set the request URLs for your Twilio number.  You gingerly unlock your phone and confidently tap in the digits.  Then you hear it:

“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

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.