Categories
Blog Software Development

Test Driven Development – A practical Example

Step Three

Until now, we have two test cases that handle empty input and invalid input. In this step, we are going to implement the first “valid” use case – addition of two numbers. Let’s start with the next test – the addition of positive integer numbers.

There are other important cases for addition: Negative Numbers, decimal numbers and the addition of more than two numbers. Let’s focus on the simplest use case and extend the other cases later. To prevent us from thinking about them all the time, let’s put them on the list:

  • Addition of two integers
  • Addition of decimals
  • Addition of negative numbers
  • Addition of multiple numbers
  • Subtraction
  • Multiplication
  • Division
  • Bracketed Expressions

The following is a test case for addition of two positive integer numbers:

def test_addition(self):
    output = self.app.calculate("3 + 5")
    self.assertEqual(output, "8")

    output = self.app.calculate("120+ 80")
    self.assertEqual(output, "200")

    output = self.app.calculate("0 +0")
    self.assertEqual(output, "0")

The test case uses three examples of addition. I chose examples that cover the most interesting cases: Addition of single-digit numbers, addition of multiple-digit numbers and addition of two zeros. Sometimes, the examples have whitespaces between the tokens and sometimes they don’t. Both variations should be allowed, so I chose the examples accordingly. I think that these examples will suffice to test the addition of two integers. Adding more examples would not add any extra value.

To make this test pass, we can use the swiss army knive of text processing: regular expressions. Python comes with the built in module re for regular expressions. The the method findAll can split the input of the user into tokens. Looking at our examples, a string is a valid mathematical expression under these conditions:

  1. It contains three tokens.
  2. The first and the last token are numbers.
  3. The second token is a ‘+’.

If these three conditions are fulfilled, the code will calculate the result. Our implementation of this rule looks like as follows:

import re

class CalculatorApplication():
    def calculate(self, expression):
        tokens = re.findall("[0-9]+|[+]", expression)
        if(len(tokens) == 3):
            if(tokens[0].isdigit() and tokens[2].isdigit() and tokens[1] == '+'):
                sum = int(tokens[0]) + int(tokens[2])
                return str(sum)

        if(expression == ""):
            return "0"
        else:
            return "Invalid expression"

This implementation makes this test pass. If you struggle with understanding how this regular expression works, check out the python documentation for the syntax.

Now the addition of two numbers is working. But we are not done yet with the TDD cycle. There is still the refactoring phase.

Refactoring

The addition of numbers is finally working, at least for a simple case. The code still looks rather short, but the class CalculatorApplication now performs several responsibilities:

  • It splits the string into tokens.
  • It checks if the string is a valid string.
  • It calculates the result.

Each class should have only one responsibility. If we don’t follow this rule, further extensions of our programm will be more complicated. Our task in this refactoring step is to put each of these tasks into a separate class. When the refactoring is finished, the class CalculatorApplication will only take the user input and orchestrate the new classes to calculate the output string for the user.

Tokenization

Let’s start with the class that will extract the parts of the mathematical expression from the user input. This step is called tokenization. That’s why we call the class Tokenizer. It will have a method tokenize that takes the string as in input and returns an array of tokens as an output.

As we want to develop our classes test-driven, we start with a new test case for Tokenizer. The first will check that an empty string is processed correctly:

import unittest

class TestTokenizer(unittest.TestCase):
    def test_tokenize_empty_string(self):
        tokenizer = Tokenizer()
        output = tokenizer.tokenize("")
        self.assertTrue(type(output) is list)
        self.assertEqual(len(output), 0)

We run the test, and it fails because the class Tokenizer does not exist yet. A simple implementation that will make this test case run and pass looks as follows:

class Tokenizer:
    def tokenize(self, string):
        return []

Next comes a test for tokenizing a non-empty string. We will skip the check if the output is a list because the previous test already checks this. As a rule, we don’t want to repeat the same check in different tests. It will not add value to our tests, and it will make refactoring harder.

The new test focusses on the content of the list.

def test_tokenize_string(self):
    output = self.tokenizer.tokenize("3 + 5")
    self.assertEqual(output, ['3', '+', '5'])

    output = self.tokenizer.tokenize("5 * -")
    self.assertEqual(output, ['5', '*', '-'])

    output = self.tokenizer.tokenize("120+ 8")
    self.assertEqual(output, ['120', '+', '8'])

Note that we have also extracted the creation of the Tokenizer instance to the setUp method, as we did in the test of CalculatorApplication.

For this test, we chose several example inputs from the tests of CalculatorApplication. Why do these examples contain both valid and invalid input? It is because the Tokenizer should produce an output list for both kinds of input. It does not have the responsibility to check for valid input. We will do this in the next refactoring step.

This new test fails with the following error message:

FAIL: test_tokenize_string (testTokenizer.TestTokenizer)
----------------------------------------------------------------------
Traceback (most recent call last):
  File ".../testTokenizer.py", line 15, in test_tokenize_string
    self.assertEqual(output, ['3', '+', '5'])
AssertionError: Lists differ: [] != ['3', '+', '5']

Second list contains 3 additional elements.
First extra element 0:
'3'

- []
+ ['3', '+', '5']

It tells us that the list returned by tokenize is not the same as the expected one. Of course, this is correct. Our code always returns an empty list. We can fix it by using the same regular expression that is currently used in CalculatorApplication.

import re

class Tokenizer:
    def tokenize(self, string):
        tokens = re.findall("[0-9]+|[+]", string)
        return tokens

When we run the test again, it still fails! Let’s examine the error message:

FAIL: test_tokenize_string (testTokenizer.TestTokenizer)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "..../testTokenizer.py", line 18, in test_tokenize_string
    self.assertEqual(output, ['5', '*', '-'])
AssertionError: Lists differ: ['5'] != ['5', '*', '-']

Second list contains 2 additional elements.
First extra element 1:
'*'

- ['5']
+ ['5', '*', '-']

It turns out that the regular expression does not cover all the operators that we used in our examples. Luckily, we did not miss this because we run our tests frequently.

By extending the regular expression to all four mathematical operators we can make the test pass.

...
tokens = re.findall("[0-9]+|[+*/\-]", string)
...

The tests for Tokenizer are green and there is no need to refactor Tokenizer or its tests at this stage. Our way is free to take care of the next responsibility – validation.

Validation

The second task that we need to remove from CalculatorApplication is the check whether the user input is a valid mathematical expression. Let’s call the class that performs this responsibility ExpressionValidator. We create a method validate. It receives the token list as an input and returns true if the input is a valid mathematical expression. An empty input is also valid. Let’s begin for a test for empty input.

import unittest

class TestExpressionValidator(unittest.TestCase):
    def setUp(self):
        self.validator = ExpressionValidator()

    def test_empty_expression(self):
        isValid = self.validator.validate([])
        self.assertTrue(isValid)

To make this test run and pass, we add a new class called ExpressionValidator:

class ExpressionValidator:
    def validate(self, tokens):
        return True

Our class ExpressionValidator is in place now. Let’s feed it with some valid input:

def test_valid_expression(self):
    isValid = self.validator.validate(['3', '+', '5'])
    self.assertTrue(isValid)

    isValid = self.validator.validate(['120', '+', '8'])
    self.assertTrue(isValid)

    isValid = self.validator.validate(['0', '+', '0'])
    self.assertTrue(isValid)

Which result do you expect when we execute this test? Of course it should pass, because validate always returns true. This is the simplest possible solution that makes all tests pass. But as soon as we add a test for invalid input, we cannot get away this easily. So let’s do the following:

def test_invalid_expression(self):
    # No operator
    isValid = self.validator.validate(['3', '5'])
    self.assertFalse(isValid)

    # Too many operators
    isValid = self.validator.validate(['120', '+', '+', '8'])
    self.assertFalse(isValid)

    isValid = self.validator.validate(['*'])
    self.assertFalse(isValid)

    # Missing operand at the end
    isValid = self.validator.validate(['0', '+', '0', '+', '0', '+'])
    self.assertFalse(isValid)

This test does fail! It means that our simple implementation is no longer sufficient. Let’s make a real implementation for validation.

class ExpressionValidator:
    def validate(self, tokens):
        if(len(tokens) == 0):
            return True

        if(len(tokens) == 3):
            if(tokens[0].isdigit() and tokens[2].isdigit() and tokens[1] == '+'):
                return True

        return False

The implementation is the same as in CalculatorApplication. Whenever there are three tokens that form an addition, the expression is valid. This check is very simple, but suffices for our use case of addition. Resist the temptation to implement all possible checks now. We will come to more sophisticated checks later, when we implement the other items from our list.

Calculation

The last responsibility that we need to take care of is the result calculation. The class that has this responsibility can be aptly named_Calculator_. Again, we start with the test for an empty expression. This should have 0 as a result.

class TestCalculator(unittest.TestCase):
    def setUp(self):
        self.calculator = Calculator()

    def test_empty_expression(self):
        result = self.calculator.calculate([])
        self.assertEqual(result, 0)

By adding the class Calculator with a method named calculate, we make the test pass.

Next we need a test for adding two numbers:

def test_addition_of_two_numbers(self):
    result = self.calculator.calculate(['3', '+', '5'])
    self.assertEqual(result, 8)

    result = self.calculator.calculate(['120', '+', '80'])
    self.assertEqual(result, 200)

Of course this test fails. We cannot fake the result easily, so we have to implement the addition now:

class Calculator:
    def calculate(self, tokens):
        if(len(tokens) == 3):
            if(tokens[0].isdigit() and tokens[2].isdigit() and tokens[1] == '+'):
                sum = int(tokens[0]) + int(tokens[2])
                return sum
        return 0

This implementation makes the test green. As the test for validation, it is very specific for the addition of two numbers. But we wait until we have added the test cases for the other use cases from our list. Only then should we try to come up with a more general design.

Integration

Now we have three classes that can take over the responsibilities from CalculatorApplication: Tokenizer, ExpressionValidator and Calculator. We know that they perform their responsibilities well, because we have green unit tests that validate their behaviour.

During the final step of our refactoring, we need to extract these responsibilites from CalculatorApplication. This class shall then use the responsibilites of our three new classes.

To do this, CalculatorApplication will need to have a reference to an instance of each of these classes. Then it can use these references from within calculate. How shall CalculatorApplication get these references? We could construct them in the initializer. But then we cannot check that CalculatorApplication actually calls our new classes.

Our best option is to use dependency injection. Then we can use mocks of our new classes in the unit tests of CalculatorApplication.

To do this, we need to change the test fixture class TestCalculatorApplication. It needs to create mocks of the three new classes and pass them to CalculatorApplication. And we need to rewrite the tests. Instead of presenting them with textual input and expecting textual output, we need to check for the correct calls to the mocks.

But wait! Don’t the tests that we already have for TestCalculatorApplication have much value to us, too? They test the correct behaviour of the complete application when we substitute mock objects with real objects. Thus they tell us if all our objects work well together. So it is better for us to keep these tests, too. Because these test will now test several real objects together, they are no longer unit tests. They have become integration test. Let’s rename the test fixture to TestIntegrationCalculatorApplication to show its new task.

We create a new test fixture class with the name TestCalculatorApplication. This class checks that CalculatorApplicationcalls all of its dependencies correctly. Let’s start with the calls to Tokenizer.

import unittest
from unittest.mock import create_autospec

from CalculatorApplication import CalculatorApplication
from Tokenizer import Tokenizer

class TestCalculatorApplication(unittest.TestCase):
    def setUp(self):
        self.tokenizer = create_autospec(Tokenizer)
        self.app = CalculatorApplication(self.tokenizer)

    def test_tokenizer_is_called(self):
        self.app.calculate("3 + 8")
        self.tokenizer.tokenize.assert_called_with("3 + 8")

This is our first test that uses a mock object. We create the mock by calling the method create_autospec from Python’s mock library. This method creates a mock that has the same methods as the class that we provide as a parameter.

The variable tokenizer is now a mock object. It has the same methods as Tokenizer, but no implementation. Because it has the same signature as Tokenizer, the initializer of CalculatorApplication will accept it instead of a Tokenizer instance. We provide the mock to the initializer of CalculatorApplication.

The test case test_tokenizer_is_called checks that calculate has called the method tokenize on our mock object during the test. The implementation of assert_called_with is provided for us automagically by Python’s mock library. This library will provide many more checks for us, some of which we will use later in the course of this example.

Running this test yields the error message that the initializer of CalculatorApplication does not take any argument. We have not implemented any initializer yet, so we do it now:

class CalculatorApplication():
    def __init__(self, tokenizer):
        self.tokenizer = tokenizer

Now the test shows a different error message:

FAIL: test_tokenizer_is_called (testCalculatorApplication.TestCalculatorApplication)
----------------------------------------------------------------------
Traceback (most recent call last):
  File ".../testCalculatorApplication.py", line 14, in test_tokenizer_is_called
    self.tokenizer.tokenize.assert_called_with("3 + 8")
  File ".../lib/python3.6/unittest/mock.py", line 805, in assert_called_with
    raise AssertionError('Expected call: %s\nNot called' % (expected,))
AssertionError: Expected call: tokenize('3 + 8')
Not called

This message originates from our mock. Its method tokenize was not called during the test. We need to call it from within calculate like this:

import re
    def calculate(self, expression):
        tokens = self.tokenizer.tokenize(expression)
        if(len(tokens) == 3):
            if(tokens[0].isdigit() and tokens[2].isdigit() and tokens[1] == '+'):
                sum = int(tokens[0]) + int(tokens[2])
                return str(sum)

        if(expression == ""):
            return "0"
        else:
            return "Invalid expression"

This fixes the error message from test_tokenizer_is_called. But now there are new error messages from our integration test:

ERROR: test_empty_input (testIntegrationCalculatorApplication.TestIntegrationCalculatorApplication)
----------------------------------------------------------------------
Traceback (most recent call last):
  File ".../testIntegrationCalculatorApplication.py", line 6, in setUp
    self.app = app = CalculatorApplication()
TypeError: __init__() missing 1 required positional argument: 'tokenizer'

The integration tests fail because the initializer of CalculatorApplication expects one parameter now. The integration test does not provide it yet. Which instance shall the integration test pass into the initializer? Our integration test should test the behaviour of real objects working together. That’s why we create an instance of Tokenizer, not a mock of it, and pass it to CalculatorApplication:

class TestIntegrationCalculatorApplication(unittest.TestCase):
    def setUp(self):
        self.tokenizer = Tokenizer()
        self.app = CalculatorApplication(self.tokenizer)

The reduction of duplicated code when we moved the creation of CalculatorApplication to the setUp method has paid off. Now we only have to change a single line instead of every test case.

After this change, all the tests pass. The integration tests show us that CalculatorApplication and Tokenizer work together correctly.

In the next step, we need to check that CalculatorApplication calls ExpressionValidator with the output of the Tokenizer. We need another mock for ExpressionValidator that we can pass into CalculatorApplication.

class TestCalculatorApplication(unittest.TestCase):
    def setUp(self):
        self.tokenizer = create_autospec(Tokenizer)
        self.expression_validator = create_autospec(ExpressionValidator)
        self.app = CalculatorApplication(self.tokenizer, self.expression_validator)

    ...

    def test_expression_validator_is_called(self):
        self.tokenizer.tokenize.return_value = ['1', '2', '3']
        self.app.calculate("3 + 8")
        self.expression_validator.validate.assert_called_with(['1', '2', '3'])

This test sets the return value of the method tokenize. The mock will return the list [‘1’, ‘2’, ‘3’] when it is called during the test. At the end of the test, we check that the list that tokenize has returned is passed as a parameter into validate. You may have noticed that the parameter lists of calculate and validate are different. This is to verify that calculate really uses the return values of tokenize instead of calculating the parameters to validate itself.

Let’s run the test to check that it fails. Then we fix it by calling validate from within calculate.

def calculate(self, expression):
    tokens = self.tokenizer.tokenize(expression)
    if(self.expression_validator.validate(tokens) == False):
        return "Invalid expression"

    if(len(tokens) == 3):
        if(tokens[0].isdigit() and tokens[2].isdigit() and tokens[1] == '+'):
            sum = int(tokens[0]) + int(tokens[2])
            return str(sum)

    return "0"

To make the integration tests pass, we again have to change the initialization of CalculatorApplication in the setUp method.

Finally, we can add a test for the call to Calculator.

def test_calculator_is_called(self):
    self.tokenizer.tokenize.return_value = ['1', '2', '3']
    self.expression_validator.validate.return_value = True
    self.app.calculate("3 + 8")
    self.calculator.calculate.assert_called_with(['1', '2', '3'])

This test sets the return value of tokenize. The returned list shall be provided as a parameter to calculate. We set the return value of validate to True, because otherwise calculate will not be called.

This new implementation of calculate makes the test pass.

def calculate(self, expression):
    tokens = self.tokenizer.tokenize(expression)
    if(self.expression_validator.validate(tokens) == False):
        return "Invalid expression"

    sum = self.calculator.calculate(tokens)

    if(len(tokens) == 3):
        if(tokens[0].isdigit() and tokens[2].isdigit() and tokens[1] == '+'):
            sum = int(tokens[0]) + int(tokens[2])
            return str(sum)

    return "0"

This implementation does not actually use the result of calculate because it calculates its own result. This is possible because there is no test yet that checks that the returned value is actually used. Let’s change that:

def test_result_of_calculate(self):
    self.calculator.calculate.return_value = 42
    result = self.app.calculate("")
    self.assertEqual(result, "42")

This test fails. We can fix it easilly by using the result of calculate.

def calculate(self, expression):
    tokens = self.tokenizer.tokenize(expression)
    if(self.expression_validator.validate(tokens) == False):
        return "Invalid expression"

    result = self.calculator.calculate(tokens)
    return str(result)

By now calculate has become very concise and can be easily understood. This is because we moved all the responsibilites to other classes.

Have you noticed that we have no test for the case that validate returns False yet? Let’s add that test:

def test_calculator_is_not_called_with_invalid_expression(self):
    self.expression_validator.validate.return_value = False
    result = self.app.calculate("3 + 8")
    self.assertEqual(result, "Invalid expression")
    self.calculator.calculate.assert_not_called()

The test checks that calculate returns “Invalid expression” and makes sure that calculate is not called when the expression is invalid. But this test does not fail because we already have an implementation for that case. You don’t want to have a test that has never failed at least once, because you don’t know if it is able to detect an error. We can enforce the failure by removing the check temporarily:

def calculate(self, expression):
    tokens = self.tokenizer.tokenize(expression)

    self.expression_validator.validate(tokens)
    #if(self.expression_validator.validate(tokens) == False):
    #   return "Invalid expression"

    result = self.calculator.calculate(tokens)
    return str(result)

This produces the error that we expect:

FAIL: test_invalid_input (testIntegrationCalculatorApplication.TestIntegrationCalculatorApplication)
----------------------------------------------------------------------
Traceback (most recent call last):
  File ".../testIntegrationCalculatorApplication.py", line 20, in test_invalid_input
    self.assertEqual(output, "Invalid expression")
AssertionError: '0' != 'Invalid expression'
- 0
+ Invalid expression

Now we can be satisfied that our test is working. We can revert our temporary changes.

As there are no more tests to write, we can finish the refactoring step.

Summary of Step Three

This step was a big one. Let’s review what we’ve done:

  • We implemented addition of two numbers in the simplest possible way – by implementing it inside calculate.
  • We noticed that CalculatorApplication had too many responsibilites and decided to split it up into several classes.
  • We created theree classes: Tokenizer, ExpressionValidator and Calculator. We did it test driven, too.
  • We chose to keep the existing tests for CalculatorApplication as integration test. This gave us the confidence to change the implementation of CalculatorApplication without breaking anything.
  • We changed CalculatorApplication step by step, writing a unit test for each step while always executing the integration tests. For the unit tests, we introduced our first mock objects.

At the end of Step Three, the application has taken a shape in which the addition of the new functionalities should be easier. As CalculatorApplication is only left with the responsibility of calling all the other classes, we can expect it to change little when new functionalites are added.

Leave a Reply

Your email address will not be published. Required fields are marked *