Functions and Classes in Python

Edge and corner cases (literally)

Overview:

  • Teaching: 10 min
  • Exercises: 10 min

Questions

  • What are edge cases?
  • What are corner cases?

Objectives

  • Understand that not all items in your datasets are equivalent
  • Know that in your tests you have to treat extreme cases differently
  • Understand how to identify these cases with specific tests, and when tests fail

So far we have been testing simple functions that take, at most, two parameters as arguments. There are no complex algorithms or logic at work, so the functions shouldn't behave differently depending on the input. The failure of these functions is down to our (I mean my) poor programming, rather than anything fundamentally complicated in their workings.

In practice, this is often not the case. Functions might require many parameters and their execution and output can vary wildly depending on the input. In many cases there might be a normal range of parameter space where the function output is easy to predict, then other regions where the behaviour can be much more complex. When writing tests it is important that you cover as many cases as possible. You should push the boundaries of your software to make sure that it works as expected across the entire range of input under which it is meant to operate. This is known as having good code coverage.

Testing extreme values is often referred to as covering edge and corner cases. Typically, edge cases test situations where one parameter is at an extreme, while corner cases test two (or more in a multidimensional problems) edge cases simultaneously. However, sometimes the definition isn't so clear. (The principle of testing unusual input holds, though.)

In this section we will make use of the provided grid package. This provides functionality for working with cells in a two-dimensional grid, like the 4x4 one shown below. (The values in each cell indicate the (x, y) position of the cell within the grid.)

(0,3) (1,3) (2,3) (3,3)
(0,2) (1,2) (2,2) (3,2)
(0,1) (1,1) (2,1) (3,1)
(0,0) (1,0) (2,0) (3,0)

Let's import the Cell class from the package and see how it works.

In [ ]:
from grid import Cell
help(Cell)

We'll now create a Cell object that sits in the bulk of the grid and test that its neighbours are correct.

# grid/test/test_cell.py
def test_bulk():
    """ Test that a cell in the bulk of the grid is correct. """

    # Instantiate a cell in the bulk of a 4x4 grid.
    c = Cell(2, 2, 4, 4)

    # Make sure that the cell has 4 neighbours.
    assert c.neighbours() == 4

    # Check the coordinates of the neighbours.
    assert c.left()  == (1, 2)
    assert c.right() == (3, 2)
    assert c.up()    == (2, 3)
    assert c.down()  == (2, 1)

Here we've instantiated a cell that sits at position (2, 2) in a 4x4 grid. Like python, we choose to index from 0.

Now let's check the neighbours of the cell. It should have 4 neighbours: (1, 2) to the left, (3, 2) to the right, (2, 1) below, and (2, 3) above.

Let's run the unit test with pytest.

In [ ]:
!pytest grid/test/test_cell.py::test_bulk

Great, everything worked as expected. But that was easy, we could just work out the neighbours straight from the cell position by just adding and subtracting 1.

Now let's check a cell on the left-hand edge of the grid at position (0, 2). This should have 3 neighbours: one to the right, one below, and one above.

# grid/test/test_cell.py
def test_left_edge():
    """ Test that a cell on the left edge of the grid is correct. """

    # Instantiate a cell on the left edge of a 4x4 grid.
    c = Cell(0, 2, 4, 4)

    # Make sure that the cell has 3 neighbours.
    assert c.neighbours() == 3

    # Check the coordinates of the neighbours.
    assert c.left()  == None
    assert c.right() == (1, 2)
    assert c.up()    == (0, 3)
    assert c.down()  == (0, 1)
In [ ]:
!pytest grid/test/test_cell.py::test_left_edge

Fantastic, it works! The behaviour of the Cell object was fundamentally different because of the input (we triggered a different set of conditions).

Let's now check a cell at the bottom left-corner. This should only have two neigbours: one to the right, and one above.

# grid/test/test_cell.py
def test_bottom_left_corner():
    """ Test that a cell on the bottom left corner of the grid is correct. """

    # Instantiate a cell at the bottom left corner of a 4x4 grid.
    c = Cell(0, 0, 4, 4)

    # Make sure that the cell has 2 neighbours.
    assert c.neighbours() == 2

    # Check the coordinates of the neighbours.
    assert c.left()  == None
    assert c.right() == (1, 0)
    assert c.up()    == (0, 1)
    assert c.down()  == None
In [ ]:
!pytest grid/test/test_cell.py::test_bottom_left_corner

Once again a different condition has been triggered by our change of input. Here we have tested a corner case.

Integration testing

So far we have been testing functions and objects in isolation, so called unit testing. However, it is likely that you will write software with multiple objects that need to work together in order to do something useful. The process of checking that different pieces of code work together as intended is often called integration testing.

The grid module also contains a Grid class that generates a matrix of Cell objects and stores them internally. The user can then manipulate the cells by filling or emptying them. Let's import the class and see how it works.

In [ ]:
from grid import Grid
help(Grid)

Let's have a play with the class.

In [ ]:
grid = Grid(10, 10)
grid.fill(0, 0)
assert grid.nFilled() == 1
In [ ]:
grid.fill(3, 7)
assert grid.nFilled() == 2
In [ ]:
grid.empty(0, 0)
assert grid.nFilled() == 1
In [ ]:
assert grid.cell(3, 7).occupied()
In [ ]:
assert not grid.cell(0, 0).occupied()

Great, it looks like the two classes are working together as expected...

Exercises

Here you'll be modifying the following files:

  • grid/grid.py
  • grid/test/test_grid.py.

1

Run the unit tests for the entire grid package.

2

Fix the bug in grid.py and verify that the tests pass. Do the tests pass when the grid isn't square?

int neigbours are checked in the private _initialiseNeighbours method.

3

Create a new file grid/test/test_grid.py to test the Grid class. You should test that the fill and empty functions behave as expected. The rules are that any cell in the grid can only be filled once.

4

Fix any bugs that you find and validate that your tests pass.

Bonus

Notice that the fill and empty methods of the Grid class take an optional keyword argument, debug. Can you replace the tests that you wrote for Exercise 3 with a method called _validate. This should check that the internal state of the Grid object is consistent any time the fill and empty methods are called with the option debug=True. This is an alternative way of testing, known as runtime testing.

Key Points

  • Edge and corner cases occur at extremes of your testing
  • They can cause bulk tests to fail, correctly
  • Typically you will need to write specific tests for different classes of edge/corner cases
  • This can be particularly pertinent with integration testing when different componenets are combined