Or, “When” and the Art of Unit Test Maintenance*
I was talking recently with a colleague about quality on his self-managed Guidewire implementation. Quality can be a hard sell when it’s up against budgets and timelines, but he’d made good headway getting his team to subscribe to what Steve McConnell calls the General Principle of Software Quality, which is that improving quality reduces development time and costs.
“The best way to improve productivity and quality,” writes McConnell, “is to reduce the time spent reworking code.” But this requires that the team make investments upstream in the development flow to prevent defects so that the code downstream doesn’t have to be reworked.
My colleague talked enthusiastically about the upstream investments his team had made to prevent defects: more effective code reviews, more static code analysis rules, and more robust automated builds. Then he paused. I could hear the word but in the air. There’s always a but when I talk to people about quality.
The but was this: the team couldn’t get behind unit testing. They’d tried to do unit testing before, but it had ended badly. Instead of an aid to developers, the tests became a burden because they were difficult to maintain and time consuming to debug when they broke. So the team gave up on unit testing.
Unit Testing Doesn’t Have to Be Difficult
But it doesn’t have to be this way. Our cloud standard for unit testing, which is available on the Guidewire Documentation site (login required to view), keeps tests easy to understand and easy to maintain. In this post, I’ll use GUnit tests for an InsuranceSuite address lookup integration to show how the standard works.
Before I get to that, here’s what I often see on projects:
class AddressLookupTest {
function testDoLookup() {
var addressLookup = new AddressLookup()
…
addressLookup.doLookup(…)
…
assert(…)
}
}
This test is difficult to maintain for two reasons. First, it’s tightly coupled to classes and functions in the integration code. If these are renamed (as so often happens), the test class or test function must also be renamed. That’s not such a big deal when we have an IDE that supports this kind of refactoring. But it’s still a maintenance overhead, especially if we need to fundamentally restructure the integration code.
The second reason this test is difficult to maintain is that it’s initially unclear what is being tested. If the test breaks, a developer needs to understand what the test is for before attempting a fix, which can take time. And this is true of all developers on the team — even the developer who wrote the test! We quickly forget the intent of the code that we write.
An Easier Approach: Focus on Behaviors
The Guidewire Cloud standard for unit tests takes a different approach. Instead of tests that focus on the classes and functions of the integration, our tests focus on behaviors. For the test described in the preceding section, we can refocus on behaviors by making three changes.
1. Rename the test class.
The test class should tell us what business behavior we’re working on. A business behavior is just a high-level requirement, something the system needs to do from the point of view of the business. Here, we’re working on the behavior of looking up an address.
Thus, we rename our test class as follows:
class WhenLookingUpAnAddressTest {
function testLookup() {
var addressLookup = new AddressLookup()
…
addressLookup.doLookup(…)
…
assert(…)
}
}
This helps us stay focused on the business value when we write our tests. But we also need to focus on how we deliver that value.
2. Rename the test function.
This brings us to the second change: to rename the test function to tell us what technical behavior we’re working on. A technical behavior is just a low-level requirement, something the system needs to do from the point of view of a developer. This integration will have many technical behaviors. Here, I’ll deal with just one: the integration should throw an exception if the request object that contains the details of the address to look up is null.
Thus, we rename our test function as follows:
class WhenLookingUpAddressTest {
function testThatNullRequestThrowsIllegalArgumentException() {
var addressLookup = new AddressLookup()
…
addressLookup.doLookup(…)
…
assert(…)
}
}
3. Add the right logic.
The third change we need to make is to add the right logic to test this behavior. We should think of the logic in any test as comprising three sections:
- The context, which we call “Given”
- The behavior, which we call “When”
- The expected outcome, which we call “Then”
We use these three sections to make our test cleaner and easier to understand:
class WhenLookingUpAddressTest {
function testThatNullRequestThrowsIllegalArgumentException() {
// GIVEN
var request = null
// WHEN
var t : Throwable
try {
new AddressLookup().doLookup(null)
} catch (e : Exception) {
t = e
}
// THEN
assert(t typeis IllegalArgumentException)
}
}
Good Unit Tests = Good Communication
This approach to unit testing is based on principles of behavior-driven development (BDD). Specifically, it draws on Chapter 10 of John Ferguson Smart’s book BDD in Action (page 293):
From a BDD perspective, writing a good unit test is an exercise in good communication. When you practice BDD, you think of every unit test as a low-level specification that illustrates some aspect of how a class or component behaves…. But the implementation of your test is also sample code that illustrates how a particular requirement is satisfied, or how a particular goal is achieved. The code inside your tests doesn’t just exercise the application; it documents how to exercise the application.
Note: If you want to learn more about behavior-driven development methodology, you can enroll in our self-paced Guidewire Education course.
Unit tests that focus on behaviors are more robust when the integration code is refactored because requirements change less frequently than the code that implements them. And because the tests document what the integration code is for and how to use it, a developer faced with a test break can quickly understand what requirement is broken and what to do to fix it. In addition, since the names of test functions describe what the integration code should do rather than how it does it, we’re prompted to think about edge cases — what the integration should do when it veers off the happy path.
In this blog post, we’ve seen one test for one edge case. However, the integration needs to do more than throw an exception when an invalid request is passed. The most important thing it needs to do, at least for business users, is to look up an address when passed a valid request. And we need a test for that behavior. But to write that test, we need to add a new technique to our testing toolbox: test doubles. That’s a topic for next time.
* This is a play on the title of the 1974 mega-best-selling philosophy book by Robert Pirsig, Zen and the Art of Motorcycle Maintenance. While the reference may be obscure to some, Pirsig’s book was recommended reading for one of the programming courses I took while attending university.
About the Author
Rob Kelly
Senior Engineering Manager