A common operation in authentication, cache maintenance, and several other concerns is to expire an object that is older than some threshold. Listing 6-12 shows a typical implementation for an authentication token with a two-week lifetime.
public class AuthenticationManager {
public static final EXPIRATION_DAYS = 14;
...
// Return indicates if the token was expired
public boolean expire(AuthenticationToken token) {
Calendar expirationHorizon = Calendar.getInstance();
expirationHorizon.add(Calendar.DAY, -EXPIRATION_DAYS);
Calendar createdDate = token.getCreatedDate();
if (expirationHorizon.after(createdDate)) {
authorizedTokens.remove(token);
return true;
}
return false;
}
}
A good suite of tests for this method would include tests immediately before, at, and after the expiration boundary. Let’s look in particular at the test for just before the expiration boundary (Listing 6-13).
public void testExpire_ImmediatelyBefore() {
Calendar barelyValid= Calendar.getInstance();
barelyValid.add(Calendar.DAY,
-AuthenticationManager.EXPIRATION_DAYS);
barelyValid.add(Calendar.MILLISECOND, 1);
AuthenticationToken token = new AuthenticationToken();
token.setCreatedDate(barelyValid);
AuthenticationManager sut = new AuthenticationManager();
Boolean expired = sut.expire(token);
assertFalse(expired);
}
This looks reasonable, right? What would you say if I told you that this code has a race condition that makes it fail a relatively small percentage of the time? Do you see it? In some ways, this is a subtle variation on the “coincidental equality” described below. The sequence of events that triggers the test failure is as follows.
1. barelyValid
is initialized.
2. One or more milliseconds passes while the rest of the fixture is created.
3. expire()
is invoked, during which expirationHorizon
is initialized to a value later than that to which barelyValid
was initialized.
4. Comparing the adjusted values of the token’s creation date and expirationHorizon
expires the token.
5. expire()
returns true
, failing the test.
The coincidental equality is the assumption that the definition of “now” will not change between the initialization of barelyValid
in the test and the initialization of expirationHorizon
in the implementation. The assumption that the time will not meaningfully change even between adjacent statements occurs much too frequently and can often be remedied with the Extract Variable [REF] refactoring in the test. However, in this case, one of the initializations is outside of the scope of the test, requiring some refactoring of the implementation.
Let’s replace the initialization of expirationHorizon
with a call to the following method by application of the Extract Method [REF] refactoring (see Listing 6-14).
protected Calendar computeNow() {
return Calendar.getInstance();
}
We can now create a nested class in our test class that we will use instead of the real AuthenticationManager
and rewrite our test, as shown in Listing 6-15.
private class TimeFrozenAuthenticationManager
extends AuthenticationManager {
private final Calendar now;
public TimeFrozenAuthenticationManager(Calendar now) {
this.now = now;
}
@Override
protected Calendar computeNow() {
return now.clone();
}
};
public void testExpire_ImmediatelyBefore() {
Calendar now = Calendar.getInstance();
Calendar barelyValid= now.clone();
barelyValid.add(Calendar.DAY,
-AuthenticationManager.EXPIRATION_DAYS);
barelyValid.add(Calendar.MILLISECOND, 1);
AuthenticationToken token = new AuthenticationToken();
token.setCreatedDate(barelyValid);
AuthenticationManager sut =
new TimeFrozenAuthenticationManager(now);
Boolean expired = sut.expire(token);
assertFalse(expired);
}
By encapsulating the invocation of an otherwise nonoverrideable system class, we have introduced the ability to freeze our expiration algorithm in time, making it easily testable. With the addition of some documentation or annotation-based infrastructure, we can suggest or enforce that the computeNow()
5 method should only be called from tests. We have traded a small amount of restricted access to internal state for a high degree of testability. The approach of encapsulating and overriding is one of the most common tools in unit testing. Often the encapsulation is already part of the implementation.
5. I prefer to name the method computeNow()
instead of getNow()
so that the semantics of getters are exclusively associated with the get prefix. As systems grow, methods change in ways that are not anticipated. Rigorous adherence to the semantics for naming helps stave off some types of technical debt. As an example, I once worked with a system that used get methods to fetch complex objects from a database. As the system grew, the act of fetching had consequences requiring additional database writes, turning the get methods into methods that set as well.
3.128.226.255