Testing Your Twilio Applications in Python with pytest

April 07, 2020
Written by
Ana Paula Gomes
Contributor
Opinions expressed by Twilio contributors are their own

Testing Your Twilio Applications in Python with pytest

Many applications have to connect to different services such as document managers, building tools, vendor APIs, and so on. A few services offer sandboxes or staging environments in order to allow testing, but usually this is some kind of infrastructure that we cannot control. What if the vendor staging server goes down? Also, tests that require connecting with external services are slow and often expensive.

In this blog post we will talk about strategies to test your Python code when using REST APIs. That’s right, APIs in general. With the techniques learned here you can test code that includes connections to third-party services including Twilio APIs.

We are going to go on an adventure based on my current journey: learning German. We’ll build a simple SMS bot that sends a new German word every day using Twilio’s Programmable SMS API and then we’ll build tests for it. Even though this is a Python tutorial you may be able to apply the concepts in your preferred programming language.

Tutorial requirements

To follow this tutorial you need the following components:

  • Python 3.6 or newer. If your operating system does not provide a Python interpreter, you can go to python.org to download an installer.
  • A Twilio account. If you are new to Twilio create a free account now. If you use this link to sign up, you will receive $10 in credit when you upgrade to a paid account.

Create a Python virtual environment

Following Python best practices, we are going to make a separate directory for our Learning German via SMS project, and inside it we are going to create a virtual environment. We then are going to install the Python packages that we need for our project on it.

If you are using a Unix or Mac OS system, open a terminal and enter the following commands to do the tasks described above:

$ mkdir learning-german-via-sms
$ cd learning-german-via-sms
$ python3 -m venv learning-german-via-sms-venv
$ source learning-german-via-sms-venv/bin/activate
(learning-german-via-sms-venv) $ pip install twilio pytest

For those of you following the tutorial on Windows, enter the following commands in a command prompt window:

$ md learning-german-via-sms
$ cd learning-german-via-sms
$ python -m venv learning-german-via-sms-venv
$ learning-german-via-sms-venv\Scripts\activate
(learning-german-via-sms-venv) $ pip install twilio pytest

The last command uses pip, the Python package installer, to install the packages that we are going to use in this project, which are:

For your reference, at the time this tutorial was released these were the versions of the above packages and their dependencies tested:

pytest==5.2.3
twilio==6.36.0

Get your tokens from the Twilio Console

In order to connect with Twilio’s SMS API we need two pieces of information: the Account SID and the Auth Token. Both are needed to prove that you are you and keep your application secure. You just need to access your Twilio Console and leave this tab open (see the image below). We’ll be right back to it.

twilio account sid and auth token

Buy a Twilio phone number

In order to use the SMS service, you need a phone number to be used as your sender. You can go to the Phone Numbers page from your dashboard or from the “#” button on the left (see the images below):

Twilio’s dashboard page

twilio console

Phone numbers page:

twilio phone numbers page

Before we dive into testing, let’s talk a bit about our project: Learning German via SMS!

Creating a SMS service

It’s time to learn German, oder?

Building a words database

After reading one of these books about learning new languages, I found that a nice way of building vocabulary in a foreign language is to memorize the top 100, 500, and then the top 1000 most frequent words. So I built this JSON list with the top 1000 most frequent German words. The service we’re about to build will send me a SMS twice a day with a random word. This way, I’ll always be learning a new word.

You can create a JSON file with anything you want to memorize. It’s ok, it doesn’t have to be German. :) I got my list of most frequent German words from the Language Daily website. Here is how a word looks like in my JSON file:

{
    "rank": 4,
    "german_word": "ich",
    "english_translation": "I (not capitalized unless it is in the beginning of a sentence); ego (capitalized - Ich)",
    "part_of_speech": "personal pronoun; noun",
    "url": "http://languagedaily.com/learn-german/vocabulary/common-german-words"
}

After deciding what you want to memorize, create a JSON file in the root of your project folder. Mine is called most-common-german-words.json. Feel free to download it if you want to use it to follow this tutorial.

Creating the service

We’re going to create a simple script that picks a random word from the JSON file and then sends it to a phone number via SMS. That’s it. With this script, you can use the scheduler you like to keep it running (e.g. Crontab, Heroku Scheduler).

Below you can find our service code. Copy it into a file called learning_german.py:

import json
import logging
import random
from twilio.base.exceptions import TwilioRestException
from twilio.rest import Client

client = Client()
# or
# account_sid = "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
# auth_token = "your_auth_token"
# client = Client(account_sid, auth_token)


def send_message(to, from_, message):
    try:
        sent_message = client.messages.create(
            to=to,
            from_=from_,
            body=message
        )
    except TwilioRestException as e:
        logging.error(f'Oh no: {e}')
        return
    return sent_message.sid


def send_a_german_word():
    german_words = json.load(open('most-common-german-words.json'))
    chosen_word = random.choice(german_words)
    if chosen_word['part_of_speech']:
        part_of_speech = f"({chosen_word['part_of_speech']})"
    else:
        part_of_speech = ''
    if chosen_word['english_translation']:
        meaning = f"\n\nMeaning: {chosen_word['english_translation']}"
    else:
        meaning = ''
    message = (
        f"The word of the day is... "
        f"{chosen_word['german_word'].upper()} {part_of_speech} {meaning}"
        f"\nMore at: {chosen_word['url']}"
    )
    sid = send_message("<your-personal-number>", "<your-twilio-number>", message)
    return sid


if __name__ == '__main__':
    send_a_german_word()

By the beginning of this file, we’re creating a Twilio REST Client. We’re going to use our Twilio’s Account SID and Auth Token to set it up (from the Twilio Console). A Client constructor without parameters will look for the TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN variables inside the current environment. In the comments you can see an alternative way of adding these variables to your code - which is not recommendable, in order to avoid security issues, like pushing it to a public repository by mistake.

Go to the terminal, and create an environment variable called TWILIO_ACCOUNT_SID and another called TWILIO_AUTH_TOKEN. On Mac OS/Linux this can be done by:

$ export TWILIO_AUTH_TOKEN=here-my-secret-token
$ export TWILIO_ACCOUNT_SID=here-my-secret-account-sid

For those of you following the tutorial on Windows, enter the following command in a command prompt window:

$ set "TWILIO_AUTH_TOKEN=here-my-secret-token"
$ set "TWILIO_ACCOUNT_SID=here-my-secret-account-sid"

Also, we’re hard coding two phone numbers in this line:

sid = send_message("<your-personal-number>", "<your-twilio-number>", message)

The first one is the number on which you will receive the messages, and the second one is the number that is sending it (your Twilio phone number). Don’t forget to replace these phone numbers with yours, using the E.164 format.

Let’s check if our script works:

(learning-german-via-sms-venv) $ python learning_german.py

Nothing happened in the console? Have you checked your phone? :) If everything went fine, you must have received an SMS that looks like this:

received sms
 

Testing your service

Our code is working just fine but, as every other software, most likely it will grow and change - and we have to make sure that the main functionality continues to work. That’s why we write code to test our code.

There are tons of testing strategies and each of them will cover a different need. Read more about it in the classic Martin Fowler’s blog post on the Test Pyramid. Here we’re going to see how to unit test your Twilio API calls.

In this blog post we’re using pytest to write tests in a clean and simple way. If you use Python’s built in test library (unittest) don’t worry; pytest got you covered, since its runner is compatible with tests written for unittest as well.

pytest 101

Writing a test with pytest only requires you to create a test file and a test method, both starting with the test_ prefix (this can be changed by you if you want to). Let’s create a file called test_learning_german.py.

It’s a good practice to name the method after the module name. Here, for instance, the module name is learning_german.py, so our test module is test_learning_german.py.

Our service has two methods: send_message and send_a_german_word. We’ll focus on testing the one that makes calls to Twilio: send_message. Let’s recap how our method looks like:

def send_message(to, from_, message):
    try:
        sent_message = client.messages.create(
            to=to,
            from_=from_,
            body=message
        )
    except TwilioRestException as e:
        logging.error(f'Oh no: {e}')
        return
    return sent_message.sid

The method receives a phone number to send the message, a phone number which is sending the message and the message itself. If the message is sent, it returns the message attribute sid; if not, it will log an error and return None.

Our first test will check if a message was sent. Copy the code below to your test_learning_german.py file:

from learning_german import send_message


def test_send_a_common_word():
    message = "Hi there"
    to = "<your-personal-number>"
    from_ = "<your-twilio-number>"
    assert send_message(to, from_, message) is not None

Run your test using:

(learning-german-via-sms-venv) $ pytest

That’s right, you don’t have to point to the test file or folder, since pytest will discover it on its own. Also, make sure to have valid phone numbers in your test for now - we’ll change this later.

If everything went fine, you will see something like this:

pytest output

Yay! Our first test just ran. But it has a few problems:

  • Every time you run it, it will send real SMSs - we don’t want this
  • Every time it sends a real SMS, Twilio charges you money
  • You need real phone numbers to test your code
  • It is slow

We can solve these problems using two different unit test strategies: Mocks and Stubs.

Mocking parts of your code

Mocking is replacing some part of your code with a fake object that may or may not simulate how it should work. In our case, we want to mock the part of our code that needs an API connection. Python has a built in module for mocking and asserting its calls. Read more about Python’s mock object library.

We have run our code and we know that it works. We can now mock the part of it that does real calls to the Twilio API: client.messages.create(). Instead of having it connecting to Twilio’s servers, we’re going to replace our previous test with the next one, that uses mocks.

Let’s see how Python’s mock module looks like:

from learning_german import send_message
from unittest import mock


@mock.patch('learning_german.client.messages.create')
def test_send_a_common_word(create_message_mock):
    message = "Hi there"
    expected_sid = 'SM87105da94bff44b999e4e6eb90d8eb6a'
    create_message_mock.return_value.sid = expected_sid

    to = "<your-personal-number>"
    from_ = "<your-twilio-number>"
    sid = send_message(to, from_, message)

    assert create_message_mock.called is True
    assert sid == expected_sid

After importing the mock module, we add a decorator to our test. This decorator has a path to the method we want to fake: @mock.patch('learning_german.client.messages.create'). The mock object, created by this decorator, will be injected as an argument to our test method: def test_send_a_common_word(create_message_mock):.

As part of mocking the method, we also need to return something once it is called, after all, the method send_message returns a sid that comes from client.messages.create(). We use return_value to indicate that every time this mock is called, it should return something. In our case, we only need it to return an attribute called sid: create_message_mock.return_value.sid = expected_sid. Since there isn’t going to be an actual call to the Twilio API, we use a made up sid.

After configuring a mock object, we’re ready to call the method, as we did before, but the mock object will remember how it is used, so after having it called, we can check it. Python’s mock library offers us a few options to check the status of a mock. Here we’re using called but you can use others such as:

  • assert_called
  • assert_called_once_with
  • assert_has_calls
  • assert_not_called
  • call_count

Time to run our tests again:

pytest output

You can see now that our tests are faster than before and no actual calls to the Twilio API were made. You can monitor the calls from your Twilio’s token in the Twilio Console.

Mocking exceptions

Apart from the “happy path”, our SMS application has a block to catch exceptions. It’s good if we make sure that this part of the application works too. We’re going to create another test; this time to check if our program is logging an error whenever an exception from Twilio’s library is thrown. Copy the test below after our previous test in your test_learning_german.py:

from twilio.base.exceptions import TwilioRestException


@mock.patch('learning_german.client.messages.create')
def test_log_error_when_cannot_send_a_message(create_message_mock, caplog):
    error_message = (
        f"Unable to create record: The 'To' number "
        "<your-personal-number> is not a valid phone number."
    )
    status = 500
    uri = '/Accounts/ACXXXXXXXXXXXXXXXXX/Messages.json'
    msg = error_message
    create_message_mock.side_effect = TwilioRestException(status, uri, msg=error_message)

    to = "<your-personal-number>"
    from_ = "<your-twilio-number>"
    sid = send_message(to, from_, "Wrong message")

    assert sid is None
    assert 'Oh no:' in caplog.text
    assert error_message in caplog.text

Here we have two different arguments in our test: the first, you already know, is our mock object; the second one is the caplog Pytest fixture, useful for capturing the writes from the standard output. Read more about Pytest fixtures here.

This test is a bit different from the previous one; we want it to simulate an exception being thrown. We simulate an unexpected behavior by using the attribute side_effect. By doing this we make sure that the exception assigned to side_effect will be thrown when the mock is called, and in this way the code will simulate an error and lead to the except block of our function.

Now we have to make sure that the expected message is logged and the method returns None:

    assert sid is None
    assert 'Oh no:' in caplog.text
    assert error_message in caplog.text

Stubs

What if you want to run the real code or the fake code depending on the environment?

Another strategy to fake real calls in unit testing is using stubs. A stub is a piece of code that will be injected as a replacement for the real thing and will behave like it. Read more about the difference between Mocks and Stubs here.

Let’s see how a stub looks like. Add the following code to learning_german.py, before the method send_message (please mind the new imports):

from collections import namedtuple
import os
import random
from twilio.base.exceptions import TwilioRestException
from twilio.rest import Client

environment = os.getenv('ENVIRONMENT', 'dev')


class FakeClient:
    def __init__(self, **kwargs):
        self.messages = self.MessageFactory()

    class MessageFactory:
        @staticmethod
        def create(**kwargs):
            Message = namedtuple("Message", ["sid"])
            message = Message(sid="SM87105da94bff44b999e4e6eb90d8eb6a")
            return message


if environment == 'dev':
    client = FakeClient()
else:
    client = Client()

# the rest of the application here

We created a FakeClient to simulate the behavior of client.messages.create(). No matter how the input is, it will always return a made up sid value that looks the same as the real ones. If you want to use a real connection, you just have to change the ENVIRONMENT variable in the environment to prod (any value different than dev will also work).

Below you can see how our test would look like if we use this approach instead of mocks. You can add it at the bottom of test_learning_german.py:

def test_send_a_common_word_with_stubs():
     message = "Hi there"
     expected_sid = 'SM87105da94bff44b999e4e6eb90d8eb6a'
     to = "<your-personal-number>"
     from_ = "<your-twilio-number>"
     sid = send_message(to, from_, message)

     assert sid == expected_sid

Same logic but no mocks. Cleaner and can be used to run the test against the real API (don’t forget to add valid numbers).

Everything together

Here is how the final versions of our service and tests look like:

learning_german.py

import json
import logging
from collections import namedtuple
import os
import random
from twilio.base.exceptions import TwilioRestException
from twilio.rest import Client

environment = os.getenv('ENVIRONMENT', 'dev')


class FakeClient:
    def __init__(self, **kwargs):
        self.messages = self.MessageFactory()

    class MessageFactory:
        @staticmethod
        def create(**kwargs):
            Message = namedtuple("Message", ["sid"])
            message = Message(sid="SM87105da94bff44b999e4e6eb90d8eb6a")
            return message


if environment == 'dev':
    client = FakeClient()
else:
    client = Client()
# or
# account_sid = "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
# auth_token = "your_auth_token"
# client = Client(account_sid, auth_token)


def send_message(to, from_, message):
    try:
        sent_message = client.messages.create(
            to=to,
            from_=from_,
            body=message
        )
    except TwilioRestException as e:
        logging.error(f'Oh no: {e}')
        return
    return sent_message.sid


def send_a_german_word():
    german_words = json.load(open('most-common-german-words.json'))
    chosen_word = random.choice(german_words)
    if chosen_word['part_of_speech']:
        part_of_speech = f"({chosen_word['part_of_speech']})"
    else:
        part_of_speech = ''
    if chosen_word['english_translation']:
        meaning = f"\n\nMeaning: {chosen_word['english_translation']}"
    else:
        meaning = ''
    message = (
        f"The word of the day is... "
        f"{chosen_word['german_word'].upper()} {part_of_speech} {meaning}"
        f"\nMore at: {chosen_word['url']}"
    )
    sid = send_message("<your-personal-number>", "<your-twilio-number>", message)
    return sid


if __name__ == '__main__':
    send_a_german_word()

test_learning_german.py

from learning_german import send_message
import pytest
from twilio.base.exceptions import TwilioRestException
from unittest import mock


@mock.patch('learning_german.client.messages.create')
def test_send_a_common_word(create_message_mock):
    message = "Hi there"
    expected_sid = 'SM87105da94bff44b999e4e6eb90d8eb6a'
    create_message_mock.return_value.sid = expected_sid

    to = "<your-personal-number>"
    from_ = "<your-twilio-number>"
    sid = send_message(to, from_, message)

    assert create_message_mock.called is True
    assert sid == expected_sid


@mock.patch('learning_german.client.messages.create')
def test_log_error_when_cannot_send_a_message(create_message_mock, caplog):
    error_message = (
        f"Unable to create record: The 'To' number "
        "<your-personal-number> is not a valid phone number."
    )
    status = 500
    uri = '/Accounts/ACXXXXXXXXXXXXXXXXX/Messages.json'
    msg = error_message
    create_message_mock.side_effect = TwilioRestException(status, uri, msg=error_message)

    to = "<your-personal-number>"
    from_ = "<your-twilio-number>"
    sid = send_message(to, from_, "Wrong message")

    assert sid is None
    assert 'Oh no:' in caplog.text
    assert error_message in caplog.text


def test_send_a_common_word_with_stubs():
    message = "Hi there"
    expected_sid = 'SM87105da94bff44b999e4e6eb90d8eb6a'

    to = "<your-personal-number>"
    from_ = "<your-twilio-number>"
    sid = send_message(to, from_, message)

    assert sid == expected_sid

With this test module you can see both strategies working together. You may also play with the environment variable ENVIRONMENT set as dev or prod to see the difference between the execution times.

Conclusion

Testing our code is the best way to make sure that we’re delivering the best experience we can to our customers. Tested code is code that will alert you of bugs before it goes to production, making developers feel confident about their changes, and also document what is expected from it.

Mocks are useful when you need to come up with different scenarios for different tests. If your code is full of nested conditions, different behaviour for different exceptions, and so on, mocks are your go to solution.

But if APIs calls are made from different parts of your code and you don’t see value in mocking them every single time, then stubs are made for you. Also, remember that you don’t have to choose one in favor of the other. You can always combine both strategies.

I hope you liked this tutorial and that it has given you some new insights. Can’t wait to hear what tests you build!

Ana Paula Gomes is a software engineer, a runner wannabe and crazy open source lady.