Don’t component-scan in main Spring application class when using Spring Web MVC Test


Posted by Steven

This article is about why annotating the Spring application class with @ComponentScan may cause problems when also using Spring Web MVC Test.

The code can be found here.

General Setup

The demo application is a simple Spring Boot application with a component scan directly in the application class:

  1. @SpringBootApplication
  2. @ComponentScan(value = {"de.stevenschwenke.java.springwebmvctestcomponentscan",
  3. "some.generated.stuff"})
  4. public class SpringWebMVCTestComponentScanApplication {
  5.  
  6. public static void main(String[] args) {
  7. SpringApplication.run(SpringWebMVCTestComponentScanApplication.class, args);
  8. }
  9. }

This scan includes all classes in the main package hierarchy and additionally external code in the package “some.generated.stuff”. A similar setup can be found in code that uses generated classes, which may be generated into places like target/generated-sources. Annotating the main application class this way is the simplest solution to finding all necessary classes. However, this setup will cause problems.

To demonstrate these problems, the demo application consists of three “business slices”:

“businessslice1” and “businessslice3” only consist of a controller that uses a simple service to create an endpoint:

  1. @RestController
  2. @RequestMapping("/business1")
  3. public class BusinessController1 {
  4.  
  5. private final BusinessService1 businessService1;
  6.  
  7. public BusinessController1(BusinessService1 businessService1) {
  8. this.businessService1 = businessService1;
  9. }
  10.  
  11. @GetMapping("/")
  12. public ResponseEntity<String> doStuff() {
  13. return ResponseEntity.of(Optional.of(businessService1.doBusinessStuff()));
  14. }
  15. }
  1. @Service
  2. public class BusinessService1 {
  3.  
  4. public String doBusinessStuff() {
  5. return "business 1";
  6. }
  7. }
  1. @RestController
  2. @RequestMapping("/business3")
  3. public class BusinessController3 {
  4.  
  5. private final BusinessService3 businessService3;
  6.  
  7. public BusinessController3(BusinessService3 businessService3) {
  8. this.businessService3 = businessService3;
  9. }
  10.  
  11. @GetMapping("/")
  12. public ResponseEntity<String> doStuff() {
  13. return ResponseEntity.of(Optional.of(businessService3.doBusinessStuff()));
  14. }
  15. }
  1. @Service
  2. public class BusinessService3 {
  3.  
  4. public String doBusinessStuff() {
  5. return "business 3";
  6. }
  7. }

“businessslice2” is slightly more complex by referencing an empty repository:

  1. @RestController
  2. @RequestMapping("/business2")
  3. public class BusinessController2 {
  4.  
  5. private final BusinessService2 businessService2;
  6.  
  7. public BusinessController2(BusinessService2 businessService2) {
  8. this.businessService2 = businessService2;
  9. }
  10.  
  11. @GetMapping("/")
  12. public ResponseEntity<String> doStuff() {
  13. return ResponseEntity.of(Optional.of(businessService2.doBusinessStuff()));
  14. }
  15. }
  1. @Service
  2. public class BusinessService2 {
  3.  
  4. private final BusinessRepository2 businessRepository2;
  5.  
  6. public BusinessService2(BusinessRepository2 businessRepository2) {
  7. this.businessRepository2 = businessRepository2;
  8. }
  9.  
  10. public String doBusinessStuff() {
  11. return "business 2";
  12. }
  13. }
  1. public interface BusinessRepository2 extends JpaRepository<SomeBean, Long> {
  2. }
  1. public class SomeBean {
  2.  
  3. @Id
  4. @GeneratedValue(strategy = GenerationType.AUTO)
  5. private Long id;
  6.  
  7. }

Problem 1: Slice corruption

As mentioned above, problems occur when testing this application with Spring Web MVC Test. The problem is the set of Spring beans loaded into the application context. Because the component scan at the application class is used whenever the application context is built, all beans found are loaded. This is the opposite of what a Web MVC Test is supposed to do. It is meant to load only one controller and its dependencies if the scan would not be there. If the scan is set as in the code above, much more beans are created as the following test shows:

  1. @WebMvcTest(BusinessController1.class)
  2. class ComponentScanOnApplicationClassTests {
  3.  
  4. @Autowired
  5. private MockMvc mockMvc;
  6.  
  7. // Mocking this bean here is OK because it is a dependency from the controller under test.
  8. @MockBean
  9. private BusinessService1 businessService1;
  10.  
  11. // This bean has to be mocked for this test. Ignore it for now, more on this in "problem 2".
  12. @MockBean
  13. private BusinessService2 businessService2;
  14.  
  15. @Test
  16. void sliceCorrupted() {
  17.  
  18. // A call to an endpoint not targeted in this MVC-test should throw an error here because only the controller
  19. // targeted in the @WebMvcTest-annotation is constructed. Hence, the endpoint /business3/ does not exist,
  20. // causing the call to return 404.
  21.  
  22. assertThrows(AssertionError.class, () ->
  23. this.mockMvc.perform(get("/business3/"))
  24. .andExpect(status().isOk())
  25. .andExpect(content().string("business 3")));
  26. }
  27.  
  28. }

This test will be red if the component scan resides at the application class as shown at the top of this article because no exception was thrown. Hence, the beans of the slice named "business3" were created, which should not be the case in a Spring MVC Test that tests only BusinessController1.

Although this does not necessarily lead to problems because if tests are green, who cares how many beans are created. However, this could make later defects harder to spot and could also be a performance issue (I did not test for that).

Problem 2: Unnecessary Mocks

Another issue with loading too much beans is accidentally loading components out of scope of Web MVC Test. This annotation will only load classes like controllers, ControllerAdvices and some other, see the documentation. It will not load database-specific classes like repositories. When writing an MVC-test for BusinessController1, it is necessary to mock all dependencies that are outside this scope, for example repositories. This can be done easily by mocking the single dependency of the controller:

  1. @MockBean
  2. private BusinessService1 businessService1;

However, if the context loads all sort of other beans, some of them may depend on repositories. This causes exceptions like this one:

  1. java.lang.IllegalStateException: Failed to load ApplicationContext
  2. Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'businessController2' defined in file [C:\repositories\springwebmvctestcomponentscan\target\classes\de\stevenschwenke\java\springwebmvctestcomponentscan\businessslice2\BusinessController2.class]: Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'businessService2' defined in file [C:\repositories\springwebmvctestcomponentscan\target\classes\de\stevenschwenke\java\springwebmvctestcomponentscan\businessslice2\BusinessService2.class]: Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'de.stevenschwenke.java.springwebmvctestcomponentscan.businessslice2.BusinessRepository2' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}

Here's the test that causes this exception:

  1. @WebMvcTest(BusinessController1.class)
  2. class ComponentScanOnApplicationClassTests {
  3.  
  4. @Autowired
  5. private MockMvc mockMvc;
  6.  
  7. // Mocking this bean here is OK because it is a dependency from the controller under test.
  8. @MockBean
  9. private BusinessService1 businessService1;
  10. @Test
  11. void shouldBeGreenTest() throws Exception {
  12.  
  13. // This test is supposed to be green but fails because a repository is loaded in a non-mocked bean from slice
  14. // 2, which would make it necessary to mock slice 2.
  15.  
  16. when(businessService1.doBusinessStuff()).thenReturn("correct return value");
  17.  
  18. this.mockMvc.perform(get("/business1/"))
  19. .andExpect(status().isOk())
  20. .andExpect(content().string("correct return value"));
  21. }
  22. }

Although only slice “1” should be tested, BusinessController2 was instantiated. Having a look at this one reveals a dependency to BusinessService2 …

  1. @RestController
  2. @RequestMapping("/business2")
  3. public class BusinessController2 {
  4.  
  5. private final BusinessService2 businessService2;
  6.  
  7. public BusinessController2(BusinessService2 businessService2) {
  8. this.businessService2 = businessService2;
  9. }
  10.  
  11. @GetMapping("/")
  12. public ResponseEntity<String> doStuff() {
  13. return ResponseEntity.of(Optional.of(businessService2.doBusinessStuff()));
  14. }
  15.  
  16. }

… and within this a dependency to BusinessRepository2:

  1. @Service
  2. public class BusinessService2 {
  3.  
  4. private final BusinessRepository2 businessRepository2;
  5.  
  6. public BusinessService2(BusinessRepository2 businessRepository2) {
  7. this.businessRepository2 = businessRepository2;
  8. }
  9.  
  10. public String doBusinessStuff() {
  11. return "business 2";
  12. }
  13. }

Repositories are not loaded by Web MVC test, which causes the exception.

A suboptimal “fix” could be to mock BusinessService2 like this:

  1. @MockBean
  2. private BusinessService2 businessService2;

However, this does not scale. Every new repository that is added to the application could force fixing of all tests.

The solution

At least for me, the best solution for these problems is to move the component scan away from the application class to its own configuration:

  1. @Configuration
  2. @ComponentScan(value = {"some.generated.stuff"})
  3. public class GeneratedCodeScanner {
  4. }

This configuration will be picked up when the whole application starts (including SpringBootTests), but not from Web MVC Tests.

Again, the code can be found here.

Category: 
Share: