ICS 32 Winter 2022
Notes and Examples: Test-Driven Development
Step-by-Step Example
The example
In lecture, we went through an iterative process of developing some functionality for a class called SongCollection, which, as its name suggests, was intended to define a kind of object that stores and manages a collection of songs. This document briefly explains the motivation behind each step we took, then provides links to the complete version of the code (including, of course, the tests) after each complete iteration. (Each iteration added one small piece of functionality to our class.)
Iteration 1: The size of a newly-created collection is zero
Our first iteration adds one simple piece of functionality to our SongCollection class: ensuring that the size of a newly-created collection is zero. We tried our best in lecture to follow the steps of the test-driven development process; I'll follow them more rigorously here. It's not a bad idea to follow the steps carefully at the beginning; as you get more accustomed to test-driven development and you understand the way it requires you to think about writing programs, you can feel a bit more free to take some liberties that you know don't violate the spirit of the process.
Writing a test
The first step is writing a test of our new feature, before we've actually written the feature. Using the unittest module in Python, we're able to write a minimal amount of code to perform the test. By leveraging the unittest module's handling of many of the tedious details, we can concentrate our efforts on what we're trying to test (i.e. what the intended behavior is), rather than worrying about details of how to find all of the tests that need to be run, execute each one, gather their results, and present them to a user.
We'll begin by defining a class to test our SongCollection class; a reasonable name for it is SongCollectionTest. The SongCollectionTest class will contain all of the tests that are focused on our SongCollection class. In order to plug into the unittest framework, our class needs to inherit from the built-in unittest.TestCase class. So we'll start by creating such a class, in a module called test_songs (the unittest module prefers tests to be written in modules whose names begin with test_, so we'll follow that convention here).
import unittest class SongCollectionTest(unittest.TestCase): pass
Next, we need a test that checks whether the size of a newly-created collection is zero. To write a unittest-based test, we add a method to a test class whose name begins with test_. The name we choose for the test is critical; it should say specifically the behavior we expect. I quite often write tests with fairly long names, because unittest reports failures by showing you the name of the test that failed; quite often, that, along with a short error message, is all you'll need to see in order to understand how to fix a problem. A good name might be test_new_collections_have_size_zero.
Like any other Python method, a unittest test method is considered to have failed if it raised an exception and succeeded if it doesn't. Assertions are used to implement these methods easily. An assertion is something that you believe should be true at some point in your test — that two values are equal, that a Boolean expression is true, that a function will raise an exception, etc. If the asserted behavior occurs, no exception is raised; if it isn't, an exception is raised. In this case, we're interested in knowing whether two things are equal: the length of a new collection and zero. Zero is what we expect; the size of the collection is what we observe. The method self.assertEqual (which, along with other "assert" methods is inherited from unittest.TestCase) can be called to make this kind of comparison between what we expect and what we observe. It raises an exception (with a nice, human-readable error message) if the comparison fails and has no effect if it succeeds.
class SongCollectionTest(unittest.TestCase): def test_new_collections_have_size_zero(self): collection = SongCollection() self.assertEqual(collection.size(), 0)
Preparing to run our test
There are a variety of ways to execute unittest test methods, but the simplest is for each of your test modules to contain an if __name__ == '__main__': block that executes all of the tests in that module. To do that, you'd add this to the end of your test module.
if __name__ == '__main__': unittest.main()
Executing this module (e.g., using F5 in IDLE) will cause all of the test methods in this module to automatically be found and executed for you; you'll see the result of the tests as the module's output.
Making the test run
We now run the test, which fails with the following error message:
NameError: global name 'SongCollection' is not defined
The error message is telling us that the SongCollection class is missing. This is no surprise, since we haven't created it yet. In order to make the test run, we'll need to create one — in a separate module, as tests shouldn't live in the same module as the code under test, because tests aren't really a part of the program we're building; they're just a tool to help us build it.
Since creating a SongCollection class is all the error messages are complaining about, that's all we should do, then try running the test again. So we'll create one in a separate module:
class SongCollection: pass
Then, back in our test module, we'll import it. Suppose that the new module is called songs; if so, we'd add this to the top of our test module:
from songs import SongCollection
Now when we run the tests again, we get a different error message:
AttributeError: 'SongCollection' has no attribute 'size'
The problem here is that we're calling a size() method on a SongCollection object, but SongCollection doesn't yet have such a method. So we need to write that method. We don't care at this stage what it does; we just want this problem to be solved. So we'll write a method that always returns 0.
class SongCollection: def size(self) -> int: return 0
Next, we'll run the test, which succeeds. The success, in this case, is accidental, but nice. If the test had failed, we'd now write the minimum amount of code that makes the test pass.
It may make you a little bit uncomfortable to have written code that is clearly going to be wrong in a future iteration, but you have to remember what the goal is: We use tests to drive the behavior we're looking for. If there's no test for it, our code isn't expected to do it. The only test we have involves asking an empty collection for its size, and our SongCollection handles that correctly. So, for now, we're on stable ground, and we'll worry about future iterations when we get to them.
Refactoring
Since we're just getting started, there aren't any improvements that can be made in either the test or the code, so we're done with the first iteration. What we have now is a SongCollection class with our one feature: the size of a new list is zero. Also, we have a test that will allow us to verify that this feature will continue to work going forward.
The test and the code at the end of the iteration
Iteration 2: The size of a collection after creating it and adding a song is 1
In this iteration, we'll add one piece of functionality: After adding one song to a newly-created collection, the size of the collection is 1. We'll begin by writing the test.
class SongCollectionTest(unittest.TestCase): ... def test_after_adding_one_song_to_a_collection__size_is_1(self): collection = SongCollection() collection.add(Song()) self.assertEqual(collection.size(), 1) }
Note that we've had to consider a couple of new things about our SongCollection class:
Now we need to make the test run, which necessitates a Song class. Since we're not depending on the Song class actually doing anything, we won't need to put any code into it yet. (Remember: We don't write code until we have a test that shows that it will work when you're done with it.)
class Song: pass
We also need to put an add method into the SongCollection class. Note that the goal, at present, is to make the test run, so we don't write any code into this method, since the method will run without any code in it.
class SongCollection: ... def add(self, song_to_add: Song) -> None: pass
At this point, the test will run, but it will fail, since, of course, we haven't added the code to make the size of the collection be 1 after we add a song to it. The fix for this problem is to add a new attribute to SongCollection that stores the number of songs. This requires an __init__ method to initialize it, along with minor changes to the size() and add() methods.
class SongCollection: def __init__(self): self._count = 0 def size(self) -> int: return self._count def add(self, song_to_add: Song) -> None: self._count = 1
Notice that we did something here that we know will be wrong later: We set the _count attribute to 1 in the add() method, rather than adding 1 to it. The reason is that the code we wrote is a direct way to make our test pass. We'll worry about the case of adding two or more songs in the next iteration; for now, the only thing we want is for the number of songs to be 1 after we add a song.
After every little step we take in making these modifications, we should run the tests. This keeps us honest and helps us to ensure that none of the changes we're making will break the functionality that already worked. And once the tests pass, we're done and ready to move on.
Here are links to the completed code at this point:
It's time for a little refactoring
Always remember that when you've implemented the functionality you want in one iteration, you're not actually done with the iteration; you still need to see if any refactoring can be done, either on the code or the tests. At this point, there are two tests in my SongCollectionTest class that create and manipulate a SongCollection. We also have a pretty good sense that all of our tests will probably need to do this. That doesn't seem like such a big deal on the face of it; what's the harm of having to create the collection each time? Consider what would happen if we had 25 tests written, then we changed our design so that the SongCollection constructor required an argument to be passed to it. What would we have to do to all 25 tests? Ugh! By isolating the code that creates SongCollections for our tests in one place, we set things up so this will only have to change in one place.
The unittest framework in Python provides a nice solution to this problem. If you write a setUp method in your test class, it will be called automatically (on a freshly-created object of your SongCollectionTest class) each time one of your tests is executed, so each test will be executed in a fresh environment, with setUp providing common initialization.
In this case, we could create a SongCollection object in the setUp method and store it in an attribute, so that we can use it in each of the test methods.
class SongCollectionTest(unittest.TestCase): def setUp(self): self._collection = SongCollection() def test_new_collections_have_size_zero(self): self.assertEqual(self._collection.size(), 0) def test_after_adding_one_song_to_a_collection__size_is_1(self): self._collection.add(Song()) self.assertEqual(self._collection.size(), 1)
Once again, we'll be careful to make only one very minor change at a time, running the tests along the way to ensure that we haven't made any mistakes. This refactoring hasn't changed what the tests do in any meaningful way, but it has improved the design of the code somewhat, eliminating some duplicate code. That's the goal of refactoring: Improve the design of the code without changing what it does.
Similarly, we realize that we're creating a Song by using the Song constructor, which will work until we get further with our code and realize that we need Songs to have artists, titles, and so on; if that Song constructor ever starts requiring arguments, we may have a lot of changes to make in our tests. So we'll isolate the code that creates a new Song in one place, too: a function that we can call when we want a Song but don't care about any of the details of what's in it.
class SongCollectionTest(unittest.TestCase): def setUp(self): self._collection = SongCollection() def test_new_collections_have_size_zero(self): self.assertEqual(self._collection.size(), 0) def test_after_adding_one_song_to_a_collection__size_is_1(self): self._collection.add(self._create_test_song()) self.assertEqual(self._collection.size(), 1) def _create_test_song(self) -> Song: return Song()
The new version of the code is available at this link:
Iteration 3: Continuing to add songs will continue to increase the size by 1 each time
Combined with the functionality from the first two iterations, this iteration will allow us to feel confident that the handling of the size of the collection as we add songs will continue to work no matter how many songs we add. The pattern of "handle none, handle one, handle more than one" is one you use a lot in this kind of test-driven development. As always, we'll start with the test, which will verify that the size increases each time we add one of a hundred songs. We need to know how many there are, so this will best be driven by a for loop that counts within a range.
class SongCollectionTest(unittest.TestCase): ... def test_continuing_to_add_songs_continues_to_increase_size(self): for song_number in range(1, 101): self._collection.add(self._create_test_song()) self.assertEqual(self._collection.size(), song_number)
The test runs, but it doesn't pass, since the size of the collection will remain 1, even after adding two or more songs. We can fix this problem by making a relatively minor change in the add() method in the SongCollection class:
class SongCollection: ... def add(self, song_to_add: Song) -> None: self._count += 1
And, just like that, the tests pass. Here is the complete set of code, as it stands now:
The only remaining question is whether any refactoring can be done. Everything smells pretty good at this point, so we'll move on to our next iteration.
Iteration 4: After a song is added to the collection, the collection contains the song
We now tackle the problem of ensuring that the collection contains a song after that song has been added. First, we need a test.
class SongCollectionTest(unittest.TestCase): ... def test_after_adding_song_to_collection__collection_contains_song(self): new_song = self._create_test_song() self._collection.add(new_song) self.assertTrue(self._collection.contains(new_song))
Notice that this test uses a different assertion method, called assertTrue(), which takes a boolean value as an argument and raises an exception if the value is not true. This is how we can assert a more general condition than "This value is equal to that value." In this case, we want to assert that it's true that the collection contains the song after having added it.
(Note that there is a fairly long list of assert methods available, which are listed in the documentation for the unittest module.)
Of course, when we run the test, we discover that it fails, because we've dreamed up a contains() method that doesn't yet exist. So, we'll need to add one to the SongCollection class.
class SongCollection: ... def contains(self, song_to_find: Song) -> bool: return True
Again, we've focused on solving only the problem at hand, so our new method is returning True in every case, even though we know that will be wrong in the future.
After making this change, our test runs successfully, so we're ready to move on. We could continue by anticipating that our next test would be opposite condition — ensuring that a collection does not contain a song that has not been added — so, at this point, I'll need to choose an underlying collection and start storing songs in it. Since we're not sure at this point what kinds of searches we might be doing, we might as well begin with the simplest data structure: a list. If we discover later that we need fast searching based on some key, we can make a different decision about our data structure and implement the appropriate changes. Remember: the goal of test-driven development is to write the minimum amount of code to make a test pass, with decisions deferred until they need to be made for some justifiable reason. This doesn't mean that lists are the right data structure ultimately, but that they are often the right first choice until we get a better idea of what we'll actually need.
The updated version of the program, after adding support for our new feature and verifying that the tests indeed pass, looks like this:
Do we need any refactoring?
The test class smells pretty good at this point, so no refactoring is necessary. But something is rotten in the SongCollection class. It's doing work that lists already do, namely the tracking of the number of songs stored in the collection. We should remove our own code that tracks this number and let the list do the work instead. As always, we make one minor change at a time, compiling and re-running the tests at each step to make sure we haven't screwed up. Note that we do not need to write any new tests; we want the program to behave the same way, but to improve its design.
After this process, the code looks like this:
Continue this for a while and see where it goes!
This is as far as we got in lecture. Try taking this process a little bit farther yourself, adding features, one at a time, using just this same process. Remember to keep the features simple, remember to write the tests first, and remember to use the compiler and the tests to ensure — after every step you take — that you haven't made any mistakes. Every few minutes, at most — and sometimes more than once per minute — you should be again standing on stable ground, with tests that all pass and the minimum amount of code to make them pass. Here are a few ideas of where you might go from here: