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!