One of the telltale signs of software with a disorganized test suite (and perhaps architecture!) is not being able to see a clear divide of the test pyramid (or diamond or whatever test strategy). This pyramid exists with all kinds of proportions and there are still wars being fought over the exact ratios between the compartments but in general a majority will agree that there are three sections: unit, integration and end-to-end.
While Spring Boot and the Spring Framework add a lot of features to ease testing in the context of the framework, it can also be the cause of rapidly decaying the separation of concerns of the test pyramid and quality of a test suite in its whole. Examples include slower test executions (and thus build times), overly complex tests and "Springifying" unit tests when this is not necessary.
An example
Let's consider the following class, with some dependencies being autowired into it. It will be picked up as a managed Spring bean due to the @Service
annotation:
@Service
class MyService {
@Autowired private DependencyA depA;
@Autowired private DependencyB depB;
// [...]
}
And this could be the accompanying fictional "Springified" unit test:
@ExtendWith(SpringExtension.class)
@Import(MyService.class)
class MyServiceTest {
@Autowired private MyService myService;
@MockBean private DependencyA depA;
@MockBean private DependencyB depB;
@Test
void typicalTest() {
// [...] mocking dependencies behavior
// test something myService does
// assert the results [...]
}
}
This is probably the result of someone googling "test bean spring", working around exceptions stating "No qualifying bean of type 'MyService' available
" or just copying stackoverflowChatGPT snippets. This is a common occurrence with developers using a tool (the Spring Framework) but having no idea how it works. Not much later, you will hear them complain about how annotations are complex and mystify the issues they have.
Though the annotations might reduce boilerplate code, they also add a bit of complexity which some consider "indistinguishable from magic" if they have no idea what these annotations do. Roughly:
@ExtendWith(SpringExtension.class)
: hooks Spring test context management into the JUnit lifecycle. Using this annotation typically indicates that there will at least be one managed Spring application context during execution of your test suite;@Import(MyService.class)
: importsMyService
as a Spring managed bean into the current application context used for this test;@Autowired
: injects the importedMyService
bean from the application context into the test class;@MockBean
: imports a mock of a bean into the current application context and makes it available for stubbing in the test. This will dirty the application context meaning it cannot be reused if another bean requires a different (non-mock) implementation
Depending on how big the test suite is and how many variations of the context are used in the tests, you can easily end up with a dozen newly created contexts per test suite which may or may not be cached. In this case, the context is trivial as it imports only one bean and though overhead exists, it is minimal. There are however plenty of examples of people starting much more expensive application contexts just to test a service! This quickly adds up and is often a cause of slower build times.
You don't need a Spring application context
As MyService
is actually a standalone unit, there is zero reason to even require a Spring application context for your unit test - even if it depends on configuration values or other beans. I cannot think of a good reason to have "Spring managed"/"Springified" unit tests even though there is a lot of material online which points beginners in the wrong direction. Just because in the context of a Spring application MyService
is a bean and Spring will initialize it for you, it is still a class that can be instantiated on its own:
MyService myService = new MyService();
Most mocking frameworks (such as Mockito) do support field injection but a better approach would be to change it and use constructor injection to make sure the object is ready for use once instantiated:
class MyService {
private final DependencyA depA;
private final DependencyB depB;
public MyService(DependencyA depA,
DependencyB depB) {
this.depA = depA;
this.depB = depB;
}
// [...]
}
Now, you can instantiate this class in a unit test without Spring context or annotations whatsoever:
class MyServiceTest {
private DependencyA depA = mock(DependencyA.class);
private DependencyB depB = mock(DependencyB.class);
private MyService myService = new MyService(depA, depB);
// [...] tests [...]
}
That doesn't mean annotations or JUnit extensions are bad or evil. For example, the JUnit Mockito extension can remove the boilerplate dealing with mocks being created, instantiation of the subject under test and depending on your strictness level how much verification happens for the mocks after the test has run:
@ExtendWith(MockitoExtension.class)
class MyServiceTest {
@Mock private DependencyA depA;
@Mock private DependencyB depB;
@InjectMocks private MyService myService;
// [...] tests [...]
}
What about controllers?
If you are sticking to "each public unit should be tested separately", there is a difference between a standalone unit test and a test which integrates with a framework. A controller class does not need an application context. You can test a controller class just like the service was tested earlier:
@ExtendWith(MockitoExtension.class)
class MyControllerTest {
@Mock private SomeService someService;
@InjectMocks private MyController myController;
@Test
void typicalTest() {
// [...] mock behavior of someService
// call myController.whateverEndpoint() method
// assert the results [...]
}
}
In an architecture where concerns are separated, a controller is not intended to do much. It is merely an (HTTP) entry point of the application, forwarding that to other components and returning the result. Therefore a unit test of a barebones controller is often not much more than ensuring the "controller unit" properly interacts with the "other components".
As soon as you are adding @WebMvcTest
to test the controller in the context of the Spring Web MVC framework, you are no longer creating a unit test. Even if that test still mocks the dependencies of the controller class:
@WebMvcTest(MyController.class)
class MyControllerWebMvcTest {
@MockBean private SomeService someService;
@Autowired private MockMvc mockMvc;
@Test
void typicalTest() {
// [...] mock behavior of someService
// call mockMvc.perform(get(...))
// assert the results [...]
}
}
At this point, you have walked up the stairs of the test pyramid. It's not a bad thing as long as it is done consciously: should they be named differently or located elsewhere to make clear they are not unit tests? Will you be testing the behavior of the controller or also the filters and Spring Security configuration which will be loaded due to that annotation? What about the controller advices which are now active in the application context? Will you do this for every controller, or only once?
These are some of the questions you might take into consideration. As always, there are tradeoffs which you should make an informed decision about. Otherwise, your test suite easily becomes a big question mark for which it is unclear what exactly you are testing and when.
And what about my web filter? Or JPA interfaces? Or <whatever>?
A rule of thumb is that as soon as you actually need an implementation of the framework to do anything meaningful, the value of the unit test decreases compared to an integration test. For an interface, this counts doubly so. There is no reason to unit test an interface as the name implies: it is an interface, not an actual implementation.
A web filter can be unit tested as a standalone unit. You might want to use some existing mocks to simplify mocking the servlet mechanism. As there's often more than one filter in an application and lots of chaining between those units, a Web MVC integration test targeting filter chains can help here. For a JPA interface, consider using @DataJpaTest
which loads the minimum required application context to test a JPA implementation.
There are many other examples in the Spring Boot reference guide regarding testing of framework implementations without immediately having to choose between "skipping unit tests" and "starting the entire application".
Final words
In all cases, you should be aware of what you are testing and how. Does your application have clear boundaries for the unit and integration tests? Or perhaps you aren't even aware that some of your "unit tests" are in fact integration tests?
The default shouldn't be to slap @SpringBootTest
on your test just to make sure it works. You don't need to document a 5W2H survey for each test but you (and the code!) should definitely be able to explain to a colleague which test is an integration test and why it requires to load the context of the entire application.
Also note that the scope of the tests is as (if not more!) important as the technical details of how the tests are set-up. You can have a meaningless 100% unit test coverage and still have software which delivers zero value!
Enjoyed reading this?
Hi! I sporadically write about tech & business. If you want an email when something new comes out, feel free to subscribe. It's free and I hate spam as much as you do.