In the last two posts, I talked about the philosophy of testing, and showed the basic framework that Python provides.

The Triangle Example

This example is a classic from Glenford Myers’ The Art of Software Testing, first published in 1979. For all of the advances we’ve seen since then, it’s still a fantastic example of the things that can cause problems in software.

Given the integer lengths of the three sides of a triangle, determine whether the triangle is scalene, isoceles, or equilateral.

For a basic refresher:

  • Equilateral Triangle: a triangle with 3 equal-length sides
  • Isoceles Triangle: a triangle with 2 equal-length sides
  • Scalene Triangle: a triangle with no equal-length sides

Here’s a first crack at implementing this function:

def classify_triangle(a, b, c):
  if a == b == c:
    return 'equilateral'
  elif (a == b) or (b == c):
    return 'isoceles'
  else:
    return 'scalene'

You probably don’t want to use this code for anything, because it has errors in it.

To start out with, lets test the equilateral case. First, we make a test that will fail:

import unittest
# this pulls our function in from the other file:
from triangle import classify_triangle  
 
class ClassifyTriangleTest(unittest.TestCase):
    def testEquilateral(self):
        self.assert_(False)
 
if __name__ == '__main__':
    unittest.main()
$ python basic_triangle_test.py
F
======================================================================
FAIL: testEquilateral (__main__.ClassifyTriangleTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "basic_triangle_test.py", line 7, in testEquilateral
    self.assert_(False)
AssertionError
 
----------------------------------------------------------------------
Ran 1 test in 0.000s
 
FAILED (failures=1)

As expected, the test is executing, but failing. Now we can fill in the test, using checking against an incorrect result, to ensure that it’s working:

import unittest
# this pulls our function in from the other file:
from triangle import classify_triangle  
 
class ClassifyTriangleTest(unittest.TestCase):
    def testEquilateral(self):
        result = classify_triangle(2, 2, 2)
        self.assertEquals(result, None)
 
if __name__ == '__main__':
    unittest.main()

Running it shows us that it’s failing (but working properly):

$ python basic_triangle_test.py
F
======================================================================
FAIL: testEquilateral (__main__.ClassifyTriangleTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "basic_triangle_test.py", line 8, in testEquilateral
    self.assertEquals(result, None)
AssertionError: 'equilateral' != None
 
----------------------------------------------------------------------
Ran 1 test in 0.001s
 
FAILED (failures=1)

You can see that the test is failing because the result is coming back as ‘equilateral’, but we’re asserting that it’s equal to None. I do this while I’m writing my tests to ensure that I’m actually computing the value I’m expecting.

Changing the assert statement corrects the test and makes it run successfully:

import unittest
# this pulls our function in from the other file:
from triangle import classify_triangle  
 
class ClassifyTriangleTest(unittest.TestCase):
    def testEquilateral(self):
        result = classify_triangle(2, 2, 2)
        self.assertEquals(result, 'equilateral')
 
if __name__ == '__main__':
    unittest.main()
$ python basic_triangle_test.py
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
 
OK

Hopefully this next step is going to be pretty clear. Instead of writing about going through the same process for the isoceles and scalene cases, I’m going to jump ahead a smidge.

Here’s the completed set of basic tests:

import unittest
# this pulls our function in from the other file:
from triangle import classify_triangle  
 
class ClassifyTriangleTest(unittest.TestCase):
    def testEquilateral(self):
        result = classify_triangle(2, 2, 2)
        self.assertEquals(result, 'equilateral')
 
    def testIsoceles(self):
        result = classify_triangle(2, 2, 1)
        self.assertEquals(result, 'isoceles')
 
    def testScalene(self):
        result = classify_triangle(3, 4, 5)
        self.assertEquals(result, 'scalene')
 
if __name__ == '__main__':
    unittest.main()

And here’s the command-line output:

$ python basic_triangle_test.py
...
----------------------------------------------------------------------
Ran 3 tests in 0.000s
 
OK

We’ve Tested Everything! We’re Done!

… not so fast.

There’s still a few problems. The first one I’m going to address is in testIsoceles(). This test is currently only checking one case: when a==b. Lets beef it up a bit, by addressing all three permutations:

    def testIsoceles(self):
        result = classify_triangle(2, 2, 1)
        self.assertEquals(result, 'isoceles')
 
        result = classify_triangle(2, 1, 2)
        self.assertEquals(result, 'isoceles')
 
        result = classify_triangle(1, 2, 2)
        self.assertEquals(result, 'isoceles')

Running the test suite again:

$ python basic_triangle_test.py
.F.
======================================================================
FAIL: testIsoceles (__main__.ClassifyTriangleTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "basic_triangle_test.py", line 15, in testIsoceles
    self.assertEquals(result, 'isoceles')
AssertionError: 'scalene' != 'isoceles'
 
----------------------------------------------------------------------
Ran 3 tests in 0.000s
 
FAILED (failures=1)

Alright! This is the first bug that our test suite has caught! In the classify_triangle routine, we’re not correctly checking all three cases where we might have an isoceles triangle. Here’s the corrected method:

def classify_triangle(a, b, c):
  if a == b == c:
    return 'equilateral'
  elif (a == b) or (b == c) or (a == c):
    return 'isoceles'          ########
  else:
    return 'scalene'

I’ve added in the new check. Lets run the unit tests again:

python basic_triangle_test.py
...
----------------------------------------------------------------------
Ran 3 tests in 0.000s
 
OK

Great success!

The next bug in classify_triangle is a bit more subtle. There’s a fourth case that wasn’t specified in the original problem statement: what if the numbers specified don’t represent a triangle at all?

Here’s another quick refresher on triangles (pardon the overly mathematic nature of it):

If A, B, C represent the lenghts of the sides of a triangle, and A ≤ B ≤ C, then A + B > C if (and only if) the lengths represent a valid triangle.

Off the top of my head, here’s two examples that aren’t valid:

  • (1, 1, 10) will be classified as isoceles, but isn’t really a triangle at all
  • (1, 2, 5) will be classified as scalene, but isn’t really a triangle either.

Lets add two test cases for these:

    def testInvalidIsoceles(self):
        result = classify_triangle(1, 1, 10)
        self.assertEquals(result, 'notvalid')
 
    def testInvalidScalene(self):
        result = classify_triangle(1, 2, 5)
        self.assertEquals(result, 'notvalid')

And re-running the tests fails, as expected:

$ python basic_triangle_test.py
.FF..
======================================================================
FAIL: testInvalidIsoceles (__main__.ClassifyTriangleTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "basic_triangle_test.py", line 26, in testInvalidIsoceles
    self.assertEquals(result, 'notvalid')
AssertionError: 'isoceles' != 'notvalid'
 
======================================================================
FAIL: testInvalidScalene (__main__.ClassifyTriangleTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "basic_triangle_test.py", line 30, in testInvalidScalene
    self.assertEquals(result, 'notvalid')
AssertionError: 'scalene' != 'notvalid'
 
----------------------------------------------------------------------
Ran 5 tests in 0.001s
 
FAILED (failures=2)

There’s a number of ways to address this in the original function, but I’d like to modify it so that it matches the description above (that is, sorting so that a ≤ b ≤ c, and performing the computation):

def classify_triangle(a, b, c):
  a, b, c = sorted([a, b, c])
  if not (a + b) > c:
    return 'notvalid'
  if a == b == c:
    return 'equilateral'
  elif (a == b) or (b == c) or (a == c):
    return 'isoceles'
  else:
    return 'scalene'

Re-running the same set of tests now succeeds:

$ python basic_triangle_test.py
.....
----------------------------------------------------------------------
Ran 5 tests in 0.000s
 
OK

There’s still more tests that should be done, but they’re not particularly interesting:

  • We should try all three permutations for the invalid triangles.
  • What if a, b, or c are negative?

Hopefully this is enough to get you going on writing simple tests with Python and the unittest framework. If there’s details that aren’t clear, please post about them below.