Encapsulate and Override

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.

Listing 6-12: A typical implementation to expire 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).

Listing 6-13: Expiration boundary test for Listing 6-12

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).

Listing 6-14: Refactoring initialization of the expiration horizon for testability

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.

Listing 6-15: Rewriting the test from Listing 6-13 to use our refactored code

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.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.128.226.255