Testing RapidSMS Applications

Automated testing is an extremely useful tool and, therefore, we recommend writing tests for all RapidSMS applications and projects. Tests provide a way to repeatedly ensure that your code functions as expected and that new code doesn’t break existing functionality.

This document outlines the tools and best practices for writing RapidSMS tests.

Prerequisites

A RapidSMS test is written using standard Python and Django testing utilities. If you’re unfamiliar with these concepts, please take a moment to read through the following links:

Additionally, since much of RapidSMS is Django-powered, these docs will not cover testing standard Django aspects (views, models, etc.), but rather focus on the areas unique to RapidSMS itself, specifically messaging and the router.

 

What To Test

Let’s start with an example. Say you’ve written a quiz application, QuizMe, that will send a question if you text the letter q to RapidSMS:

You: q
RapidSMS: What color is the ocean? Answer with 'q ocean <answer>'
You: q ocean red
RapidSMS: Please try again!
You: q ocean blue
RapidSMS: Correct!

Additionally, if no questions exist, the application will inform you:

You: q
RapidSMS: No questions exist.

While the application is conceptually simple, determining what and how to test can be a daunting task. To start, let’s look a few areas that we could test:

  • Message parsing. How does the application know the difference between q and q ocean blue? Will it be confused by other input, like q   ocean   blue or quality?
  • Workflow. What happens when there aren’t any questions in the database?
  • Logic testing. Is the answer correct?

How to test these aspects is another question. Generally speaking, it’s best practice, and conceptually the easiest, to test the smallest units of your code. For example, say you have a function to test if an answer is correct:

class QuizMeApp(AppBase):
    def check_answer(self, question, answer_text):
        """Return if guess is correct or not"""
        guess = answer_text.lower()
        answer = question.correct_answer.lower()
        return guess == answer

Writing a test that uses check_answer directly will verify the correctness of that function alone. With that test written, you can write higher level tests knowing that check_answer is covered and will only fail if the logic changes inside of it.

The following sections describe the various methods and tools to use for testing your RapidSMS applications.

Testing Methods

 

General Testing

RapidSMS provides a suite of test harness tools. Below you’ll find a collection of TestCase extensions to make testing your RapidSMS applications easier.

 

RapidTest

The RapidTest class provides a simple test environment to analyze sent and received messages. You can inspect messages processed by the router and, if needed, see if messages were delivered to a special backend, mockbackend. Let’s take a look at a simple example:

from rapidsms.tests.harness import RapidTest
class QuizMeStackTest(RapidTest):
    def test_no_questions(self):
        """Outbox should contain message explaining no questions exist"""
        self.receive('q', self.lookup_connections('1112223333')[0])
        self.assertEqual(self.outbound[0].text, 'No questions exist.')

In this example, we want to make sure that texting q into our application will return the proper message if no questions exist in our database. We use receive to communicate to the router and lookup_connections to create a connection object to bundle with the message. Our app will respond with a special message, No questions exist, if the database isn’t populated, so we inspect the outbound property to see if it contains the proper message text. That’s it! With just a few lines we were able to send a message through the entire routing stack and verify the functionality of our application.

class rapidsms.tests.harness.RapidTest(methodName=’runTest’)
apps

A list of app classes to load, rather than INSTALLED_APPS, when the router is initialized.

disable_phases = False

If disable_phases is True, messages will not be processed through the router phases. This is useful if you’re not interested in testing application logic. For example, backends may use this flag to ensure messages are sent to the router, but don’t want the message to be processed.

inbound

The list of message objects received by the router.

outbound

The list of message objects sent by the router.

sent_messages

The list of message objects sent to mockbackend.

clear_sent_messages()

Manually empty the outbox of mockbackend.

receive(text, connection, fields=None)

A wrapper around the receive API. See Receiving Messages.

send(text, connections)

A wrapper around the send API. See Sending Messages.

lookup_connections(identities, backend=’mockbackend’)

A wrapper around the lookup_connections API. See Connection Lookup.

Database Interaction

RapidTest provides flexible means to check application state, including the database. Here’s an example of a test that examines the database after receiving a message:

from rapidsms.tests.harness import RapidTest
from quizme.models import Question, Answer
class QuizMeGeneralTest(RapidTest):
    def test_question_answer(self):
        """Outbox should contain question promt and answer should be recorded in database"""
        Question.objects.create(short_name='ocean',
                                text="What color is the ocean?",
                                correct_answer='Blue')
        msg = self.receive('q ocean blue', self.lookup_connections('1112223333')[0])
        # user should receive "correct" response
        self.assertEqual(self.outbound[0].text, 'Correct!')
        # answer from this interaction should be stored in database
        answer = Answer.objects.all()[0]
        self.assertTrue(answer.correct)
        self.assertEqual(msg.connection, answer.connection)

Application Logic

If you have application logic that doesn’t depend on message processing directly, you can always test it indepdently of the router API. RapidSMS applications are just Python classes, so you can construct your app inside of your test suite. For example:

from django.test import TestCase
from rapidsms.router.test import TestRouter
from quizme.app import QuizMeApp
class QuizMeLogicTest(TestCase):
    def setUp(self):
        # construct the app we want to test with the TestRouter
        self.app = QuizMeApp(TestRouter())
    def test_inquiry(self):
        """Messages with only the letter "q" are quiz messages"""
        self.assertTrue(self.app.is_quiz("q"))
    def test_inquiry_whitespace(self):
        """Message inquiry whitespace shouldn't matter"""
        self.assertTrue(self.app.is_quiz(" q "))
    def test_inquiry_skip(self):
        """Only messages starting with the letter q should be considered"""
        self.assertFalse(self.app.is_quiz("quantity"))
        self.assertFalse(self.app.is_quiz("quality 50"))

This example tests the logic of QuizMeApp.is_quiz, which is used to determine whether or not the text message is related to the quiz application. The app is constructed with TestRouter and tests is_quiz with various types of input.

This method is useful for testing specific, low-level components of your application. Since the routing architecture isn’t loaded, these tests will also execute very quickly.

Scripted Tests

You can write high-level integration tests for your applications by using the TestScript framework. TestScript allows you to write message scripts (akin to a movie script), similar to our example in the What To Test section above:

You: q
RapidSMS: What color is the ocean? Answer with 'q ocean <answer>'
You: q ocean blue
RapidSMS: Correct!

The main difference is the syntax:

1112223333 > q
1112223333 < What color is the ocean? Answer with 'q ocean <answer>'
1112223333 > q ocean blue
1112223333 < Correct!

The script is interpreted like so:

  • number > message-text
    • Represents an incoming message from number whose contents is message-text
  • number < message-text
    • Represents an outoing message sent to number whose contents is message-text

The entire script is parsed and executed against the RapidSMS router.

Example

To use this functionality in your test suite, you simply need to extend from TestScript to get access to runScript:

from rapidsms.tests.harness.scripted import TestScript
from quizme.app import QuizMeApp
from quizme.models import Question
class QuizMeScriptTest(TestScript):
    apps = (QuizMeApp,)
    def test_correct_script(self):
        """Test full script with correct answer"""
        Question.objects.create(short_name='ocean',
                                text="What color is the ocean?",
                                correct_answer='Blue')
        self.runScript("""
            1112223333 > q
            1112223333 < What color is the ocean? Answer with 'q ocean <answer>'
            1112223333 > q ocean blue
            1112223333 < Correct!
        """)

This example uses runScript to execute the interaction against the RapidSMS router. apps must be defined at the class level to tell the test suite which apps the router should load. In this case, only one app was required, QuizMeApp.

This test method is particularly useful when executing high-level integration tests across multiple RapidSMS applications. However, you’re limited to the test script. If you need more fined grained access, like checking the state of the database in the middle of a script, you should use General Testing.

Test Helpers

Below you’ll find a list of mixin classes to help ease unit testing. Most of these mixin classes are used by the RapidSMS test classes for convenience, but you can also use these test helpers independently if needed.

CreateDataMixin

The CreateDataMixin class can be used with standard TestCase classes to make it easier to create common RapidSMS models and objects. For example:

from django.test import TestCase
from rapidsms.tests.harness import CreateDataMixin
class ExampleTest(CreateDataMixin, TestCase):
    def test_my_app_function(self):
        contact1 = self.create_contact()
        contact2 = self.create_contact({'name': 'John Doe'})
        connection = self.create_connection({'contact': contact1})
        text = self.random_string()
        # ...
class CreateDataMixin
random_string(length=255, extra_chars=”)

Generate a random string of characters.

Parameters:
  • length – Length of generated string.
  • extra_chars – Additional characters to include in generated string.
random_unicode_string(max_length=255)

Generate a random string of unicode characters.

Parameters:length – Length of generated string.
create_backend(data={})

Create and return RapidSMS backend object. A random name will be created if not specified in data attribute.

Parameters:data – Optional dictionary of field name/value pairs to pass to the object’s create method.
create_contact(data={})

Create and return RapidSMS contact object. A random name will be created if not specified in data attribute.

Parameters:data – Optional dictionary of field name/value pairs to pass to the object’s create method.
create_connection(data={})

Create and return RapidSMS connection object. A random identity and backend will be created if not specified in data attribute.

Parameters:data – Optional dictionary of field name/value pairs to pass to the object’s create method.
create_outgoing_message(data={})

Create and return RapidSMS OutgoingMessage object. A random template will be created if not specified in data attribute.

Parameters:data – Optional dictionary of field name/value pairs to pass to OutgoingMessage.__init__.

CustomRouterMixin

The CustomRouterMixin class allows you to override the RAPIDSMS_ROUTER and INSTALLED_BACKENDS settings. For example:

from django.test import TestCase
from rapidsms.tests.harness import CustomRouterMixin
class ExampleTest(CustomRouterMixin, TestCase)):
    router_class = 'path.to.router'
    backends = {'my-backend': {'ENGINE': 'path.to.backend'}}
    def test_sample(self):
        # this test will use specified router and backends
        pass
class CustomRouterMixin
router_class

String to override RAPIDSMS_ROUTER during testing. Defaults to 'rapidsms.router.blocking.BlockingRouter'.

backends

Dictionary to override INSTALLED_BACKENDS during testing. Defaults to {}.

Previous topic

Frequently Asked Questions

This Page