Explain this to me as if I was a small child – Developer Test Driven Development

There are so many ways to do testing. It all depends on the technology, the framework, and what automation tools your organization is willing to invest in. Automated testing is a great way to assert sanity in your build, especially when multiple people are touching the same segments of code. Two main approaches to testing here are Behavior Driven Development, which is usually done for acceptance testing, and Test Driven Development, which is used by developers to validate specification. This write-up will cover the latter.

So you’ve been told to write unit tests with a Test Driven Development (TDD) approach. What does that mean? You’ve read that you write a little code, and test a little code. You may have read you write all you tests first (daunting!). Sounds foreign to you, so you say, “screw it, I’ll use the debugger like I usually do.” Well, if you write good unit tests, you probably won’t ever need a debugger, and in the chance you actually need to run the debugger, it’ll get you to what you need much quicker.

How do you do it? Enter the xUnit the framework. xUnit is simply a loose collection of testing frameworks modeled off of the original SUnit framework used with SmallTalk. Just about every language has a clone of this framework you can use, e.g. jUnit for Java and Test::Class for Perl. In this example, we’ll be using unittest.py for Python.

So let’s get to some code. Let’s say we’re trying to write a rudimentary encryption library. We’ll use something stupid easy, like ROT13. (Note, don’t ever use ROT13 as an encryption format). Let’s start by creating a blank class.

class ROT13(object):
    pass

Now at this point, believe it or not, you’re ready to write your first test. Why would you write a test for something that doesn’t have much content? There are many reasons. First, it’s to make sure you spelled things right. Sure, in the age of IDEs, this isn’t supposed to happen, but it can if you have to make a one-off change in a text editor. Second, it can teach you things about a new language, like, “Does this language provide a default constructor?”

And third, which I think is most important, this allows you to define the API, or at least put down some semblance of a specification that you’re given. The interface should, more often than not, be dictated by how the library is going to be used, not by the data store or back end technology that it wraps.

Let’s start with the basic test stub and add a basic test. We’ll say that we want to at least be able to instantiate a ROT13 object and print out the ciphertext.

from rot13 import ROT13
import unittest

class ROT13Test(unittest.TestCase):

    def test_defaultconstructor(self):
        rot13obj = ROT13()
        print(rot13obj)

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

Our custom test class inherits from unittest.TestCase, which gives us access to a bunch of other methods we’ll come across later. The test method, starting with test, will get executed every run. The final line in the main body simply executes all test cases inheriting TestCase. Let’s run it to see what happens.

$ python3 rot13_test.py
<rot13.ROT13 object at 0x7febbdbf3ef0>
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

Hmm, okay, it passed. This tells us two things: Python provides us with a default constructor, and it also provides a default string representation. Now, the default string output is not quite what we wanted. Let’s define our interface such that we have a constructor that takes in a plaintext, and our string representation gives us the ciphertext. We’ll keep our old test to make sure our old behavior still works, but we’ll add a new test to confirm our new behavior:

    def test_basicencrypt(self):
        rot13obj = ROT13("AAAA")
        self.assertEqual(
            "NNNN",
            str(rot13obj),
            "Should translate AAAA as NNNN")

Running our test again, we’ll see that it fails.

$ python3 rot13_test.py
E<rot13.ROT13 object at 0x7f5e391ecb00>
.
======================================================================
ERROR: test_basicencrypt (__main__.ROT13Test)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "rot13_test.py", line 11, in test_basicencrypt
    rot13obj = ROT13("AAAA")
TypeError: object() takes no parameters

----------------------------------------------------------------------
Ran 2 tests in 0.000s

FAILED (errors=1)

Now, we can go back to our class and an optional parameter so that we can satisfy both tests.

class ROT13:

    def __init__(self, plaintext=""):
        self.plaintext = plaintext

Running our tests again, we’ll see that we get passed the first error, but are presented with another error:

$ python3 rot13_test.py
F<rot13.ROT13 object at 0x7f585ee52d30>
.
======================================================================
FAIL: test_basicencrypt (__main__.ROT13Test)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "rot13_test.py", line 15, in test_basicencrypt
    "Should translate AAAA as NNNN")
AssertionError: 'NNNN' != '<rot13.ROT13 object at 0x7f585ee52d30>'
- NNNN
+ <rot13.ROT13 object at 0x7f585ee52d30>
 : Should translate AAAA as NNNN

----------------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=1)

(As a side note, in writing this, since I’ve been living in Java-land for the past year, I forgot that the constructor in Python is the __init__() method, and not ROT13(). Having a unit test is a quick way to remind me that I’m doing it wrong.)

In looking at the output, we still need to correct our str() output. Well, let’s correct that real quick.

class ROT13(object):

    def __init__(self, plaintext=""):
        self.plaintext = plaintext

    def __str__(self):
        return "NNNN"

I know… this is so cheating, but it works! If you run your tests again, all will pass. At some point in your professional life, you will find yourself doing this, whether due to time constraints or to some loose requirement that you don’t quite understand how to implement. This is why you have these tests and multiple versions of these test to make sure what you’re testing works as intended.

Let’s clean it up and make it work with anything, and not just with AAAA. What we’ll do is take each letter in the plaintext, add 13 to the ordinal number of the letter, then convert that ordinal number back into a character using UTC encoding. We can do it in one line.

    def __str__(self):
        return "".join([chr(ord(c)+13) for c in self.plaintext])

Running this will also cause our tests to pass. Great!

However, this will only work with letters up to M. What happens when we do something N and beyond?

$ python3 rot13_test.py
.
.F
======================================================================
FAIL: test_encryptlatterhalfofalphabet (__main__.ROT13Test)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "rot13_test.py", line 22, in test_encryptlatterhalfofalphabet
    "NUNS should translate as AHAF")
AssertionError: 'AHAF' != '[b[`'
- AHAF
+ [b[`
 : NUNS should translate as AHAF

----------------------------------------------------------------------
Ran 3 tests in 0.001s

Well, that’s to be expected. We’ll have to add some code to account for the wraparound after ‘Z’. Let’s introduce a helper method that translates one character at a time, and we’ll have our __str__() method still do a list comprehension and call our helper method. The helper method will simply check to see

    def __str__(self):
        
        return "".join([self._getCipherText(c) for c in self.plaintext])

    def _getCipherText(self, char):
        ordinal = ord(char)
        if ordinal >= ord('N') and ordinal <= ord('Z'):
            ordinal -= 26
        return chr(ordinal + 13)

Running our tests again, we’ll see that it passes.

Now, let’s try our hand at lower case letters. Our new test will have mixed case, with a good representation from both before and after the m-n midpoint:

    def test_encryptmixedcase(self):
        rot13obj = ROT13("TAcos")
        self.assertEqual(
            "GNpbf",
            str(rot13obj),
            "Lowercase letters should work, too.")

Running this will give us interesting results:

$ python3 rot13_test.py
.
..F
======================================================================
FAIL: test_encryptmixedcase (__main__.ROT13Test)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "rot13_test.py", line 29, in test_encryptmixedcase
    "Lowercase letters should work, too.")
AssertionError: 'GNpbf' != 'GNp|\x80'
- GNpbf
+ GNp|€
 : Lowercase letters should work, too.

----------------------------------------------------------------------
Ran 4 tests in 0.001s

FAILED (failures=1)

It looks like we just need to add to our helper method to do the same thing with lowercase n through z characters.

    def _getCipherText(self, char):
        ordinal = ord(char)
        if (ordinal >= ord('N') and ordinal <= ord('Z')) \
          or (ordinal >= ord('n') and ordinal <= ord('z')):
            ordinal -= 26
        return chr(ordinal + 13)

This will now allow our test to pass.

What about non-alpha characters? We’ll stipulate that non-alpha characters should just pass through unaltered, so that a space in the plaintext is still a space in the ciphertext.

    def test_nonalpha(self):
        rot13obj = ROT13("I love tacos 2!")
        self.assertEqual(
            "V ybir gnpbf 2!",
            str(rot13obj),
            "Non-alpha characters should just pass through.")

Before our change, we’ll see… not what we wanted.

$ python3 rot13_test.py
.
...F
======================================================================
FAIL: test_nonalpha (__main__.ROT13Test)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "rot13_test.py", line 36, in test_nonalpha
    "Non-alpha characters should just pass through.")
AssertionError: 'V ybir gnpbf 2!' != 'V-ybir-gnpbf-?.'
- V ybir gnpbf 2!
+ V-ybir-gnpbf-?.
 : Non-alpha characters should just pass through.

----------------------------------------------------------------------
Ran 5 tests in 0.001s

FAILED (failures=1)

We’ll put a guard around the block of code in our helper method to keep out all non-alpha characters from being manipulated.

    def _getCipherText(self, char):
        ordinal = ord(char)
        if (ordinal >= ord('A') and ordinal <= ord('Z')) \
           or (ordinal >= ord('a') and ordinal <= ord('z')):
            
            if (ordinal >= ord('N') and ordinal <= ord('Z')) \
               or (ordinal >= ord('n') and ordinal <= ord('z')):
                ordinal -= 26
            return chr(ordinal + 13)
        return char

Now, our tests all pass. We have the behavior we want.

This tutorial will end here, but there are many other directions you can go. You can add other methods to continue testing. You can also decide to refactor the helper class to do dictionary lookups instead of integer calculation. Once you change up the helper method, you should be able to run the tests without changing them to confirm that you have the exact same behavior as you did before you do your refactor.

The unittest framework also provides other helpful assertions, like null condition checking and truth validation. It also provides helper methods for setting up and tearing down each test case or each class load.

Remember, the idea of this iterative approach is to first establish your core functionality and slowly add in edge cases as you go, so you’re not overwhelmed with testing every branch of you code all at once. There should be at least one test case per branch.

The complete code for this demo can be found on my Github account. Hope this helps you better understand how to do TDD!

Leave a Reply

Your email address will not be published.