GAMR1520: Markup languages and scripting

python logo

Lab 4.1: Unit testing with python

Part of Week 4: Unit testing

General setup

For all lab exercises you should create a folder for the lab somewhere sensible.

Assuming you have a GAMR1520-labs folder, you should create a GAMR1520-labs/week_4 folder for this week and a GAMR1520-labs/week_4/lab_4.1 folder inside that.

Create a collection of scripts. If necessary, use folders to group related examples.

GAMR1520-labs
└─ week_4
    └─ lab_4.1
        ├─ experiment1.py
        └─ experiment2.py

Try to name your files better than this, the filename should reflect their content. For example, string_methods_.py, conditionals.py or while_loops.py.

Make sure your filenames give clues about what is inside each file. A future version of you will be looking back at these, trying to remember where a particular example was.

General approach

As you encounter new concepts, try to create examples for yourself that prove you understand what is going on. Try to break stuff, its a good way to learn. But always save a working version.

Modifying the example code is a good start, but try to write your own programmes from scratch, based on the example code. They might start very simple, but over the weeks you can develop them into more complex programmes.

Think of a programme you would like to write (don't be too ambitious). Break the problem down into small pieces and spend some time each session trying to solve a small problem.

This lab exercise will explore some simple approaches with the unittest module from the standard library. In particular, we will explore how to create unittest.TestCase instances with simple test methods.

Unit testing approaches have many advantages:

Table of contents

A simple example

The best way to understand testing is to consider some real scenarios. We will begin with some code to test.

def times_table(arg):
    return tuple(range(0, arg * 12, arg))

make a copy of this in a module called times_table.py.

The intent is that the function should return a tuple containing the given times table from 1 x arg to 12 x arg. However, the above code has two bugs which we can see if we exercise the function.

for i in range(1, 13):
    print(times_table(i))
(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)
(0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22)
(0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33)
(0, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44)
(0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55)
(0, 6, 12, 18, 24, 30, 36, 42, 48, 54, 60, 66)
(0, 7, 14, 21, 28, 35, 42, 49, 56, 63, 70, 77)
(0, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88)
(0, 9, 18, 27, 36, 45, 54, 63, 72, 81, 90, 99)
(0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110)
(0, 11, 22, 33, 44, 55, 66, 77, 88, 99, 110, 121)
(0, 12, 24, 36, 48, 60, 72, 84, 96, 108, 120, 132)

The result should begin at 1 x arg but it always begins at zero. It should end at 12 x arg but the last value is 11 x arg.

This is a classic off by one error which occurs in two places in the code.

Although this may be an easy issue to spot, we can write some tests which will catch the problem for us.

Our first test

We want to create some code to test the times_table.py module. To create a test we need to import the unittest.TestCase class and inherit from it.

Inheriting means we are creating a customised version of the unittest.TestCase class. As we shall see, our class will inherit capabilities which make testing easy.

import unittest

from times_table import times_table

class TestTimesTable(unittest.TestCase):
    def test_with_one(self):
        msg = "One times table isn't correct"
        expected = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
        actual = times_table(1)
        self.assertEqual(actual, expected, msg)

if __name__ == "__main__":
    unittest.main()

Any method that begins with the word 'test' is treated as a test. Our class has one test method.

The test creates an expected variable and assigns a tuple literal containing the integers 1 through 12. This is the result we want from our function. It then passes the value 1 into our function to create the actual result. This is what the function really produces.

The test includes an assertion using self.assertEqual (this is a method inherited from the TestCase class) which asserts the the actual value should be the same as the expected value. The test will pass if the values are identical. It will fail if they are different.

Finally, the code calls the unittest.main() method. This is what triggers the tests to run. In this case we only have one test, if there were more tests, even in multiple classes, this method call would run them all in turn.

Save the above code as test_times_table.py and run the module.

ERROR MESSAGES ARE GOOD

When working with tests and with python in general it’s important to realise that errors are your friends. When we get errors, we should be happy. Because without the errors we would be lost in chaos with no idea what to do. See learning from errors for more on this.

Nobody writes perfect code. Finding bugs can be a painful process. Our error messages show us exactly what went wrong. They often make solving a problem a lot easier. At least they help us to solve the right problem.

We write tests precisely because we want to raise errors if something doesn’t work.

The output looks like this.

F
======================================================================
FAIL: test_with_one (__main__.TestTimesTable)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "path/to/test_times_table.py", line 10, in test_with_one
    self.assertEqual(actual, expected, msg)
AssertionError: Tuples differ: (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11) != (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)

First differing element 0:
0
1

- (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)
?  ---

+ (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
?                                   ++++
 : One times table isn't correct

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)

The output is a bit confusing, but we should be able to tell that the test is failing.

Starting at the top, the output begins with a summary showing the results of each test.

F

In our case, we have only one test and it failed.

For multiple tests, there would be more characters. e.g. 'FF.E' would indicate two failures followed by a pass, followed by an error.

We will see how to produce more useful output shortly.

This is followed by information about each failed test in turn. In our case, there is only one failure report.

======================================================================
FAIL: test_with_one (__main__.TestTimesTable)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/test_times_table.py", line 10, in test_with_one
    self.assertEqual(actual, expected, msg)
AssertionError: Tuples differ: (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11) != (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)

First differing element 0:
0
1

- (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)
?  ---

+ (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
?                                   ++++
 : One times table isn't correct

We can see the code raised an AssertionError with the message Tuples differ: (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11) != (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12). It goes on to provide even more detail about the difference between the expected and actual result and ends with our custom error message One times table isn’t correct.

The output ends with another summary showing how many tests were executed and how many failures (and errors) there were.

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)

Let’s tweak our function to fix the first error.

def times_table(arg):
    return tuple(range(arg, arg * 12, arg))

We replaced a 0 with arg so the result should no longer start with zero.

Now running the tests produces a slightly different error message.

AssertionError: Tuples differ: (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11) != (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)

Second tuple contains 1 additional elements.
First extra element 11:
12

- (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)
+ (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
?                                   ++++
 : One times table isn't correct

The error is telling us that our expected tuple contains an extra value and it’s pointing to the exact problem.

If we fix this final issue, then our test passes.

def times_table(arg):
    return tuple(range(arg, arg * 13, arg))

We’ve extended arg * 12 to arg * 13 because the range constructor end value is exclusive, just like slicing.

Execute the test and you should see that the output for passing tests is much quieter.

.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

The summary shows that one test was executed and it passed. The single dot . on the first line represents the single passing test.

We can produce slightly more output if we set the verbosity to 2 in our test code.

if __name__ == "__main__":
    unittest.main(verbosity=2)
test_with_one (__main__.TestTimesTable) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

The single dot has now been replaced by a more verbose output which lists each test by name. There is still only one passing test, but this output can be useful as a summary of exactly what is passing and what is failing.

A few more tests

So, we can see that tests provide concrete feedback which will show us exactly where the problem is.

This is particularly useful if we have complex code that is often changed. In complex code, every change that adds a new feature has the potential to break an existing feature. By writing a decent set of tests, we can test our code every time we make a change to confirm we haven’t broken it.

Imagine you were responsible for maintaining a library that was used by hundreds of developers. No matter how simple the code, when releasing an update any change to the behaviour would break your users code and prevent them from using the new update. So a good testing strategy is essential.

Now, our function seems to work, we can add a few additional test cases to specify what should happen with various values including extreme values and error values.

First, we can add something very similar for 2 and 12.

import unittest

from times_table import times_table

class TestTimesTable(unittest.TestCase):
    def test_with_one(self):
        msg = "One times table isn't correct"
        expected = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
        actual = times_table(1)
        self.assertEqual(actual, expected, msg)

    def test_with_two(self):
        msg = "Two times table isn't correct"
        expected = (2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24)
        actual = times_table(2)
        self.assertEqual(actual, expected, msg)

    def test_with_twelve(self):
        msg = "Twelve times table isn't correct"
        expected = (12, 24, 36, 48, 60, 72, 84, 96, 108, 120, 132, 144)
        actual = times_table(12)
        self.assertEqual(actual, expected, msg)

if __name__ == "__main__":
    unittest.main(verbosity=2)
test_with_one (__main__.TestTimesTable) ... ok
test_with_twelve (__main__.TestTimesTable) ... ok
test_with_two (__main__.TestTimesTable) ... ok

----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK

Great, everything is working as we want.

Notice the order in which the tests run is alphanumeric.

We wrote a few tests to define the correct behaviour when the function is passed appropriate data. This is called testing the happy path. If they are important, we can also consider error states.

Testing bad input

Now, perhaps we confirm that our function raises a TypeError if a string is passed. We can add a test to check this.

import unittest

from times_table import times_table

class TestTimesTable(unittest.TestCase):
    def test_with_one(self):
        msg = "One times table isn't correct"
        expected = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
        actual = times_table(1)
        self.assertEqual(actual, expected, msg)

    def test_with_two(self):
        msg = "Two times table isn't correct"
        expected = (2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24)
        actual = times_table(2)
        self.assertEqual(actual, expected, msg)

    def test_with_twelve(self):
        msg = "Twelve times table isn't correct"
        expected = (12, 24, 36, 48, 60, 72, 84, 96, 108, 120, 132, 144)
        actual = times_table(12)
        self.assertEqual(actual, expected, msg)

    def test_with_hello(self):
        msg = "'hello' should raise TypeError"
        with self.assertRaises(TypeError, msg=msg):
            times_table('hello')

if __name__ == "__main__":
    unittest.main(verbosity=2)
test_with_hello (__main__.TestTimesTable) ... ok
test_with_one (__main__.TestTimesTable) ... ok
test_with_twelve (__main__.TestTimesTable) ... ok
test_with_two (__main__.TestTimesTable) ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.007s

OK

So it turns out this is already true and the test also passes.

This test will need to be changed later.

Adapting to changing requirements

Now we want to allow our function to accept numeric strings. So we add another test which passes '10' into our function and expects it to work.

import unittest

from times_table import times_table

class TestTimesTable(unittest.TestCase):
    def test_with_one(self):
        msg = "One times table isn't correct"
        expected = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
        actual = times_table(1)
        self.assertEqual(actual, expected, msg)

    def test_with_two(self):
        msg = "Two times table isn't correct"
        expected = (2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24)
        actual = times_table(2)
        self.assertEqual(actual, expected, msg)

    def test_with_twelve(self):
        msg = "Twelve times table isn't correct"
        expected = (12, 24, 36, 48, 60, 72, 84, 96, 108, 120, 132, 144)
        actual = times_table(12)
        self.assertEqual(actual, expected, msg)

    def test_with_hello(self):
        msg = "'hello' should raise TypeError"
        with self.assertRaises(TypeError, msg=msg):
            times_table('hello')

    def test_with_numeric_string(self):
        msg = "'10' should be interpreted as 10"
        expected = (10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120)
        actual = times_table('10')
        self.assertEqual(actual, expected, msg)

if __name__ == "__main__":
    unittest.main(verbosity=2)
test_with_hello (__main__.TestTimesTable) ... ok
test_with_numeric_string (__main__.TestTimesTable) ... ERROR
test_with_one (__main__.TestTimesTable) ... ok
test_with_twelve (__main__.TestTimesTable) ... ok
test_with_two (__main__.TestTimesTable) ... ok

======================================================================
ERROR: test_with_numeric_string (__main__.TestTimesTable)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "path/to/test_times_table.py", line 32, in test_with_numeric_string
    actual = times_table('10')
  File "path/to/times_table.py", line 3, in times_table
    return tuple(range(arg, arg * 13, arg))
TypeError: 'str' object cannot be interpreted as an integer

----------------------------------------------------------------------
Ran 5 tests in 0.004s

FAILED (errors=1)

Strictly speaking the test_with_numeric_string() test didn’t fail, it raised an error. Our function raised an exception which prevented the test from completing.

The new test passes a numeric string into our function which raises the same TypeError as any string would.

actual = times_table('10')

The error comes from passing a string into the range() function which only accepts integers. We can fix this, and implement the new feature, by converting our arg into an integer before we pass it into range().

def times_table(arg):
    arg = int(arg)
    return tuple(range(0, arg * 12, arg))

Now we can run our tests again and get this output.

test_with_hello (__main__.TestTimesTable) ... ERROR
test_with_numeric_string (__main__.TestTimesTable) ... ok
test_with_one (__main__.TestTimesTable) ... ok
test_with_twelve (__main__.TestTimesTable) ... ok
test_with_two (__main__.TestTimesTable) ... ok

======================================================================
ERROR: test_with_hello (__main__.TestTimesTable)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "path/to/test_times_table.py", line 27, in test_with_hello
    times_table('hello')
  File "path/to/times_table.py", line 2, in times_table
    arg = int(arg)
ValueError: invalid literal for int() with base 10: 'hello'

----------------------------------------------------------------------
Ran 5 tests in 0.002s

FAILED (errors=1)

Study the output. You should see that the new test test_with_numeric_string() passed.

Its test_with_hello() that raises an error. This is because we cannot convert 'hello' into an integer. Because a string is an acceptable type (some strings can be converted to integers), the error is actually a ValueError not a TypeError.

At this point we think… It seems reasonable that passing a non-numeric string is actually a ValueError because a numeric string is now allowed. So we can change our test accordingly.

Changing tests vs changing code is a tricky choice requiring careful thought.

The updated test looks like this:

    def test_with_hello(self):
        msg = "'hello' should raise ValueError"
        with self.assertRaises(ValueError, msg=msg):
            times_table('hello')

with this change, we now have five passing tests.

test_with_hello (__main__.TestTimesTable) ... ok
test_with_numeric_string (__main__.TestTimesTable) ... ok
test_with_one (__main__.TestTimesTable) ... ok
test_with_twelve (__main__.TestTimesTable) ... ok
test_with_two (__main__.TestTimesTable) ... ok

----------------------------------------------------------------------
Ran 5 tests in 0.005s

OK

Add a few tests

We should think about the kinds of data a user might pass into our function and decide what should happen in each case.

Write tests that define how our function should handle…

  • negative values (e.g. test_with_minus_five())
  • extreme values (e.g. test_with_one_billion())
  • floating point values (e.g. test_with_three_point_six())

You will need to decide what the correct output is in each case.

Run the tests. If they don’t pass then you need to decide whether you change the code or change the test to make them pass.

A test-driven approach

OK, so the simple example above demonstrates the basic principles of the unittest.TestCase() API. Rather than writing tests for existing code, we can start with a test and write the code necessary for the tests to pass. Our tests can explain what should happen. As we develop our code, we can handle each test one at a time. Once all the tests pass then the code should be complete.

We will create a very simple example and walk though it slowly, one step at a time.

In a real situation you will often solve many issues in one pass, here we will literally try to hit every error possible.

Start by creating a test_coordinate.py file and enter the following code.

import unittest

from coordinate import Coordinate


class TestCoordinate(unittest.TestCase):

    def test_values(self):
        c = Coordinate(10, 20)
        self.assertEqual(c.x, 10)
        self.assertEqual(c.y, 20)

if __name__ == "__main__":
    unittest.main()

The above code is familiar. We can see that it is testing a Coordinate class like we dealt with when we introduced object-oriented python.

The test code gives us some important clues about what we need to do to make the tests pass.

Test code always gives everything required to make the tests pass. Though, just like any code, tests can be poorly written and not very useful.

Checking the imports

If we attempt to run the tests (i.e. execute the test_coordinate module) we get an immediate ModuleNotFoundError.

Traceback (most recent call last):
  File "path/to/test_coordinate.py", line 3, in <module>
    from coordinate import Coordinate
ModuleNotFoundError: No module named 'coordinate'

This is because, even before the test code is executed, our test_coordinate module imports a Coordinate class (the capitalised name implies a class) from a coordinate module.

This means we need to define a Coordinate class within a coordinate module (i.e. a coordinate.py file within the same folder as the test code).

Within a new coordinate.py file, we can include a class definition for a Coordinate class, like this.

class Coordinate:
    pass

If the coordinate.py file existed but we didn’t define the correct class then we would get an ImportError when the class wasn’t found.

Traceback (most recent call last):
  File "path/to/test_coordinate.py", line 3, in <module>
    from coordinate import Coordinate
ImportError: cannot import name 'Coordinate' from 'coordinate' (path/to/coordinate.py)

Running the tests

With the import statement now working, our test code will be executed when we run the test_coordinate.py module. Initially, we get a failing test.

E
======================================================================
ERROR: test_values (__main__.TestCoordinate)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "path/to/test_coordinate.py", line 9, in test_values
    c = Coordinate(10, 20)
TypeError: Coordinate() takes no arguments

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (errors=1)

Notice the last line of output indicated our test not only failed but that our test code raised an error.

FAILED (errors=1)

We can see the error is pointing towards line 9, the first line of the test_values test.

  File "path/to/test_coordinate.py", line 9, in test_values
    c = Coordinate(10, 20)
TypeError: Coordinate() takes no arguments

Our test assumes that our class has a constructor which takes two positional arguments. Our class takes no constructor arguments (because it has no custom __init__() method).

So we should expand our code to accept two arguments into the constructor.

class Coordinate:
    def __init__(self, x, y):
        pass

Running the tests now produces a different error.

E
======================================================================
ERROR: test_values (__main__.TestCoordinate)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "path/to/test_coordinate.py", line 10, in test_values
    self.assertEqual(c.x, 10)
AttributeError: 'Coordinate' object has no attribute 'x'

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (errors=1)

Again, the test output tells us exactly what is going wrong. Our test includes an assertion to check that the instance attribute x is equal to 10. But the code raises an AttributeError because our instance doesn’t have an x attribute.

We can update out Coordinate.__init__() method to assign the provided arguments to the x and y attributes.

We pre-empted the next error here because lines 10 and 11 of the test_coordinate module are basically identical but line 11 is checking the value of the y instance attribute.

class Coordinate:
    def __init__(self, x, y):
        self.x = x
        self.y = y

With this update, our test passes. We can be sure that the x and y values are properly initialised.

The full set of tests

The Coordinate class is clearly more complicated than this basic test implies. Here’s the full suite of tests to capture the functionality described in previous exercises.

import unittest

from coordinate import Coordinate


class TestCoordinate(unittest.TestCase):

    def test_values(self):
        c = Coordinate(10, 20)
        self.assertEqual(c.x, 10)
        self.assertEqual(c.y, 20)

    def test_invertion(self):
        c = Coordinate(10, 20)
        c.invert()
        self.assertEqual(c.x, 20)
        self.assertEqual(c.y, 10)

    def test_addition(self):
        c = Coordinate(10, 20) + Coordinate(20, 10)        
        self.assertEqual(c.x, 30)
        self.assertEqual(c.y, 30)

    def test_subtraction(self):
        c = Coordinate(10, 20) - Coordinate(20, 10)        
        self.assertEqual(c.x, -10)
        self.assertEqual(c.y, 10)

    def test_multiplication(self):
        c = Coordinate(10, 20) * Coordinate(20, 10)        
        self.assertEqual(c.y, 200)
        self.assertEqual(c.x, 200)

    def test_division(self):
        c = Coordinate(10, 20) / Coordinate(20, 10)        
        self.assertEqual(c.x, 0.5)
        self.assertEqual(c.y, 2)

    def test_str(self):
        cstr = str(Coordinate(10, 20))        
        self.assertEqual(cstr, "(10, 20)")

    def test_repr(self):
        crepr = repr(Coordinate(10, 20))        
        self.assertEqual(crepr, "Coordinate(x=10, y=20)")

if __name__ == "__main__":
    unittest.main()

Make all the tests pass

Expand the Coordinate class until all the tests pass.

This will involve adding the following methods to the Coordinate class:

  • invert(self)
  • __str__(self)
  • __repr__(self).
  • __add__(self, other)
  • __sub__(self, other)
  • __mul__(self, other)
  • __truediv__(self, other)

Another set of tests

The following tests cover many scenarios for using our shopping list class.

Notice we have made a minor tweak to how the padding works. The pad keyword argument now applies to each side and defaults to 5.

import unittest
from shopping import ShoppingList

class TestShoppingDefaults(unittest.TestCase):

    def test_empty(self):
        """Testing an empty list"""
        shopping = ShoppingList()
        res = str(shopping).splitlines()
        self.assertEqual(res[0], "********************")
        self.assertEqual(res[1], "*     shopping     *")
        self.assertEqual(res[2], "********************")
        self.assertEqual(res[3], "********************")

    def test_args(self):
        """Basic test to show the constructor works"""
        shopping = ShoppingList('apples', 'bananas', 'cherries')
        res = str(shopping).splitlines()
        self.assertEqual(res[0], "********************")
        self.assertEqual(res[1], "*     shopping     *")
        self.assertEqual(res[2], "********************")
        self.assertEqual(res[3], "*      apples      *")
        self.assertEqual(res[4], "*     bananas      *")
        self.assertEqual(res[5], "*     cherries     *")
        self.assertEqual(res[6], "********************")

    def test_iadd(self):
        """Test to show adding items works"""
        shopping = ShoppingList('apples', 'bananas')
        shopping += 'cherries'
        res = str(shopping).splitlines()
        self.assertEqual(res[0], "********************")
        self.assertEqual(res[1], "*     shopping     *")
        self.assertEqual(res[2], "********************")
        self.assertEqual(res[3], "*      apples      *")
        self.assertEqual(res[4], "*     bananas      *")
        self.assertEqual(res[5], "*     cherries     *")
        self.assertEqual(res[6], "********************")

    def test_isub(self):
        """Test to show removing items works"""
        shopping = ShoppingList('apples', 'bananas', 'cherries')
        shopping -= 'bananas'
        res = str(shopping).splitlines()
        self.assertEqual(res[0], "********************")
        self.assertEqual(res[1], "*     shopping     *")
        self.assertEqual(res[2], "********************")
        self.assertEqual(res[3], "*      apples      *")
        self.assertEqual(res[4], "*     cherries     *")
        self.assertEqual(res[5], "********************")

    def test_alternative_ch(self):
        """Test to show a custom character can be used"""
        shopping = ShoppingList(
            'apples', 'bananas', 'cherries', 
            ch='+'
        )
        res = str(shopping).splitlines()
        self.assertEqual(res[0], "++++++++++++++++++++")
        self.assertEqual(res[1], "+     shopping     +")
        self.assertEqual(res[2], "++++++++++++++++++++")
        self.assertEqual(res[3], "+      apples      +")
        self.assertEqual(res[4], "+     bananas      +")
        self.assertEqual(res[5], "+     cherries     +")
        self.assertEqual(res[6], "++++++++++++++++++++")


    def test_narrow_no_pad(self):
        """A narrow list with no padding"""
        shopping = ShoppingList(
            'a', 'b', 'c', 'd', 
            title="!", pad=0
        )
        res = str(shopping).splitlines()
        self.assertEqual(res[0], "***")
        self.assertEqual(res[1], "*!*")
        self.assertEqual(res[2], "***")
        self.assertEqual(res[3], "*a*")
        self.assertEqual(res[4], "*b*")
        self.assertEqual(res[5], "*c*")
        self.assertEqual(res[6], "*d*")
        self.assertEqual(res[7], "***")

    def test_narrow_with_pad(self):
        """A narrow list with padding"""
        shopping = ShoppingList(
            'a', 'b', 'c', 'd', 
            title="!", pad=10
        )
        res = str(shopping).splitlines()
        self.assertEqual(res[0], "***********************")
        self.assertEqual(res[1], "*          !          *")
        self.assertEqual(res[2], "***********************")
        self.assertEqual(res[3], "*          a          *")
        self.assertEqual(res[4], "*          b          *")
        self.assertEqual(res[5], "*          c          *")
        self.assertEqual(res[6], "*          d          *")
        self.assertEqual(res[7], "***********************")


    def test_wide_no_pad(self):
        """A wide list with no padding"""
        shopping = ShoppingList(
            'a', 'b', 'abracadabra', 
            title="!", pad=0
        )
        res = str(shopping).splitlines()
        self.assertEqual(res[0], "*************")
        self.assertEqual(res[1], "*     !     *")
        self.assertEqual(res[2], "*************")
        self.assertEqual(res[3], "*     a     *")
        self.assertEqual(res[4], "*     b     *")
        self.assertEqual(res[5], "*abracadabra*")
        self.assertEqual(res[6], "*************")

    def test_wide_title_no_pad(self):
        shopping = ShoppingList('!', title="abracadabra", pad=0)
        res = str(shopping).splitlines()
        self.assertEqual(res[0], "*************")
        self.assertEqual(res[1], "*abracadabra*")
        self.assertEqual(res[2], "*************")
        self.assertEqual(res[3], "*     !     *")
        self.assertEqual(res[4], "*************")

    def test_wide_and_padded(self):
        shopping = ShoppingList('!', title="!", pad=2)
        shopping += 'a long item in the list'
        res = str(shopping).splitlines()
        self.assertEqual(res[0], "*****************************")
        self.assertEqual(res[1], "*             !             *")
        self.assertEqual(res[2], "*****************************")
        self.assertEqual(res[3], "*             !             *")
        self.assertEqual(res[4], "*  a long item in the list  *")
        self.assertEqual(res[5], "*****************************")

    def test_wide_title_and_padded(self):
        shopping = ShoppingList(
            '!', '!', 
            title="a long title!", pad=5
        )
        res = str(shopping).splitlines()
        self.assertEqual(res[0], "*************************")
        self.assertEqual(res[1], "*     a long title!     *")
        self.assertEqual(res[2], "*************************")
        self.assertEqual(res[3], "*           !           *")
        self.assertEqual(res[4], "*           !           *")
        self.assertEqual(res[5], "*************************")


if __name__ == "__main__":
    unittest.main(verbosity=2)

Run the tests and make sure they all pass.