ICS 22 / CSE 22 Fall 2012
Unit Testing and Test-Driven Development with JUnit
Code Examples
The examples
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 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 explained in the What We Did and How We Did It document; I'll follow them more rigorously here.
Writing a test
The first step is writing a test of our new feature, before we've actually written the feature. Using JUnit, we're able to write a minimal amount of code to perform the test. By leveraging the JUnit framework'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 gather test 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 class doesn't need to do anything special to plug into the JUnit framework, but it was necessary to create it differently within Eclipse, so that Eclipse would know that we intended to put tests into it. Eclipse put some code into the class for us, but it wasn't code that we could use, so we immediately deleted it.
public class SongCollectionTest { }
Next, we need a test that checks whether the size of a newly-created collection is zero. To write a JUnit-based test, we add a method to a test class that is public, returns void, takes no parameters, and includes the @Test annotation above it. The name we choose for the test is critical; it should say specifically what we're trying to test for. A good name might be sizeOfNewCollectionIsZero.
A JUnit test method is considered to have failed if it throws 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. If it is true, no exception is thrown; if it isn't, an exception is thrown. In this case, we're interested in knowing whether two things are equal: zero and the size of a new collection. Zero is what we expect; the size of the collection is what we observe. The method assertEquals can be called to make this kind of comparison between what we expect and what we observe. It throws an exception if the comparison fails and has no effect if it succeeds.
public class SongCollectionTest { @Test public void sizeOfNewCollectionIsZero() { SongCollection emptyCollection = new SongCollection(); assertEquals(0, emptyCollection.size()); } }
Making the test compile
We now compile the test, which fails with the following error message:
SongCollection cannot be resolved to a type
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 compile, we'll need to declare one. Since that's all the error messages are telling us, that's all we should do, then recompile.
public class SongCollection { }
Now when we recompile, we get a new error message:
The method size() is undefined for the type SongCollection
The compiler is now telling us that there's a missing size() method, so we'll write one. We don't care at this stage what it does; we just want it to compile. So we'll write a method that always returns 0.
public class SongCollection { public int size() { return 0; } }
Finally, we're able to compile the test and the code successfully.
Running the test and making it pass
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.
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: verifying that after adding one song to a newly-created collection, the size of the collection is 1. We'll begin by writing the test.
public class SongCollectionTest { // ... @Test public void afterAddingOneSongTheSizeIsOne() { Song song = new Song(); SongCollection collection = new SongCollection(); collection.add(song); assertEquals(1, collection.size()); } }
Now we need to make the test compile, 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.)
public class Song { }
We also need to put an add method into the SongCollection class. Note that the goal, at present, is to make the test compile, so we don't write any code into this method, since the method will compile without any code in it.
public class SongCollection { // ... public void add(Song songToAdd) { } }
At this point, the test will compile, but it will fail, since, of course, we haven't added the code to make the size of the list be 1 after we add a song. The fix for this problem is to add a new field to SongCollection that stores the number of songs. This requires a constructor to initialize it, along with minor changes to the size() and add() methods.
public class SongCollection { private int songCount; public SongCollection() { songCount = 0; } public int size() { return songCount; } public void add(Song songToAdd) { songCount = 1; } }
Notice that we did something here that we know will be wrong later: we set songCount 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 compile the code and tests and, if compiling them is successful, 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.
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 a parameter. 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.
So it's time to introduce a separate method and set it to execute before each test. Marking a method with the @Before annotation will tell JUnit to execute that method before each test in the same class; similarly, you can use the @After annotation to mark a method that should be run after each test in the same class. In this case, our "before" method should create a SongCollection object and store it in a field, so that we can use it in each of the test methods.
public class SongCollectionTest { private SongCollection collection; @Before public void createEmptyCollection() { collection = new SongCollection(); } @Test public void sizeOfNewCollectionIsZero() { assertEquals(0, collection.size()); } @Test public void afterAddingOneSongTheSizeIsOne() { Song song = new Song(); collection.add(song); assertEquals(1, collection.size()); } }
Once again, we'll be careful to make only one very minor change at a time, compiling and running the tests along the way to ensure that we haven't made any mistakes. This refactoring hasn't changed what the tests do at all, 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 "new Song()", which will work until we get further with our code and realize that we need Songs to have artists, titles, and so on. So we'll isolate the code that creates a new Song in one place, too.
public class SongCollectionTest { private SongCollection collection; @Before public void createEmptyCollection() { collection = new SongCollection(); } @Test public void sizeOfNewCollectionIsZero() { assertEquals(0, collection.size()); } @Test public void afterAddingOneSongTheSizeIsOne() { Song song = createTestSong(); collection.add(song); assertEquals(1, collection.size()); } private Song createTestSong() { return new 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. As always, we'll start with the test, which will verify that the size increases each time we add one of a hundred songs.
public class SongCollectionTest { // ... @Test public void sizeGrowsAccuratelyAsSongsAreAdded() { for (int i = 1; i <= 10; i++) { collection.add(createSong()); assertEquals(i, collection.size()); } } }
The code now compiles, but the new test 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:
public class SongCollection { // ... public void add() { songCount++; } }
Now the code compiles and 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.
public class SongCollectionTest { // ... @Test public void afterAddingSongToCollectionTheCollectionContainsTheSong() { Song song = createSong(); collection.add(song); assertTrue(collection.contains(song)); } }
Notice that this test uses a different assertion method, called assertTrue(), which takes a boolean value as a parameter and throws 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.
Of course, when we compile the test, we discover that it doesn't compile, because we've dreamed up a contains() method that doesn't yet exist. So, we'll need to add one to the SongCollection class. All we want is for the test to compile, so we write it this way:
public class SongCollection { // ... public boolean contains(Song songToFind) { return false; } }
We could have decided to make the method return true; either is fine, since all we want is for the test to compile.
Now, the test fails, because the contains() method always says that a song is not in the collection, no matter who it is. To fix this problem with the absolute minimum amount of code, we could do so by having the method always return true. This time, however, I'm going to anticipate that my next test is going to be the opposite condition, ensuring that a collection does not contain a song who 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 on the songs, we might as well begin with a "flat" data structure, such as an ArrayList<Song>. If we discover later that we need fast searching based on some key (such as song ID), we can make a better 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 ArrayLists 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 ArrayList already does, namely the tracking of the number of songs stored in the collection. We need to remove our own code to track this number and let the ArrayList 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.