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 django.test.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')

Inherits from TestRouterMixin, LoginMixin, TestCase.

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 independently 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 or TestScriptMixin to get access to runScript():

from rapidsms.tests.harness 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.

class rapidsms.tests.harness.TestScript(methodName='runTest')

Inherits from TestScriptMixin, TransactionTestCase.

class rapidsms.tests.harness.TestScriptMixin

The scripted.TestScript class subclasses unittest.TestCase and allows you to define unit tests for your RapidSMS apps in the form of a ‘conversational’ script:

from myapp.app import App as MyApp
from rapidsms.tests.scripted import TestScript

class TestMyApp (TestScript):
    apps = (MyApp,)
    testRegister = """
       8005551212 > register as someuser
       8005551212 < Registered new user 'someuser' for 8005551212!
    """

    testDirectMessage = """
       8005551212 > tell anotheruser what's up??
       8005550000 < someuser said "what's up??"
    """

This TestMyApp class would then work exactly as any other unittest.TestCase subclass (so you could, for example, call unittest.main()).

Inherits from TestRouterMixin.

runScript(script)

Run a test script.

Parameters:script (string) – A multi-line test script. See TestScriptMixin.
class rapidsms.tests.harness.scripted.TestScriptMixin

Full name of rapidsms.tests.harness.TestScriptMixin.

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 rapidsms.tests.harness.CreateDataMixin

Base test mixin class that provides helper functions to create data.

No superclasses.

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_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_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_incoming_message(data={})

Create and return RapidSMS IncomingMessage object.

create_outgoing_message(data={}, backend=None)

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__.
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.
class rapidsms.tests.harness.base.CreateDataMixin

Full name for rapidsms.tests.harness.CreateDataMixin.

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 rapidsms.tests.harness.CustomRouterMixin

Inheritable TestCase-like object that allows Router customization.

Inherits from CreateDataMixin.

backends = {}

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

get_router()

get_router() API wrapper.

handlers = None

List to override RAPIDSMS_HANDLERS with, or if None, leave RAPIDSMS_HANDLERS alone

lookup_connections(backend, identities)

lookup_connections() API wrapper.

receive(text, connection, **kwargs)

A wrapper around the receive API. See Receiving Messages.

router_class = 'rapidsms.router.blocking.BlockingRouter'

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

send(text, connections, **kwargs)

A wrapper around the send API. See Sending Messages.

class rapidsms.tests.harness.router.CustomRouterMixin

Full name for rapidsms.tests.harness.CustomRouterMixin.

TestRouterMixin

TestRouterMixin extends CustomRouterMixin and arranges for tests to use the rapidsms.router.test.TestRouter.

class rapidsms.tests.harness.TestRouterMixin

Test extension that uses TestRouter

Inherits from CustomRouterMixin.

apps

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

clear_sent_messages()

Manually empty the outbox of mockbackend.

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.

lookup_connections(identities, backend='mockbackend')

A wrapper around the lookup_connections API. See Connection Lookup.

outbound

The list of message objects sent by the router.

sent_messages

The list of message objects sent to mockbackend.

class rapidsms.tests.harness.router.TestRouterMixin

Full name for rapidsms.tests.harness.TestRouterMixin.

TestRouter

The TestRouter can be used in tests. It saves all messages for later inspection by the test.

class rapidsms.router.test.TestRouter(*args, **kwargs)

Router that saves inbound/outbound messages for future inspection.

Inherits from BlockingRouter.

inbound = None

List of all the inbound messages

outbound = None

List of all the outbound messages

receive_incoming(msg)

Save all inbound messages locally for test inspection

send_outgoing(msg)

Save all outbound messages locally for test inspection

DatabaseBackendMixin

The DatabaseBackendMixin helps tests to use the DatabaseBackend.

class rapidsms.tests.harness.DatabaseBackendMixin

Arrange for test to use the DatabaseBackend, and add a .sent_messages attribute that will have the list of all messages sent.

Inherits from CustomRouterMixin.

lookup_connections(identities, backend='mockbackend')

lookup_connections wrapper to use mockbackend by default

sent_messages

Messages passed to backend.

LoginMixin

class rapidsms.tests.harness.LoginMixin

Helpers for creating users and logging in

login()

If not already set, creates self.username and self.password, otherwise uses the existing values. If there’s not already a user with that username, creates one. Sets self.user to that user. Logs the user in.

class rapidsms.tests.harness.base.LoginMixin

Full name for rapidsms.tests.harness.LoginMixin.

Django TestCase

Some of these classes inherit from:

class django.test.testcases.TestCase

which is the full name for django.test.TestCase.