Abstract
vs. Concrete Parameters:
Contradictory Patterns for Testable Designs
Kent
Beck,
Three Rivers Institute
August 2008
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, they need to support currently required functionality,
and they need to 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 it 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:
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.