Let’s try specification-based testing and structural testing together on a real-world example: the leftPad()
function from Apache Commons Lang.
Left-pad a string with a specified string. Pad to a size of size
.
str
—The string to pad out; may benull
.size
—The size to pad to.padStr
—The string to pad with. Null or empty is treated as a single space.
The method returns a left-padded string, the original string if no padding is necessary, or null
if a null string is input.
For example, if we give "abc"
as the string input, a dash "-"
as the pad string, and 5 as the size, the program will output "--abc"
.
A developer on your team comes up with the implementation in listing 3.5. For now, suppose you are testing code written by others, so you need to build an understanding of the code before you can test it properly. Specification-based testing and structural testing are applied the same way, regardless of whether you wrote the code. We discuss test-driven development and how you can use tests to guide you through implementation.
Listing 3.5 leftPad
implementation from the Apache Commons
public static String leftPad(final String str, final int size,
String padStr) {
if (str == null) { ❶
return null;
}
if (padStr==null || padStr.isEmpty()) { ❷
padStr = SPACE;
}
final int padLen = padStr.length();
final int strLen = str.length();
final int pads = size - strLen;
if (pads <= 0) { ❸
// returns original String when possible
return str;
}
if (pads == padLen) { ❹
return padStr.concat(str);
} else if (pads < padLen) { ❺
return padStr.substring(0, pads).concat(str);
} else { ❻
final char[] padding = new char[pads];
final char[] padChars = padStr.toCharArray();
for (int i = 0; i < pads; i++) {
padding[i] = padChars[i % padLen];
}
return new String(padding).concat(str);
}
}
❶ If the string to pad is null, we return null right away.
❷ If the pad string is null or empty, we make it a space.
❸ There is no need to pad this string.
❹ If the number of characters to pad matches the size of the pad string, we concatenate it.
❺ If we cannot fit the entire pad string, we add only the part that fits.
❻ We have to add the pad string more than once. We go character by character until the string is fully padded.
Now it is time for some systematic testing. As we know, the first step is to apply specification-based testing. Let’s follow the process discussed (I suggest you try to do it yourself and compare your solution to mine):
- We read the requirements. We understand that the program adds a given character/string to the beginning (left) of the string, up to a specific size. The program has three input parameters:
str
, representing the original string to be padded;size
, representing the desired size of the returned string; andpadStr
, representing the string used to pad. The program returns aString
. The program has specific behavior if any of the inputs is null. (If we had implemented the feature ourselves, we would probably skip this step, as we would already have a complete understanding of the requirements.) - Based on all the observations in step 1, we derive the following list of partitions:
- There are several boundaries:
- We can devise single tests for exceptional cases such as null, empty, and negative size. We also have a boundary related to
padStr
: we can exercisepadStr
with a single character only once and have all other tests use a pad with a single character (otherwise, the number of combinations would be too large). We obtain the following tests:
Now we automate the tests. I used a parameterized test, but it is fine if you prefer nine traditional JUnit tests.
Listing 3.6 Tests for LeftPad
after specification-based testing
public class LeftPadTest {
@ParameterizedTest
@MethodSource("generator")
void test(String originalStr, int size, String padString,
String expectedStr) { ❶
assertThat(leftPad(originalStr, size, padString))
.isEqualTo(expectedStr);
}
static Stream<Arguments> generator() { ❷
return Stream.of(
of(null, 10, "-", null), ❸
of("", 5, "-", "-----"), ❹
of("abc", -1, "-", "abc"), ❺
of("abc", 5, null, " abc"), ❻
of("abc", 5, "", " abc"), ❼
of("abc", 5, "-", "--abc"), ❽
of("abc", 3, "-", "abc"), ❾
of("abc", 0, "-", "abc"), ❿
of("abc", 2, "-", "abc") ⓫
);
}
}
❶ The parameterized test, similar to the ones we have written before
❷ The nine tests we created are provided by the method source.
It is time to augment the test suite through structural testing. Let’s use a code coverage tool to tell us what we have already covered (see figure 3.8). The report shows that we are missing some branches: the if
(pads
==
padLen)
and else
if
(pads
<
padLen)
expressions.
Figure 3.8 Code coverage achieved by the specification-based tests for the leftPad
method. The two return
lines near the arrow are not covered; the if
and else if
, also near the arrow, are only partially covered. The remaining lines are fully covered.
This is useful information. Why didn’t we cover these lines? What did we miss? As a developer, you should triangulate what you see in the source with the specification and your mental model of the program. In this case, we conclude that we did not exercise padStr
being smaller, greater, or equal to the remaining space in str
. What a tricky boundary! This is why structural testing is essential: it helps identify partitions and boundaries we may have missed.
With that information in mind, we derive three more test cases:
- T10: the length of
padStr
is equal to the remaining spaces instr
. - T11: the length of
padStr
is greater than the remaining spaces instr
. - T12: the length of
padStr
is smaller than the remaining spaces instr
(this test may be similar to T6).
We add these three extra test cases to our parameterized test, as shown in listing 3.7. When we run the coverage tool again, we get a report similar to the one in figure 3.9. We now cover all the branches.
Listing 3.7 Three new test cases for leftPad
static Stream<Arguments> generator() {
return Stream.of(
// ... others here
of("abc", 5, "--", "--abc"), // T10
of("abc", 5, "---", "--abc"), // T11
of("abc", 5, "-", "--abc") // T12
);
}
Figure 3.9 Code coverage of the leftPad
method after specification-based and structural tests. We now achieve 100% branch coverage.
NOTE Interestingly, if you look at the entire class, JaCoCo does not give 100% coverage, but only 96%. The report highlights the first line of the file: the declaration of the class, public
class
LeftPadUtils
{
. The leftPad
method is static, so none of our tests instantiate this class. Given that we know the context, we can ignore the fact that this line is not covered. This is a good example of why only looking at the numbers makes no sense.
With all the branches covered, we now look for other interesting cases to test. The implementation contains interesting decisions that we may decide to test. In particular, we observe an if
(pads
<=
0)
block with the code comment “returns original String when possible”. As a tester, you may decide to test this specific behavior: “If the string is not padded, the program should return the same String
instance.” That can be written as a JUnit test as follows.
Listing 3.8 Another extra test for leftPad
@Test
void sameInstance() {
String str = "sometext";
assertThat(leftPad(str, 5, "-")).isSameAs(str);
}
We are now much more confident that our test suite covers all the critical behavior of the program. Structural testing and code coverage helped us identify parts of the code that we did not test (or partitions we missed) during our specification-based testing—and that is what structural testing is all about.
Leave a Reply