CS1: Lecture 13 – Test-driven Development
Dear students,
Is there anything like charAt
for integers? For instance, given a number and an “index” representing the place, can we get back the digit at that place? Not exactly, but there’s nothing stopping us from writing our own method to accomplish this task!
But before we do that, let me share with you a quotation from Maurice Wilkes, one of the developers of the first computers:
By June 1949 people had begun to realize that it was not so easy to get programs right as at one time appeared. I well remember when this realization first came on me with full force. The EDSAC was on the top floor of the building and the tape-punching and editing equipment one floor below… It was on one of my journeys between the EDSAC room and the punching equipment that “hesitating at the angles of stairs” the realization came over me with full force that a good part of the remainder of my life was going to be spent in finding errors in my own programs.
That “hesitating at the angles of stairs” is Wilkes alluding to the poetry of T.S. Eliot.
If we are going to be an inventor—which is what we software developers are—we must care to do it right. Our software must do its task well. Our financial institutions, health systems, and Gabe Newell’s networth depend on it. There are many practices that will help us produce software of high quality, including code reviews, unit tests like the SpecChecker provides, and pair programming. We’ll talk about another one of them today: test-driven development.
Test-driven Development
Here’s a recipe to follow when writing a method to accomplish some task:
- Accept that you will not write the method perfectly in the first draft, and that this imperfection doesn’t mean you are not cut out to write code.
- Determine the method name and its inputs and outputs.
- Write the method so that it does nothing but compile. Have it immediately return 0 or
false
ornull
. - Write a test thats feeds certain values to the method. Pit the expected results from running the method on these values against the actual results.
- Fix the method so that the test passes.
- Clean up any code that needs cleaning up, making sure the tests continue to pass as you make changes.
- Repeat step 3.
This process is called red-green-refactor—red for erroring test, green for passing test, and refactor for that beautiful moment when you know you code is working and where edits can be made without fear. The process is intended to minimize headache. Writing software can be an enjoyable intellectual activity, but only when you anticipate your mistakes. When you bounce from error to error and each one surprises you, you will start to think you aren’t fit to write code. But really the problem is your blundersome technique.
digitAt
Let’s see these ideas at work with our charAt
for numbers. We’ll call the method digitAt
, give it the number and place index, and have it return the digit, which will be an int
in [0-9]:
public static int digitAt(int n, int place) {
// ...
}
Step 3 is about doing the least you can to make the code compile:
public static int digitAt(int n, int place) {
return 0;
}
Step 4 is to write a test. We could do this in a little main
:
public static void main(String[] args) {
int expected = 7;
int actual = digitAt(8752, 2);
System.out.printf("%d == %d | place 2 from 8752%n", expected, actual);
}
Step 5 is to fix the code so the test passes. A purist of test-driven development might tell you to do the simplest thing possible to make the test pass:
public static int digitAt(int n, int place) {
return 2;
}
The test should pass now.
Let’s jump back to step 3 and add another test:
public static void main(String[] args) {
int expected = 7;
int actual = digitAt(8752, 2);
System.out.printf("%d == %d | place 2 from 8752%n", expected, actual);
expected = 5;
actual = digitAt(8752, 1);
System.out.printf("%d == %d | place 1 from 8752%n", expected, actual);
}
Assertion Utility
If we are going to consistently test code, we want to make writing tests as simple and brief as possible. Let’s create a new abstraction in a separate class that we will expect to reuse:
public class Certainly {
public static void same(int expected, int actual, String message) {
System.out.printf("%d == %d | %s%n", expected, actual, message);
}
}
Then in main
I can simply say this:
Certainly.same(7, digitAt(8752, 2), "place 2 from 8752");
Certainly.same(5, digitAt(8752, 1), "place 1 from 8752");
When I run this, the first test passes but the second fails. Now we must attend to the deeper logic issues we have going on in digitAt
. We’ll sort out the implementation together in class. But after we get something working, we keep adding tests to try and break it. We should test on negative numbers, we should test place indices that reach beyond the number.
Here’s how I’d implement it:
public static int digitAt(int n, int place) {
int withoutRightDigits = n / (int) Math.pow(10, place);
int onesPlace = withoutRightDigits % 10;
return onesPlace;
}
We could do some magic with String
too, but that solution requires an if
statement to avoid out-of-bounds errors. I guess we could write it like this:
public static int digitAt(int n, int place) {
n = Math.abs(n);
String s = "0" + n;
return s.charAt(Math.max(0, s.length() - 1 - place)) - '0';
}
But this solution seems obtuse to me.
Other Problems
Let’s apply test-driven development to a few other problems. We’ll extend our assertion utilities as necessary to handle String
s.
- CountWrite a method that counts the number of occurrences of a
char
in aString
. - Truncate FiveWrite a method that truncates a
String
to no longer than 5 characters. - Star PointsWrite a method that accepts a number of stars that you collected in a game and returns how many points the stars are worth. Each of the first 10 stars is worth 2 points, but each star past that is worth 5.
- Truncate EvenWrite a method that accepts a
String
and returns a similarString
, but with an even length, truncated if necessary.
TODO
Here’s your TODO list to complete before we meet again:
- On a quarter sheet of paper to be turned in at the beginning of our next lecture, define a method that
hasJustOne
that accepts aString
and achar
. Have it return aboolean
indicating if theString
contains just a single instance of thechar
. Note that the==
operator can be used to compare two values and produce aboolean
. For example, ina == b
yieldstrue
whena
andb
are the same. - CS 148, your lab is posted. Feel free to start early. We also have our peer review during lab.
See you next class!
P.S. It’s time for a haiku!
Red-green-refactor
It hurts less than my old way
Red-bloody-burning
P.P.S. Here’s the code we wrote together in class…
Main.java
package lecture1002.cs145;
public class Main {
public static void main(String[] args) {
Certainly.samesies(1, NumberUtilities.digitAt(210, 1));
Certainly.samesies(2, NumberUtilities.digitAt(210, 2));
Certainly.samesies(0, NumberUtilities.digitAt(210, 0));
Certainly.samesies(0, NumberUtilities.digitAt(210, 3));
Certainly.samesies(0, NumberUtilities.digitAt(-210, 0));
Certainly.samesies(1, NumberUtilities.digitAt(-210, 1));
Certainly.samesies(2, NumberUtilities.digitAt(-210, 2));
Certainly.samesies(0, NumberUtilities.occurrences("Chris", 'p'));
Certainly.samesies(1, NumberUtilities.occurrences("Chris", 'i'));
Certainly.samesies(1, NumberUtilities.occurrences("Chris", 'i'));
Certainly.samesies(2, NumberUtilities.occurrences("Marshall", 'a'));
Certainly.samesies(2, NumberUtilities.occurrences("Marshall", 'l'));
Certainly.samesies(1, NumberUtilities.occurrences("Marshall", 'M'));
Certainly.samesies(0, NumberUtilities.occurrences("Marshall", 'm'));
}
}
Certainly.java
package lecture1002.cs145;
public class Certainly {
public static void samesies(int expected, int actual) {
System.out.printf("%d == %d%n", expected, actual);
}
}
NumberUtilities.java
package lecture1002.cs145;
public class NumberUtilities {
public static int digitAt(int number, int place) {
int shifted = Math.abs(number) / (int) Math.pow(10, place);
int ones = shifted % 10;
return ones;
}
public static int occurrences(String haystack, char needle) {
int originalLength = haystack.length();
int shortenedLength = haystack.replaceAll(needle + "", "").length();
return originalLength - shortenedLength;
}
}
Main.java
package lecture1002.cs148;
public class Main {
public static void main(String[] args) {
Certainly.samesies(6, NumberUtilities.digitAt(246, 0), "digitAt: Bad ones place!");
Certainly.samesies(4, NumberUtilities.digitAt(246, 1), "digitAt: Bad tens place!");
Certainly.samesies(2, NumberUtilities.digitAt(246, 2), "digitAt: Bad hundreds place!");
Certainly.samesies(0, NumberUtilities.digitAt(246, 3), "digitAt: Bad thousands place!");
Certainly.samesies(6, NumberUtilities.digitAt(-246, 0), "digitAt: Bad ones place for negative!");
Certainly.samesies(4, NumberUtilities.digitAt(-246, 1), "digitAt: Bad tens place for negative!");
Certainly.samesies(2, NumberUtilities.digitAt(-246, 2), "digitAt: Bad hundreds place for negative!");
Certainly.samesies(0, NumberUtilities.digitAt(-246, 3), "digitAt: Bad thousands place for negative!");
Certainly.samesies(0, NumberUtilities.count("What's up, Jo?", 'z'), "count: No occurrences");
Certainly.samesies(1, NumberUtilities.count("What's up, Jo?", 'J'), "count: Single occurrence");
Certainly.samesies(2, NumberUtilities.count("What's up, Jo?", ' '), "count: Two occurrences");
}
}
Certainly.java
package lecture1002.cs148;
public class Certainly {
public static void samesies(int expected, int actual, String message) {
if (expected != actual) {
throw new RuntimeException(String.format("%s%nExpected: %d%n Actual: %d%n", message, expected, actual));
}
}
}
NumberUtilities.java
package lecture1002.cs148;
public class NumberUtilities {
/**
* Return digit at particular place in number.
* @return A number 0-9 at given place.
*/
public static int digitAt(int number, int index) {
int shifted = Math.abs(number) / (int) Math.pow(10, index);
int ones = shifted % 10;
return ones;
}
public static int count(String text, char needle) {
return text.length() - text.replaceAll(needle + "", "").length();
}
}