End-to-End Testing - A Code Example

Prior posts in this series:

Abstract concepts are always easier to learn with concrete examples. This is the first code-heavy post in the series, and it is intended to illustrate the mechanics of the concept, not yet how it is applied to the problem domain. For this reason, I use the example of Fibonacci Numbers, with a naive implementation - the business logic is not the emphasis here, but how we test them on multiple levels.

I chose Django to write the code, in because of its lovely built in end-to-end testing support, but knowing that not everyone is a Django developer (yet!), I chose not to use any of the more idiomatic Django class based views, since the purpose of this post is not to teach idiomatic Django.

Enough of the disclaimers, on to the code!

The Specs for the App

class FibonacciCalculatorTests:

    def test_cannot_calculate_sequence_elements_less_than_one(self):
        self.assert_cannot_calculate(n=-1)
        self.assert_cannot_calculate(n=0)
        self.assert_can_calculate(1)

    def test_currently_we_cannot_calculate_numbers_greater_than_10(self):
        self.assert_can_calculate(10)
        self.assert_cannot_calculate(11)
        self.assert_cannot_calculate(7895)

    def test_can_calculate_the_first_ten_fibonacci_numbers(self):
        self.assertEquals(1, self.get_fibonacci(1))
        self.assertEquals(1, self.get_fibonacci(2))
        self.assertEquals(2, self.get_fibonacci(3))
        self.assertEquals(3, self.get_fibonacci(4))
        self.assertEquals(5, self.get_fibonacci(5))
        self.assertEquals(8, self.get_fibonacci(6))
        self.assertEquals(13, self.get_fibonacci(7))
        self.assertEquals(21, self.get_fibonacci(8))
        self.assertEquals(34, self.get_fibonacci(9))
        self.assertEquals(55, self.get_fibonacci(10))

    def test_can_only_calculate_fibonacci_for_integers(self):
        self.assert_cannot_calculate(3.14)
        self.assert_cannot_calculate('not a number')
        self.assert_cannot_calculate(None)

    def assert_can_calculate(self, n):
        result = self.get_fibonacci(n)
        self.assertIsNotNone(result)

We'll get to the implementation of assert_cannot_calculate next.

The Business Logic

Let's add the actual test implementation

from django.test import TestCase
from simple_fibonacci import calculator, urls


class InMemory(FibonacciCalculatorTests, TestCase):

    def assert_cannot_calculate(self, n):
        with self.assertRaises(ValueError):
            self.get_fibonacci(n)

    def get_fibonacci(self, n):
        return calculator.fibonacci(n)

Followed by the calculator fibonacci logic

def fibonacci(n):
    # don't do this in production
    # use https://en.wikipedia.org/wiki/Fibonacci_number#Closed-form_expression
    if n < 1:
        raise ValueError('value (%r) too low' % n)
    if n > 10:
        raise ValueError('value (%r) too high' % n)
    if n in (1, 2):
        return 1
    return fibonacci(n - 1) + fibonacci(n - 2)

This is a webservice, let's test the JSON API!

You can probably guess, this is where we add the second implementation for the FibonacciCaulcatorTests:

import simple_fibonacci
from django.test.client import Client
from django.core.urlresolvers import reverse
import json


class JsonHttpResponse(FibonacciCalculatorTests, TestCase):

    urls = simple_fibonacci.urls

    def assert_cannot_calculate(self, n):
        data = self.get_fibonacci_parsed_json_response(n)
        self.assertFalse('result' in data, data)
        self.assertEquals('ERROR', data['status'])

    def get_fibonacci(self, n):
        data = self.get_fibonacci_parsed_json_response(n)
        self.assertFalse('error' in data, data)
        self.assertEquals('OK', data['status'], data)
        return data['result']

    def get_fibonacci_parsed_json_response(self, n):
        client = Client()
        url = reverse('fibonacci')
        response = client.post(url, {'n': n}, HTTP_ACCEPT='application/json')
        data = json.loads(response.content)
        allowed_keys = set(['status', 'n', 'error', 'result'])
        data_keys = set(data.keys())
        self.assertEquals(set([]), data_keys - allowed_keys)
        self.assertEquals(unicode(n), data['n'])
        return data

As you can see, on top of the assertions in the base class, I've added a few additional assertions, for the api contract (invariants) - this api shouldn't return extra fields, or if it does, I should be notified and then decided whether that's a bug that needs correction or a feature, in which case I'll adjust the allowed_keys variable

And here is the implementation:

from django.views.generic import View
import json
from django.http import HttpResponse
from simple_fibonacci import calculator


class FibonacciView(View):

    def post(self, request):
        n = request.POST['n']
        error = None
        result = None
        try:
            result = calculator.fibonacci(int(n))
        except ValueError as e:
            error = unicode(e)
        content, content_type = \
            self.get_response_content_and_type_function(request)(
                n, error, result
            )
        return self.to_response(content, content_type)

    def get_response_content_and_type_function(self, request):
        return self.json_response

    def json_response(self, n, error, result):
        response_data = {'n': n}
        if error is not None:
            response_data['status'] = 'ERROR'
        else:
            response_data['status'] = 'OK'
            response_data['result'] = result
        return (json.dumps(response_data), 'application/json')

    def to_response(self, content, content_type):
        response = HttpResponse(content)
        response['Content-Type'] = content_type
        return response

Let's expose this feature to normal user as HTML!

The order of the events is sometimes the other way around, but sooner or later the point comes when the same functionality must be exposed through a different channel - whether it's a simplified/more efficient power user interface for your internal support people as opposed to your first time customers through the public web shop, or adding a mobile app for your web service, it will happen. And it's nice to know we can execute the same set of tests against all implementations.

Let's update the tests first:

import re


class HtmlUserFriendlyHttpResponse(FibonacciCalculatorTests, TestCase):

    urls = urls

    def assert_cannot_calculate(self, n):
        response = self.get_fibonacci_response(n)
        self.assertContains(response=response, text='<p class="error">')
        self.assertNotContains(response=response, text='<p class="success">')

    def get_fibonacci(self, n):
        response = self.get_fibonacci_response(n)
        self.assertContains(response=response, text='<p class="success">')
        self.assertNotContains(response=response, text='<p class="error">')
        return int(
            re.search(
                r'<span id="result">(\d+)</span>', response.content
            ) .groups()[0])

    def get_fibonacci_response(self, n):
        client = Client()
        url = reverse('fibonacci')
        client.get(url, HTTP_ACCEPT='text/html')  # as the user, we first load the page
        post_response = client.post(
            url, {'n': n}, HTTP_ACCEPT='text/html')
        self.assertEquals('text/html', post_response['Content-Type'])
        return post_response

And update our implementation:

from django.views.generic import View
import json
from django.http import HttpResponse
from simple_fibonacci import calculator


class FibonacciView(View):

    def post(self, request):
        n = request.POST['n']
        error = None
        result = None
        try:
            result = calculator.fibonacci(int(n))
        except ValueError as e:
            error = unicode(e)
        content, content_type = \
            self.get_response_content_and_type_function(request)(
                n, error, result
            )
        return self.to_response(content, content_type)

    def get_response_content_and_type_function(self, request):
        return self.json_response

    def json_response(self, n, error, result):
        response_data = {'n': n}
        if error is not None:
            response_data['status'] = 'ERROR'
        else:
            response_data['status'] = 'OK'
            response_data['result'] = result
        return (json.dumps(response_data), 'application/json')

    def to_response(self, content, content_type):
        response = HttpResponse(content)
        response['Content-Type'] = content_type
        return response

That's the concept. The series will continue with me developing an app, and sharing relevant snippets and lessons learned from that project. Stay tuned!

What do you think? I would love if you would leave a comment - drop me an email at hello@zsoldosp.eu, tell me on Twitter!

Posted on in code, django, end-to-end, software, testing by

Share this post if you liked it - on Digg, Facebook, Google+, reddit, or Twitter

Your email address