14 December 2006

Only Mock Immediate Neighbours (i)

We often see tests that are hard to understand because they drive code that calls objects returned from other objects. Here's an example of code that calls a chain of objects.
public void allUnresolvedIssues(IssueHandler handler) throws Failure {
 TicketManager ticketManager = connections.getTicketManager();

 ticketManager.lock();
 try {
   IssueManager  issueManager = ticketManager.getIssueManager();
   Iterator issues = issueManager.allIssuesIterator();

   while (issues.hasNext()) {
     Issue issue = issues.next();
  
     if (issue.isUnresolved()) {
       handler.accept(issue);
     }
   }
 } finally {
   ticketManager.unlock();
 }
}
This is a minor offender, but even so it gets a list of Issues from an IssueManager, which it gets from a TicketManager, which it gets from a Connection. The important part of the method is the filtering of the issues, but to get there it has to navigate three objects, including locking and unlocking the TicketManager. We might have to do something like this to test it.
public void testFiltersUnresolvedIssuesFromIssueManager() throws Failure {
 final ArrayList allIssues = new ArrayList() {{
   add(issue1Unresolved);
   add(issue2);
   add(issue3Unresolved);
 }};

 expects(new InAnyOrder() {{
   allowing(connections).getTicketManager(); will(returnValue(ticketManager));

   expects(new InThisOrder() {{
     one(ticketManager).lock();
     one(ticketManager).getIssueManager(); will(returnValue(issueManager));
     one(issueManager).allIssuesIterator(); will(returnValue(allIssues.iterator()));
  
     one(issueHandler).accept(issue1Unresolved);
     one(issueHandler).accept(issue3Unresolved);
  
     one(ticketManager).unlock();
   }});
 }});

 moreThanImmediateNeighbours.allUnresolvedIssues(issueHandler);
}
I find this confusing to read. First, too much of the test code is for setting up state to get to the interesting feature. Second, the test is exercising several things at once: locking and unlocking the TicketManager, querying the issues within the lock, and filtering the issues. If I want to test more conditions, such as when there are no issues or when something fails, I'll have to duplicate lots of irrelevant test code. I can extract that duplication into setup methods, but I think that's missing what the test is telling me. Another problem is that the test is, more or less, just reproducing the logic of the code, which means it's not adding much value and also makes it brittle when I want to refactor later—especially if I have many similar tests to change. This is a common reason given for not using Mock Objects and a plausible one when dealing with code like this. Let's see if I can break things up a bit. My first thought is that I can separate out everything to do with the TicketManager by extracting a method.
  public void allUnresolvedIssues(IssueHandler handler) throws Failure {
   TicketManager ticketManager = connections.getTicketManager();
  
   ticketManager.lock();
   try {
     retrieveUnresolvedIssues(ticketManager.getIssueManager(), handler);
   } finally {
     ticketManager.unlock();
   }
 }

 private void retrieveUnresolvedIssues(IssueManager issueManager, IssueHandler handler) {
   Iterator issues = issueManager.allIssuesIterator();
  
   while (issues.hasNext()) {
     Issue issue = issues.next();
    
     if (issue.isUnresolved()) {
       handler.accept(issue);
     }
   }
 }
Now I can see a bit more clearly the two activities going on here: getting hold of the issue manager, and retrieving issues from it. I can break these up into different, focussed objects. I could start from the current design and refactor, but I want to begin again to show the design process...

4 comments:

Dave said...

Steve, instead of breaking things out into a separate function, I would consider one of two things:

- Grab the issue manager directly, since that seems to be what you need here. or...
- Add a getAllIssues method to the TicketManager.

Either way, now you're not having to acquire an object from an object and I feel that helper methods like your retrieveUnresolvedIssues are code smells. We should be focused on calling methods on objects. Even if you don't have access to the TicketManager, you can still wrap that object to provide the functionality you need here.

Steve Freeman said...

You're ahead of me :) I was about to write up some alternatives in my next posting. Hang in there...

Matthew said...

The first method is uncomposed. The proposed change involves composing the method. How does composing the method remove the need to create all the mocks? It looks to me that the test prep is still the same.

Also dave mentioned that the compose method refactoring (he calls it a helper method) is a code smell. What code smell do you think it is?

Matthew said...

I thought about it a bit and I think I answered my own questions.

The code smell is possibly lazy class; it is impossible to tell without more context.

IssueHanlder, TaskManager, or IssueManager should own the code for retreiving the unresolved issues. This code could be tested as part of one of those objects greatly reducing the number of mocks required.