Using Spring Boot @SpyBean


Today, a colleague asked me to help him write a REST API integration test. We use Spring’s MockMvc API to test the REST API. The application uses MongoDB with Spring Data MongoDB. The application uses both MongoTemplate and Mongo based repositories for working with MongoDB. To make tests work independent of MongoDB, we mock Spring MongoDB repository interfaces.

Let’s suppose you are testing a REST API resource class shown below.

import java.net.URI;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping(path = "/api/users")
public class UserResource {

	@Autowired
    private UserRepository userRepository;

    @PostMapping
    public ResponseEntity<Void> create(CreateUserRequest request){
        User user = userRepository.save(request.toUser());
        HttpHeaders headers = new HttpHeaders();
        headers.setLocation(URI.create("/api/users/"+user.getId()));
        return new ResponseEntity<>(headers, HttpStatus.CREATED);
    }

}

The code shown above is an example resource class implemented using Spring MVC.

  1. We used @RestController annotation to mark the class as REST API controller. @RestController annotation applies @ResponseBody semantics so you don’t have to manually add it.
  2. Next, we used Spring MongoDB repository interface UserRepository to save user to MongoDB.
  3. Finally, we created ResponseEntity object and returned it back to the user.

The above code can be easily tested in isolation by mocking the UserRepository interface. We will use Spring Boot @MockBean annotation to mock out UserRepository as shown below. @MockBean is Mockito @Mock wrapped in Spring clothes.

import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@RunWith(SpringRunner.class)
@AutoConfigureMockMvc
public class UserResourceTests {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserRepository userRepository;

    @Test
    public void should_create_a_user() throws Exception {
        String json = "{\"username\":\"shekhargulati\",\"name\":\"Shekhar Gulati\"}";
        when(userRepository.save(Mockito.any(User.class))).thenReturn(new User("123"));
        this.mockMvc
                .perform(post("/api/users").contentType(MediaType.APPLICATION_JSON).content(json))
                .andDo(print())
                .andExpect(status().isCreated())
                .andExpect(header().string("Location", "/api/users/123"));
    }
}

If you have written unit tests using Mockito’s @Mock annotation then you will find that @MockBean is very similar to it. The use case of @MockBean is integration test cases. It allows you to mock a specific bean in the dependency graph. Spring testing support with override the real bean with the mock created using Mockito. This is the reason we used Mockito’s when API to set our expectation.

Testing MongoTemplate

If your application only uses Spring Data MongoDB repository interfaces then you can use @MockBean to mock them. It become interesting when your code uses both MongoTemplate and repository interfaces. Let’s suppose we have another resource class that create users using MongoTemplate as shown below.

@RestController
@RequestMapping(path = "/api/users")
public class UserResource {

    @Autowired
    private MongoTemplate mongoTemplate;

    @PostMapping
    public ResponseEntity<Void> create(@RequestBody CreateUserRequest request) {
        User user = this.mongoTemplate.findOne(
                Query.query(Criteria.where("username").is(request.username)),
                User.class
        );
        if (user != null) {
            return new ResponseEntity<>(HttpStatus.CONFLICT);
        }
        mongoTemplate.save(request.toUser(), "user");
        return new ResponseEntity<>(HttpStatus.CREATED);
    }

}

The obvious way we can write test for UserResource is by mocking the MongoTemplate using the @MockBean annotation as shown below.

@SpringBootTest
@RunWith(SpringRunner.class)
@AutoConfigureMockMvc
public class UserResourceTests {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private MongoTemplate mongoTemplate;

    @Test
    public void should_create_a_user() throws Exception {
        String json = "{\"username\":\"shekhargulati\",\"name\":\"Shekhar Gulati\"}";
        when(mongoTemplate.findOne(Mockito.any(Query.class), Mockito.eq(User.class))).thenReturn(new User("123"));
        this.mockMvc
                .perform(post("/api/users").contentType(MediaType.APPLICATION_JSON).content(json))
                .andDo(print())
                .andExpect(status().isCreated());
        verify(mongoTemplate).save(Mockito.any(User.class));
    }
}

But, when you will run the test case you will be greeted by NullPointerException. Yes, NullPointerException!. The stacktrace is shown below.

Caused by: java.lang.NullPointerException
    at org.springframework.data.mongodb.repository.support.MongoRepositoryFactory.&lt;init&gt;(MongoRepositoryFactory.java:73)
    at org.springframework.data.mongodb.repository.support.MongoRepositoryFactoryBean.getFactoryInstance(MongoRepositoryFactoryBean.java:104)
    at org.springframework.data.mongodb.repository.support.MongoRepositoryFactoryBean.createRepositoryFactory(MongoRepositoryFactoryBean.java:88)
    at org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport.afterPropertiesSet(RepositoryFactoryBeanSupport.java:248)
    at org.springframework.data.mongodb.repository.support.MongoRepositoryFactoryBean.afterPropertiesSet(MongoRepositoryFactoryBean.java:117)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1687)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1624)

The reason test threw NullPointerException is that Spring uses MongoTemplate internally to provide implementation of your repository interfaces. One solution could be to not mock both repository interface and MongoTemplate. But that solution would not scale when your application has more than one Spring MongoDB repository. You will have to mock all the repositories each time you have to mock MongoTemplate.

@SpyBean to the rescue

Spy wraps the real bean but allows you to verify method invocation and mock individual methods without affecting any other method of the real bean. So, by making MongoTemplate a SpyBean we can mock only the methods we want to mock in our test case and leave the others untouched.

The modified test case with @SpyBean usage is shown below.

@SpringBootTest
@RunWith(SpringRunner.class)
@AutoConfigureMockMvc
public class UserResourceTests {

    @Autowired
    private MockMvc mockMvc;

    @SpyBean
    private MongoTemplate mongoTemplate;

    @Test
    public void should_create_a_user() throws Exception {
        String json = "{\"username\":\"shekhargulati\",\"name\":\"Shekhar Gulati\"}";
        doReturn(null)
                .when(mongoTemplate).findOne(Mockito.any(Query.class), Mockito.eq(User.class));
        doNothing().when(mongoTemplate).save(Mockito.any(User.class));
        this.mockMvc
                .perform(post("/api/users").contentType(MediaType.APPLICATION_JSON).content(json))
                .andDo(print())
                .andExpect(status().isCreated());
        verify(mongoTemplate).save(Mockito.any(User.class));
    }
}

In the code example shown above, we did the following:

  1. We made MongoTemplate a spy by annotating it with @SpyBean annotation. This does not override the Spring application context with a mock bean. So, repositories will keep working.
  2. Next, we have to set expectations using the doReturn syntax. If you do the usual when syntax, then the real bean will call the method. Hence, it will throw the exception.
  3. To mock the void method mongoTemplate.save(), we used doNothing API of Mockito.
  4. Finally, we verified that save call was made using verify Mockito method.

3 thoughts on “Using Spring Boot @SpyBean”

  1. Thanks for your clear post.
    It works but I don’t know if I will use this annotation.
    I even don’t know if we should use it.

    By Using SpyBean, the mongoTemplate will join the real database to play the query we ask to repository layer.
    So if you don’t have an empty DB for testing, this test will take a looooonngg time (it’s my case).

    Also, unit testing has to be developped to avoid all interaction with external world that is not the case by using SpyBean annotation. So I think it’s not the best approach.

    Regards.
    Logan.

  2. Thanks! I was getting a slightly different error: Failed to load ApplicationContext
    But this solution still worked for me.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: