Every now and then we stumble over unit tests with much setup and numerous checked aspects. These tests easily become a maintenance nightmare. While J.B. Rainsberger advocates getting rid of integration tests in his somewhat lengthy but very insightful talk at Agile 2009 he gives some advice I would like to use as a guide to better unit tests. His goal is basic correctness achieved by the means of what he aptly calls focused tests. Focused tests test exactly one interesting behaviour.
The proposed way to write these focused tests is to look at three different topics for each unit under test:
- Interactions (Do I ask my collaborators the right questions?)
- Do I handle all answers correctly?
- Do I answer questions correctly?
Conventional unit testing emphasizes on the third topic which works fine for leave classes that do not need collaborators. Usually, your programming world is not as simple, so you need mocking and stubbing to check all these aspects without turning your unit test into some large integration test that is slow to run and potentially difficult to maintain.
I will try to show you the approach using a simple and admittedly a bit contrived example. Hopefully, it illustrates Rainsberger’s technique good enough. Assume the IllustrationController
below is our unit under test:
public class IllustrationController { private final PermissionService permissionService; private final IllustrationAction action; public IllustrationController(PermissionService permissionService, IllustrationAction action) { super(); this.permissionService = permissionService; this.action = action; } /** * @return true, if the action was executed, false otherwise */ public boolean performIfAllowed(Role r) { if (!permissionService.allowed(r)) { return false; } this.action.execute(); return true; } }
It has two collaborators: PermissionService
and IllustrationAction
. The first thing to check is:
Do I ask my collaborators the right questions?
In this case this is quite simple to answer, as we only have a few cases: Do we pass the right role to the PermissionService? This results in tests like below:
@Test public void asksForPermissionWithCorrectRole() throws Exception { PermissionService ps = mock(PermissionService.class); IllustrationAction action = mock(IllustrationAction.class); IllustrationController ic = new IllustrationController(ps, action); ic.performIfAllowed(Role.User); // this question needs a test in PermissionService verify(ps, atLeastOnce()).allowed(Role.User); ic.performIfAllowed(Role.Admin); // this question needs a test in PermissionService verify(ps, atLeastOnce()).allowed(Role.Admin); }
Do I handle all answers correctly?
In our example only the PermissionService provides two different answers, so we can easily test that:
@Test public void interactsWithActionBecausePermitted() { PermissionService ps = mock(PermissionService.class); IllustrationAction action = mock(IllustrationAction.class); // there has to be a case when PermissionService returns true, so write a test for it! when(ps.allowed(any(Role.class))).thenReturn(true); IllustrationController ic = new IllustrationController(ps, action); ic.performIfAllowed(Role.Admin); verify(ps, atLeastOnce()).allowed(any(Role.class)); verify(action, times(1)).execute(); } @Test public void noActionInteractionBecauseForbidden() { PermissionService ps = mock(PermissionService.class); IllustrationAction action = mock(IllustrationAction.class); // there has to be a case when PermissionService returns false, so write a test for it! when(ps.allowed(any(Role.class))).thenReturn(false); IllustrationController ic = new IllustrationController(ps, action); ic.performIfAllowed(Role.User); verify(ps, atLeastOnce()).allowed(any(Role.class)); verify(action, never()).execute(); }
Note here, that not only return values are answers but also exceptions. If our action may throw exceptions on execution we can handle, we have to test that too!
Do I answer questions correctly?
Our controller answers the question, if the operation was performed or not by returning a boolean from its performIfAllowed()-method so lets check that:
@Test public void handlesForbiddenExecution() throws Exception { PermissionService ps = mock(PermissionService.class); IllustrationAction action = mock(IllustrationAction.class); when(ps.allowed(any(Role.class))).thenReturn(false); IllustrationController ic = new IllustrationController(ps, action); assertFalse("Perform returned success even though it was forbidden.", ic.performIfAllowed(Role.User)); } @Test public void handlesSuccessfulExecution() throws Exception { PermissionService ps = mock(PermissionService.class); IllustrationAction action = mock(IllustrationAction.class); when(ps.allowed(any(Role.class))).thenReturn(true); IllustrationController ic = new IllustrationController(ps, action); assertTrue("Perform returned failure even though it was allowed.", ic.performIfAllowed(Role.Admin)); }
Conclusion
What we are doing here is essentially splitting different aspects of interesting behaviour in their own tests. The first two questions define the contract between our unit under test and its collaborators. For every question we ask and therefore stub using our mocking framework there has to be a test, that verifies that this question is answered like we expect it. If we handle all the answers correctly, our interaction is deemed to be correct, too. And finally, if our class implements its class contract correctly by answering the third question our clients also know what to expect and can rely on us.
Because each test focuses on only one aspect it tends to be simple and should only break if that aspect changes. In many cases these kind of tests can make your integration tests obsolete like Rainsberger states. I think there are cases in modern frameworks like grails where you do not want to mock all the framework magic because it is too easy to make wrong assumptions about the behaviour of the framework. So imho integration tests provide some additional value there because the behaviour of the platform stays part of the tests without being tests explicitly.
This article made me finally understand the ‘other’ two questions (the first and second) that you may not regularly see after a TDD exercise. Thank you for the clarity. I will use this advice from now on to bolster any TDD skills i may have.