In my previous column, I discussed functional TDD, which is a form of TDD I am cottoning to. The basic difference when compared with unit test-based TDD is that the functional test serves as the driver of code. I write a failing functional test that defines my next coding effort. When the test passes, I either write another, or, more often, I’ll write unit tests to exercise edge conditions on the code I’ve just created. This approach confers many of the same benefits as traditional TDD, while mitigating some of its shortcomings. I discussed some of these in the previous column and promised I’d discuss the tools I use here.
I write functional tests in the two fundamental coding scenarios: prior to writing new code, and when maintaining existing code. Because I code primarily in Java, I’ll look at the tools that work with it.
When fixing defective code, I reproduce the problem using Groovy as a scripting language. Groovy scripts are easy to hack together quickly, and the syntax is close enough to Java that its use is not an obstacle to other developers on the team. They can understand the tests easily. I try to reproduce the defect at the highest level possible. An ideal result is running the entire application and causing the error to show. Then, I’m really testing how the solution I propose works with the various computational units it deals with in deployment, rather than testing it in isolation.
These functional tests go into a regression suite, and the entire suite is run before the defect is closed to make sure all is copasetic. Once the functional test passes, I look for edge conditions and code that was insufficiently tested. And for items, I write either additional functional tests or, more commonly, unit tests. The choice depends on the locus and scope of the tests.
When it comes to writing new code, I rely on tools normally associated with behavior-driven development (BDD). This approach favors writing code scenarios from requirements in the form of tests, and then implementing the scenarios in code. Typically, the scenarios have the form of: given X and Y, when A occurs, B should result. The framework then sets up X and Y, does A, and tests for B.
There are two BDD frameworks that I know of that do this well in the Java universe: easyb, which won a Jolt award last year, and JBehave. Many BDD-oriented frameworks are appearing for other languages, including Cucumber, RSpec and ScalaTest, and they’re gaining popularity.
The scenario-based approach of BDD does not inherently define the scope of tests. It can be used at a high level where the test incarnates the project requirements. In theory, if all scenarios pass, the project is complete. They can also dip down to unit and sub-unit levels.
The JUnit style of testing with set-ups and tear-downs and frequent setting of mocks to create an environment where a specific condition can be tested is not that different from a “given X, given Y” scenario. For my purposes, I use BDD for a level that falls between these two endpoints: something greater than a unit but less than the full program, which (crucially!) articulates a scenario the user would recognize. I attempt to stay above implementation details in the scenarios.
The benefit of all this is that unit tests return to their original role: testing the workings of units. And functional tests continue with their mission of testing the functionality of larger groups of units. I rarely have the experience I used to occasionally have in the past, which consisted of writing numerous unit tests, which passed successfully even though the resulting functionality did not work correctly. Now, both the functionality and implementation details are tested together and work in concert to deliver the user requirements.
This approach fixes one of the byproducts of TDD, which is inescapable: The investment in large numbers of unit tests creates an obstacle to change. TDD exponents tend to understate this effect by pointing out that all those unit tests are excellent at enabling change by letting developers refactor code with confidence.
Refactor, sure; change, not so much. By having suites of both functional and unit tests, I can make large changes, and my functional tests should still work. In fact, I posit that functional tests make better characterization tests than do unit tests. I can tell right off whether I’ve unhinged functionality that I promised the user.
That’s my first concern. Later, I can resolve the matter of writing new unit tests to lock down the edge cases.
Andrew Binstock is the principal analyst at Pacific Data Works. Read his blog at binstock.blogspot.com.