Abstract vs. Concrete Parameters
Contradictory Patterns for Testable Designs
Originally published August 2008. It’s interesting to me how many of the themes of 15 years ago are still relevant today. I was more down in the programming details then than I usually am today. Let me know if this sort of specific advice helps.
Easy-to-test software is "controllable". Testers can cheaply and accurately simulate the contexts in which the software needs to run. Two contradictory patterns help achieve controllability: making parameters more concrete and more abstract. This apparent contradiction resolves when looked at from a broader perspective.
Introduction
Designing software is hard. Designs need to:
Be understandable to those who will implement them
Support currently required functionality
Support future changes, anticipated and unanticipated.
The two economic imperatives of software development—rapid initial time to market and low lifecycle costs—often call for different designs. Designers need to find some resolution of these technical and economic conflicts.
As if this wasn't difficult enough, Extreme Programming comes along and calls for software to be testable, automatically testable, as well. For software to be testable, it must be controllable and observable. Observability is the ability to measure the effects of a computation. I won't talk about more in this note. Controllability is the ability to reproduce all the contexts in which an object is to run, normal and abnormal. The new job of design is to make sure that software is controllable cheaply in both developer time and execution time.
I wrote tests and programs together for years without explicitly worrying about controllability. One of the strengths of test-driven development is that using it provides immediate feedback about whether or not a proposed API can exercise an object and how difficult the API is to use. In more complicated situations, though, or when dealing with legacy code, I've found thinking explicitly about controllability to be helpful.
Concrete Parameters Enhance Controllability
An early encounter with controllability came when I was working on an insurance system. We needed to be sure that we were retrieving the correct mortality table for a customer. Our first attempt at a test/interface pair was to pass the customer as a parameter:
class MortalityTableTest {
@Test retrieveMaleNonSmoker() {
// ... a bunch of stuff to create a male, non-smoking customer
MortalityTable result= MortalityTable.lookup(customer);
assertEquals("QM115", result.getName());
}
}
This worked, but it was expensive, both writing the code to create a customer and the execution time to create the megabyte of objects necessary to create a well-formed customer. This left the test slow and vulnerable to changes in the way we set up customers.
Looking at the MortalityTable.lookup() code we could see that the only parts of a Customer we used were the gender and smoker status: two one-bit enums out of a megabyte. Rather than wait and let the table lookup extract this information, we could shift that bit of code to the caller [ed: more on this kind of call tree manipulation later]:
class MortalityTableTest {
@Test retrieveMaleNonSmoker() {
// ... a bunch of stuff to create a customer
MortalityTable result=
MortalityTable.lookup(
customer.getGender(),
customer.getSmoker());
assertEquals("QM115", result.getName());
}
}
The second step was to inline all the customer creation and gender/smoker retrieval, since we knew what the answers would be:
class MortalityTableTest {
@Test retrieveMaleNonSmoker() {
MortalityTable result=
MortalityTable.lookup(Gender.MALE, Smoker.NO);
assertEquals("QM115", result.getName());
}
}
The resulting test is easier to read and runs fast. Changes to the Customer won't affect the test. However, the test is vulnerable to changes in the lookup process. In the first version, if the lookup began also checking for marital status we would only have to change the test to set the appropriate status. In the third version, we would not only have to change the test, we would also have to change the API of MortalityTable. As long as mortality table lookup remains stable, though, we improved the controllability of our system by passing more-concrete parameters.
Abstract Parameters Enhance Controllability
Recently, I had an experience that seemed to offer the opposite conclusion. A questioner to the JUnit mailing list asked about testing a legacy object that needed to communicate over a socket. The existing API took an IP address and port number. How could they write tests?
A black box strategy is to create a test fixture that can open up server sockets simulating the various test conditions. This has the advantage that the object-under-test need not be modified. However, it would be a fairly complex fixture to write, making sure to completely clean up the test bed, correctly handle timeouts and avoid race conditions.
An alternative is to objectify the IP address and port number into a SocketConnection. Rather than pass raw numbers into the constructor, pass a SocketConnection (an existing implementation, if possible). The current constructor can be grandfathered by having it create a connection:
class Client {
Client(int address, int port) {
this(new SocketConnection(address, port));
}
}
Tests can then create impostor connections to simulate all the test scenarios. This solution will likely run faster and be easier to write tests for, but at the cost of modifying the Client. The big point, from the perspective of this paper, is that controllability in this case was achieved by passing a more abstract parameter.
Ambiguous? Maybe. Contradictory? No.
Here we run aground. In the first case, controllability came from making the parameters more concrete, in the second, from making them more abstract. Which is it? What is the simple rule?
It turns out that in this situation, the rule for achieving controllability is not simple and linear. How best to design for controllability is the result of a tradeoff between the cost and the required flexibility of the tests. One leg of the tradeoff says that concrete parameters are more economical than abstract parameters:
The second leg of the tradeoff says that the flexibility of tests are enhanced by abstract parameters:
Put the two together and you have the tradeoff curve:
As a designer, I find such tradeoffs to be extremely useful, especially as a way of explaining my decisions. I may get an intuition without explicitly thinking of the tradeoff, but when I want to discuss a decision I find it valuable to be able to say, "In this case, we really don't need flexibility, so concrete parameters are appropriate":
Alternatively, if my gut tells me a more-abstract parameter would be an improvement, I like being able to illustrate it, "We have all these tests and all these parameters. I think it would be simpler to bundle them together.":
Seeing the two factors together helps me be more aware of when I have an opportunity to improve my design by sliding towards abstract or concrete parameters.
Conclusion
Now I can state the general rule: to make software controllable, pass concrete parameters when tests don't need much flexibility and pass abstract parameters when they need more flexibility. Having just realized this relationship, though, I'm not sure how far it will go. If you find interesting examples (or counter-examples), I'd love to hear about them.
"if my gut tells"...
I think the designer decision for concrete or abstract is proportional to the knowledge of the domain. When you know the domain you know what moves/changes frequently, what is fixed, what has many scenarios, etc and it's easier to find the right balance.
In a scenario where you have a low domain knowledge it is common to make bad decisions on the tradeoff curve, for both sides. In this context I would suggest to got for more concretes design since it is easier to refactoring and avoid premature optimisation (in this case, predicting scenarios that you don't know for sure)
Recently, I came across advice suggesting we should "Strive for unchanging tests" in the book 'Engineering at Google'. This resonated with your discussion about controllability. I believe the aim of creating code that is resilient to change, or can be easily modified when necessary, is not exclusive to tests but extends to any piece of code we produce.
In your view, how closely does this idea of striving for unchanging code align with the controllability you're discussing? Do you see them as two facets of the same principle?