Simplified Explanation: MockMVC Testing in Spring

Overview of Testing Types

  1. Unit Tests:
    • Test individual pieces of code (e.g., a single class or method).
    • Should be fast, lightweight, and independent of external systems (no databases, Spring context, or servers).
    • Focus on code coverage, ideally 70-80%.
    • Examples include validating logic or behavior within a class.
  2. Integration Tests:
    • Test interactions between components (e.g., multiple classes or modules).
    • Can involve the Spring context, databases, or message brokers.
    • Broader in scope and slower compared to unit tests.
  3. Functional Tests:
    • Test the system end-to-end, often on a running application.
    • Simulate user interactions, such as API calls, and validate outcomes.
    • These are the heaviest and most time-consuming tests.

Testing Pyramid

  • Base: Unit Tests (most tests should fall here).
  • Middle: Integration Tests.
  • Top: Functional Tests (least detailed, lowest volume).

Why Controllers Are Hard to Unit Test

Spring MVC controllers rely heavily on the framework for:

  • Routing requests.
  • Binding data (e.g., JSON to Java objects).
  • Formatting responses.

Because of this, pure unit tests often fall short. Enter MockMVC, a tool that simulates the servlet environment and provides:

  • Mock HTTP requests and responses.
  • A dispatch servlet (mimicking a real web server).

What Makes MockMVC Special?

  • MockMVC runs in a controlled Spring test environment.
  • It can work with or without a full Spring context:
    • Without Spring context: Closer to true unit tests.
    • With Spring context: Considered integration tests.

Important

By using WebMvcTest, you load only a small slice of the Spring context, focusing on the controller under test. Dependencies (like services) are not automatically loaded, so you use mocks for them.

Using Mockito for Mocks

Mockito is a popular framework for creating test doubles:

  • Dummy: Exists only to make code compile.
  • Fake: Simple implementation, not production-ready.
  • Stub: Returns predefined responses to specific calls.
  • Mock: Like a stub, but can verify interactions (e.g., call count).
  • Spy: Wraps an actual object, allowing you to monitor interactions.

Mocks are especially useful in controller tests for:

  • Verifying input data is parsed correctly (e.g., JSON to objects).
  • Simulating service layer responses.
  • Testing exception handling.

Testing Flow with MockMVC

  1. Set up the test environment:
    • Use JUnit to provide the test framework.
    • Configure Spring MockMVC via WebMvcTest.
    • Mock dependencies with Mockito.
  2. Write the test:
    • Simulate a request (e.g., GET /users/1).
    • Mock the service layer’s response.
    • Verify the controller’s logic and response formatting.
  3. Validate outputs:
    • Ensure JSON data is parsed and bound correctly.
    • Confirm service methods were called as expected.
    • Test response data, including exceptions.

@SpringBootTest vs @WebMvcTest

Here’s a breakdown of the differences between @SpringBootTest and @WebMvcTest(BeerController.class) with examples:

1. Purpose

  • @SpringBootTest:

    • Loads the entire Spring application context.
    • Useful for integration tests that require all the application components, including services, repositories, and the full Spring Boot environment.
    • Runs slower due to the overhead of initializing the full context.
  • @WebMvcTest(BeerController.class):

    • Loads only the Spring MVC components relevant to the specified controller (BeerController in this case).
    • Useful for focused testing of the web layer (e.g., controllers, request mapping, response handling).
    • Runs faster because it doesn’t load the entire application context.

2. Dependencies and Setup

  • @SpringBootTest:
    • Includes all application components like services, repositories, and configurations.

    • All dependencies are injected by the Spring container.

      @SpringBootTest
      class BeerControllerTest {
       
          @Autowired
          BeerController beerController;
       
          @Test
          void getBeerById() {
              System.out.println(beerController.getBeerById(UUID.randomUUID()));
          }
      }
    • In this example, BeerController depends on BeerService. Since the full context is loaded, the real BeerService implementation would be used unless explicitly mocked.


  • @WebMvcTest(BeerController.class):
    • Focuses only on the MVC layer.

    • Requires explicitly mocking dependencies like BeerService (using @MockBean or MockitoBean).

    • Doesn’t include non-web-related beans like repositories or external configurations.

      @WebMvcTest(BeerController.class)
      class BeerControllerTest {
       
          @Autowired
          MockMvc mockMvc;
       
          @MockitoBean
          BeerService beerService;
       
          @Test
          void getBeerById() throws Exception {
              Beer testBeer = new Beer(UUID.randomUUID(), "Test Beer", "IPA", 6.5);
       
              given(beerService.getBeerById(any(UUID.class))).willReturn(testBeer);
       
              mockMvc.perform(get("/api/v1/beer/" + UUID.randomUUID())
                      .accept(MediaType.APPLICATION_JSON))
                      .andExpect(status().isOk())
                      .andExpect(content().contentType(MediaType.APPLICATION_JSON));
          }
      }
    • In this example, BeerService is mocked, ensuring that only the BeerController logic is tested, without interacting with the actual BeerService implementation.


3. When to Use

  • @SpringBootTest:

    • Use for end-to-end integration testing when you need the complete application context.
    • For example:
      • Testing service-layer logic along with database integration.
      • Verifying how multiple components work together.
  • @WebMvcTest:

    • Use for controller testing when you want to focus on request mappings, input validation, response formats, and HTTP behavior.
    • It’s faster and more focused than @SpringBootTest.

4. Performance

  • @SpringBootTest: Slower due to the full context initialization.
  • @WebMvcTest: Faster because only the web layer is initialized.

Key Takeaway:

  • Use @SpringBootTest for comprehensive integration tests that involve multiple layers of the application.
  • Use @WebMvcTest for isolated controller tests, mocking dependencies and focusing on the web layer.

Testing JSON response with Jayway JsonPath

@WebMvcTest(BeerController.class)  
class BeerControllerTest {  
  
    @Autowired  
    MockMvc mockMvc;  
  
    @MockitoBean  
    BeerService beerService;  
  
    BeerServiceImpl beerServiceImpl = new BeerServiceImpl();  
  
    @Test  
    void getBeerById() throws Exception {  
        Beer testBeer = beerServiceImpl.listBeers().get(0);  
  
        given(beerService.getBeerById(testBeer.getId())).willReturn(testBeer);  
  
        mockMvc.perform(get("/api/v1/beer/" + testBeer.getId())  
                .accept(MediaType.APPLICATION_JSON))  
                .andExpect(status().isOk())  
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))  
                .andExpect(jsonPath("$.id", is(testBeer.getId().toString())))  
                .andExpect(jsonPath("$.beerName", is(testBeer.getBeerName())));  
    }  
}

Testing creating JSON with Jackson to do the POST

We want to create and update beer objects using our API, which means we need to pass JSON data into MockMvc. While you could handwrite the JSON, that’s tedious and error-prone. A better approach is to use Jackson, a library that can handle JSON serialization and deserialization for us.

Getting Started with Jackson

Jackson provides an ObjectMapper, a powerful tool for converting Java objects (POJOs) to JSON and vice versa. Let’s start by creating a simple test using it. We’ll use a beer object from our BeerServiceImpl and convert it to JSON.

  1. Basic Conversion:

    objectMapper.writeValueAsString(beer);

    This converts the beer object to a JSON string. However, if something goes wrong, this method throws an exception, so we’ll add it to the test signature.

  2. Handling Date Formats: By default, Jackson doesn’t support Java 8 LocalDateTime. Running the test will result in an error. To fix this, we need to configure Jackson to load the appropriate module:

    objectMapper.findAndRegisterModules();

    This enables support for handling Java 8 date/time types. Now the test should pass, and you’ll see the JSON representation of the beer object.

  3. Improving Configuration: The default date format may not be ideal. Jackson offers many configuration options, but instead of customizing everything manually, we can use Spring Boot’s pre-configured ObjectMapper.


Using Spring Boot’s ObjectMapper

Spring Boot automatically configures an ObjectMapper with sensible defaults. Instead of creating a new instance, we can use the one provided by the Spring context.

  • Autowiring the ObjectMapper:

    @Autowired
    private ObjectMapper objectMapper;

    This ensures that the same configuration is used across both tests and application components, avoiding conflicts and duplicate configurations.

  • Benefits:

    • You get consistent behavior for date formats and other settings.
    • Spring Boot’s defaults are often sufficient, but they can be customized if needed.
    • Reduces maintenance since there’s only one configuration to manage.

When we run the test with Spring Boot’s ObjectMapper, we’ll see improvements like a nicely formatted date.

@WebMvcTest(BeerController.class)  
class BeerControllerTest {  
  
    @Autowired  
    MockMvc mockMvc;  
  
    @Autowired  
    ObjectMapper objectMapper;  
  
    @MockitoBean  
    BeerService beerService;  
  
    BeerServiceImpl beerServiceImpl;  
  
    @BeforeEach  
    void setUp() {  
        beerServiceImpl = new BeerServiceImpl();  
    }  
  
    @Test  
    void testCreateNewBeer() throws Exception {  
        Beer beer = beerServiceImpl.listBeers().get(0);  
        beer.setVersion(null);  
        beer.setId(null);  
  
        given(beerService.saveNewBeer(any(Beer.class))).willReturn(beerServiceImpl.listBeers().get(1));  
  
        mockMvc.perform(post("/api/v1/beer")  
                .accept(MediaType.APPLICATION_JSON)  
                        .contentType(MediaType.APPLICATION_JSON)  
                        .content(objectMapper.writeValueAsString(beer)))  
                .andExpect(status().isCreated())  
                .andExpect(header().exists("Location"));  
    }  
}

Key Takeaways

  • Jackson’s ObjectMapper simplifies woNrking with JSON.
  • Spring Boot provides a pre-configured ObjectMapper that aligns with the application’s settings.
  • Always use the Spring-provided ObjectMapper in tests to ensure consistency and avoid conflicts.
  • In the next step, we’ll use the generated JSON to create a new beer using our controller.

This approach ensures we follow best practices while keeping our code clean and maintainable.

Error handling

How to throw exception by mockito

@Test  
void getBeerByIdNotFound() throws Exception {  
given(beerService.getBeerById(any(UUID.class))).willThrow(NotFoundException.class);
    mockMvc.perform(get(BeerController.BEER_PATH_ID, UUID.randomUUID()))  
            .andExpect(status().isNotFound());  
}

@ControllerAdvice

Controller advice in Spring is a mechanism for handling cross-cutting concerns in web applications, such as exception handling, binding validation, or model attributes setup. It uses the @ControllerAdvice annotation to centralize and decouple these concerns from individual controllers.

  • Customize the response
  • Control over the Response Body

Key Features of @ControllerAdvice

  1. Centralized Exception Handling:

    • It allows you to handle exceptions for multiple controllers in one place.
    • Paired with @ExceptionHandler, it makes it easy to define responses for specific exceptions. Example:
    @ControllerAdvice
    public class GlobalExceptionHandler {
     
        @ExceptionHandler(ResourceNotFoundException.class)
        public ResponseEntity<String> handleNotFound(ResourceNotFoundException ex) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage());
        }
    }
  2. Global Model Attributes:

    • You can define attributes that should be available to all controllers.
    • Use the @ModelAttribute annotation for this purpose.

    Example:

    @ControllerAdvice
    public class GlobalModelAttributes {
     
        @ModelAttribute("appName")
        public String appName() {
            return "My Application";
        }
    }

    Any controller can now access appName in its views.

  3. Request and Response Body Customization:

    • You can modify request or response bodies globally using @InitBinder.

    Example:

    @ControllerAdvice
    public class GlobalBindingInitializer {
     
        @InitBinder
        public void customizeBinding(WebDataBinder binder) {
            binder.registerCustomEditor(LocalDate.class, new CustomDateEditor());
        }
    }

When to Use @ControllerAdvice

  • Exception Handling: To centralize error handling for cleaner controllers and consistent error responses.
  • Common Data Sharing: To share attributes like application metadata or user details across all views.
  • Input Binding Customization: To apply uniform data binding rules (e.g., formatting dates) across controllers.

Advantages of @ControllerAdvice

  1. Reusability: One place to handle concerns for multiple controllers.
  2. Readability: Keeps controllers focused on request handling logic.
  3. Consistency: Provides a uniform way to handle exceptions and set up model attributes.
  4. Scalability: Easy to update or extend as the application grows.

In summary, @ControllerAdvice is a powerful tool to manage cross-cutting concerns efficiently, promoting cleaner, more modular, and maintainable code in Spring applications.

Response status annotation

@ResponseStatus(value = HttpStatus.NOT_FOUND, reason = "Value Not Found")  
public class NotFoundException extends RuntimeException {  
    public NotFoundException() {  
    }  
  
    public NotFoundException(String message) {  
        super(message);  
    }  
  
    public NotFoundException(String message, Throwable cause) {  
        super(message, cause);  
    }  
  
    public NotFoundException(Throwable cause) {  
        super(cause);  
    }  
  
    public NotFoundException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {  
        super(message, cause, enableSuppression, writableStackTrace);  
    }  
}