Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MockMvc support for testing errors #5574

Closed
rwinch opened this issue Apr 5, 2016 · 21 comments
Closed

MockMvc support for testing errors #5574

rwinch opened this issue Apr 5, 2016 · 21 comments
Labels
status: declined A suggestion or change that we don't feel we should currently apply

Comments

@rwinch
Copy link
Member

rwinch commented Apr 5, 2016

Spring Boot currently registers an endpoint with the servlet container to process errors. This means that MockMvc cannot be used to assert the errors. For example, the following will fail:

mockMvc.perform(get("/missing"))
    .andExpect(status().isNotFound())
    .andExpect(content().string(containsString("Whitelabel Error Page")));

with:

java.lang.AssertionError: Response content
Expected: a string containing "Whitelabel Error Page"
     but: was ""
    ..

despite the fact that the message is displayed when the application is actually running. You can view this problem in https://github.com/rwinch/boot-mockmvc-error

It would be nice if Spring Boot could provide hooks to enable testing of the error pages too.

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged label Apr 5, 2016
@wilkinsona
Copy link
Member

I'm not sure that we should be going out of our way to encourage people to test for errors in this way. It's testing that Boot's error page support is working, rather than testing a user's own code. IMO, in most cases it'll be sufficient to verify that the request has been forwarded to /error.

That said, I have encountered this problem before when trying to provide documentation for the JSON error response. The difficulty is that MockMvc doesn't fully support forwarding requests which is what the error page support uses. I worked around the problem by making a MockMvc call to /error with the appropriate attributes:

@Test
public void errorExample() throws Exception {
    this.mockMvc
            .perform(get("/error")
                    .requestAttr(RequestDispatcher.ERROR_STATUS_CODE, 400)
                    .requestAttr(RequestDispatcher.ERROR_REQUEST_URI,
                            "/notes")
                    .requestAttr(RequestDispatcher.ERROR_MESSAGE,
                            "The tag 'http://localhost:8080/tags/123' does not exist"))
            .andDo(print()).andExpect(status().isBadRequest())
            .andExpect(jsonPath("error", is("Bad Request")))
            .andExpect(jsonPath("timestamp", is(notNullValue())))
            .andExpect(jsonPath("status", is(400)))
            .andExpect(jsonPath("path", is(notNullValue())))
            .andDo(document("error-example",
                    responseFields(
                            fieldWithPath("error").description("The HTTP error that occurred, e.g. `Bad Request`"),
                            fieldWithPath("message").description("A description of the cause of the error"),
                            fieldWithPath("path").description("The path to which the request was made"),
                            fieldWithPath("status").description("The HTTP status code, e.g. `400`"),
                            fieldWithPath("timestamp").description("The time, in milliseconds, at which the error occurred"))));
    }

@rwinch
Copy link
Member Author

rwinch commented Apr 5, 2016

@wilkinsona Thanks for the response.

It's testing that Boot's error page support is working, rather than testing a user's own code.

The goal is to ensure that user's have everything configured correctly. This becomes more important when the user configures custom error handling.

The difficulty is that MockMvc doesn't fully support forwarding requests which is what the error page support uses.

This is a good point. However, the MockMvc and HtmlUnit support does handle forwards. Granted, it does not handle error codes but perhaps this is something that should change.

Ultimately, my goal is to easily be able to test custom error handling in Boot. I want to be able to ensure that if my application has an error it is properly handled (not just if I directly request a URL for error handling).

@wilkinsona wilkinsona added type: enhancement A general enhancement and removed status: waiting-for-triage An issue we've not yet triaged labels Jul 2, 2016
@pavelfomin
Copy link

pavelfomin commented Dec 9, 2016

I have a similar issue with the verification of the JSR-303 validation errors from our rest controllers. When 400 http status is returned, the response content is empty when using mockmvc (returns proper json error content during actual app run). W/out the response it's harder to tell for sure which field caused the validation error. TestRestTemplate approach mentioned in 7321 is not ideal as it could potentially commit and cause test data pollution (i.e. @Transactional on test doesn't affect the standalone container started). I ended up creating custom @ExceptionHandler methods to handle errors. See this simplified example of exception handlers for more details and a test verifying a particular validation error as well.

@dsyer dsyer added the for: team-attention An issue we'd like other members of the team to review label Dec 15, 2016
@snicoll
Copy link
Member

snicoll commented Dec 15, 2016

I got caught by that as well when writing a test for #7582

@philwebb philwebb removed the for: team-attention An issue we'd like other members of the team to review label Jan 4, 2017
@shakuzen
Copy link
Member

shakuzen commented Jan 5, 2017

As the for-team-discussion label was removed, could the results of the discussion be shared for this issue?

@philwebb
Copy link
Member

philwebb commented Jan 5, 2017

We discussed it briefly in the context of the 1.5 release but we don't think we can find a suitable solution in the time-frame. We'll need to look again once 1.5 is released.

@kiru
Copy link

kiru commented Mar 29, 2017

Why can I not disable the ErrorPageFilter like described here: http://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#howto-disable-registration-of-a-servlet-or-filter
?

@wilkinsona
Copy link
Member

@kiru It's impossible to say without some more context and this isn't a great place to ask questions. If you'd like some help, please use Gitter or Stack Overflow.

@tunix
Copy link

tunix commented Nov 23, 2017

Hi, I'm trying to document a service using REST Docs and I too, have this problem. 😄 In some conditional case, the value returned from the API expires and I'd like to show the error case in the documentation. Currently I'm showing what the error response looks like using the above code sample @wilkinsona shared.

@wilkinsona
Copy link
Member

Ultimately, my goal is to easily be able to test custom error handling in Boot. I want to be able to ensure that if my application has an error it is properly handled (not just if I directly request a URL for error handling).

To be sure that any error handling is working fully, it's necessary to involve the servlet container in that testing as it's responsible for error page registration etc. Even if MockMvc itself or a Boot enhancement to MockMvc allowed forwarding to an error page, you'd be testing the testing infrastructure not the real-world scenario that you're actually interested in.

Our recommendation for tests that want to be sure that error handling is working correctly, is to use an embedded container and test with WebTestClient, RestAssured, or TestRestTemplate.

@wilkinsona wilkinsona added status: declined A suggestion or change that we don't feel we should currently apply and removed type: enhancement A general enhancement labels Sep 7, 2018
@irlyanov
Copy link

irlyanov commented Oct 3, 2018

As it properly said above, when RestDocs came, error handling became important and mockMvc won't used only for the testing, but for the test-driven documentation. And it's very sad if one cannot document some important error case.

I think the approach proposed by @wilkinsona is good, we can just make manual redirect to error page.
Not sure it will work for everyone but works for me.

this.mockMvc.perform(
        post("/v1/item").content(createData())
                        .contentType(MediaType.APPLICATION_JSON_VALUE))
            .andDo(result -> {
                if (result.getResolvedException() != null) {
                    byte[] response = mockMvc.perform(get("/error").requestAttr(RequestDispatcher.ERROR_STATUS_CODE, result.getResponse()
                                                                                                                           .getStatus())
                                                                   .requestAttr(RequestDispatcher.ERROR_REQUEST_URI, result.getRequest()
                                                                                                                           .getRequestURI())
                                                                   .requestAttr(RequestDispatcher.ERROR_EXCEPTION, result.getResolvedException())
                                                                   .requestAttr(RequestDispatcher.ERROR_MESSAGE, String.valueOf(result.getResponse()
                                                                                                                                      .getErrorMessage())))
                                             .andReturn()
                                             .getResponse()
                                             .getContentAsByteArray();
                    result.getResponse()
                          .getOutputStream()
                          .write(response);
                }
            })
            .andExpect(status().isForbidden())
            .andDo(document("post-unautorized-example",
                            responseHeaders(headerWithName(HEADER_WWW_AUTHENTICATE).description("Unauthorized header.")),
                            responseFields(ERROR_PLAYLOAD)));`

@jmisur
Copy link

jmisur commented Jun 27, 2019

For anyone who's still struggling with this, I found super easy solution.
Just add this component to your test classes. It will be included in your test context for MockMvc test and proper error translation will be performed.

import org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController
import org.springframework.boot.web.servlet.error.ErrorController
import org.springframework.http.ResponseEntity
import org.springframework.validation.BindException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import javax.servlet.http.HttpServletRequest

/**
 * This advice is necessary because MockMvc is not a real servlet environment, therefore it does not redirect error
 * responses to [ErrorController], which produces validation response. So we need to fake it in tests.
 * It's not ideal, but at least we can use classic MockMvc tests for testing error response + document it.
 */
@ControllerAdvice
internal class MockMvcValidationConfiguration(private val errorController: BasicErrorController) {

    // add any exceptions/validations/binding problems
    @ExceptionHandler(MethodArgumentNotValidException::class, BindException::class)
    fun defaultErrorHandler(request: HttpServletRequest, ex: Exception): ResponseEntity<*> {
        request.setAttribute("javax.servlet.error.request_uri", request.pathInfo)
        request.setAttribute("javax.servlet.error.status_code", 400)
        return errorController.error(request)
    }
}

@arey
Copy link

arey commented Jul 9, 2019

Thanks for the tips @jmisur. I've used your solution in my CrashControllerTest

@harsathmeetha
Copy link

harsathmeetha commented Apr 9, 2020

    private MvcResult uploadWithInvalidServiceName(String originalFilename, byte[] testFileContent, String serviceName)
            throws Exception {
        var postFile = multipart(API_V1 + "/file/" + serviceName);
        postFile.with(request -> {
            request.setMethod("POST");
            request.addHeader(ACCEPT_LANGUAGE, Locale.US.toLanguageTag());
            request.addHeader(AUTHORIZATION, "Bearer " + ownerToken);
            request.addParameter(PASSWORD_KEY, passwordKey);
            request.setContentType(MediaType.MULTIPART_FORM_DATA_VALUE);
            return request;
        });
        return (MvcResult) mockMvc.perform(postFile.file(new MockMultipartFile("file",
                originalFilename, MediaType.TEXT_PLAIN_VALUE, testFileContent)))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.[0].code", equalTo("connection.invalid")))
                .andExpect(jsonPath("$[0].defaultMessage",
                        equalTo("The given ServiceName is in Wrong Format")))
                .andDo(print());
    }
    @Test
    @DisplayName("Upload a file and Download")
    void uploadFileToAndDownload() throws Exception {
        final var originalFilename = "test_file_6.txt";
        final var testFileContent = "Test Content.".getBytes();
        final MvcResult objectResult = uploadWithInvalidServiceName(originalFilename, testFileContent, "error_drive");
        final var objectMap = new ObjectMapper().readValue(objectResult.getResponse().getContentAsString(), Map.class);

        mockMvc.perform(post(API_V1 + "/file/error_drive/" + objectMap.get("id"))
                .header(AUTHORIZATION, "Bearer " + ownerToken)
                .param(PASSWORD_KEY, passwordKey))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.[0].code", equalTo("connection.invalid")))
                .andExpect(jsonPath("$[0].defaultMessage",
                        equalTo("The given ServiceName is in Wrong Format")))
                .andDo(print());

        deleteFile((Integer) objectMap.get("id"));
    }

@firatkucuk
Copy link

firatkucuk commented Oct 23, 2020

I have modified @jmisur solution, it works for all kind of exception, I am not satisfied with json conversion but if I find any better way I can update it.

@TestConfiguration
public class MockMvcRestExceptionConfiguration implements WebMvcConfigurer {

  private final BasicErrorController errorController;

  public MockMvcRestExceptionConfiguration(final BasicErrorController basicErrorController) {
    this.errorController = basicErrorController;
  }

  @Override
  public void addInterceptors(final InterceptorRegistry registry) {
    registry.addInterceptor(
        new HandlerInterceptor() {
          @Override
          public void afterCompletion(
              final HttpServletRequest request,
              final HttpServletResponse response,
              final Object handler,
              final Exception ex)
              throws Exception {

            final int status = response.getStatus();

            if (status >= 400) {
              request.setAttribute(RequestDispatcher.ERROR_STATUS_CODE, status);
              new ObjectMapper()
                  .writeValue(
                      response.getOutputStream(),
                      MockMvcRestExceptionConfiguration.this
                          .errorController
                          .error(request)
                          .getBody());
            }
          }
        });
  }
}

@rubensa
Copy link

rubensa commented Apr 30, 2021

As it properly said above, when RestDocs came, error handling became important and mockMvc won't used only for the testing, but for the test-driven documentation. And it's very sad if one cannot document some important error case.

I think the approach proposed by @wilkinsona is good, we can just make manual redirect to error page.
Not sure it will work for everyone but works for me.

this.mockMvc.perform(
        post("/v1/item").content(createData())
                        .contentType(MediaType.APPLICATION_JSON_VALUE))
            .andDo(result -> {
                if (result.getResolvedException() != null) {
                    byte[] response = mockMvc.perform(get("/error").requestAttr(RequestDispatcher.ERROR_STATUS_CODE, result.getResponse()
                                                                                                                           .getStatus())
                                                                   .requestAttr(RequestDispatcher.ERROR_REQUEST_URI, result.getRequest()
                                                                                                                           .getRequestURI())
                                                                   .requestAttr(RequestDispatcher.ERROR_EXCEPTION, result.getResolvedException())
                                                                   .requestAttr(RequestDispatcher.ERROR_MESSAGE, String.valueOf(result.getResponse()
                                                                                                                                      .getErrorMessage())))
                                             .andReturn()
                                             .getResponse()
                                             .getContentAsByteArray();
                    result.getResponse()
                          .getOutputStream()
                          .write(response);
                }
            })
            .andExpect(status().isForbidden())
            .andDo(document("post-unautorized-example",
                            responseHeaders(headerWithName(HEADER_WWW_AUTHENTICATE).description("Unauthorized header.")),
                            responseFields(ERROR_PLAYLOAD)));`

This worked for me but changing from result.getResponse().getErrorMessage() to result.getResolvedException().getMessage() (and don't forget to set server.error.include-message=always as from Spring 2.3.0 is set to never)

@rubensa
Copy link

rubensa commented Apr 30, 2021

This is my solution based on @jmisur using a ControllerAdvise

package org.eu.rubensa.springboot.error.common;

import java.util.Map;

import org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import org.springframework.web.util.WebUtils;

/**
 * This advice is necessary because MockMvc is not a real servlet environment,
 * therefore it does not redirect error responses to {@link ErrorController},
 * which produces validation response. So we need to fake it in tests. It's not
 * ideal, but at least we can use classic MockMvc tests for testing error
 * response + document it.
 */
@ControllerAdvice
public class MockMvcRestExceptionControllerAdvise extends ResponseEntityExceptionHandler {
  BasicErrorController errorController;

  public MockMvcRestExceptionControllerAdvise(BasicErrorController errorController) {
    this.errorController = errorController;
  }

  /**
   * Handle any generic {@link Exception} not handled by
   * {@link ResponseEntityExceptionHandler}
   * 
   * @param ex
   * @param request
   * @return
   * @throws Exception
   */
  @ExceptionHandler({ Exception.class })
  public final ResponseEntity<Object> handleGenericException(Exception ex, WebRequest request) throws Exception {
    HttpHeaders headers = new HttpHeaders();
    HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
    ResponseStatus responseStatus = AnnotationUtils.findAnnotation(ex.getClass(), ResponseStatus.class);
    if (responseStatus != null) {
      status = responseStatus.value();
    }
    return handleExceptionInternal(ex, null, headers, status, request);
  }

  /**
   * Overrides
   * {@link ResponseEntityExceptionHandler#handleExceptionInternal(Exception,
   * Object, HttpHeaders, HttpStatus, WebRequest))} to expose error attributes to
   * {@link BasicErrorController} and uses it to handle the response.
   * 
   * @param ex
   * @param body
   * @param headers
   * @param status
   * @param request
   * @return ResponseEntity<Object>
   */
  @Override
  protected ResponseEntity<Object> handleExceptionInternal(Exception ex, Object body, HttpHeaders headers,
      HttpStatus status, WebRequest request) {
    request.setAttribute(WebUtils.ERROR_STATUS_CODE_ATTRIBUTE, status.value(), WebRequest.SCOPE_REQUEST);
    request.setAttribute(WebUtils.ERROR_REQUEST_URI_ATTRIBUTE,
        ((ServletWebRequest) request).getRequest().getRequestURI().toString(), WebRequest.SCOPE_REQUEST);
    request.setAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE, ex, WebRequest.SCOPE_REQUEST);
    request.setAttribute(WebUtils.ERROR_MESSAGE_ATTRIBUTE, ex.getMessage(), WebRequest.SCOPE_REQUEST);

    ResponseEntity<Map<String, Object>> errorControllerResponeEntity = errorController
        .error(((ServletWebRequest) request).getRequest());
    return new ResponseEntity<>(errorControllerResponeEntity.getBody(), errorControllerResponeEntity.getHeaders(),
        errorControllerResponeEntity.getStatusCode());
  }

}

@rubensa
Copy link

rubensa commented Apr 30, 2021

And this is my solution based on @firatkucuk using a custom @TestConfiguration with a HandlerInterceptor

  @TestConfiguration
  public class MockMvcRestExceptionConfiguration implements WebMvcConfigurer {

    private final BasicErrorController errorController;

    public MockMvcRestExceptionConfiguration(final BasicErrorController basicErrorController) {
      this.errorController = basicErrorController;
    }

    @Override
    public void addInterceptors(final InterceptorRegistry registry) {
      registry.addInterceptor(new HandlerInterceptor() {
        @Override
        public void afterCompletion(final HttpServletRequest request, final HttpServletResponse response,
            final Object handler, final Exception ex) throws Exception {

          final int status = response.getStatus();

          if (status >= 400) {
            request.setAttribute(RequestDispatcher.ERROR_STATUS_CODE, status);
            request.setAttribute(WebUtils.ERROR_STATUS_CODE_ATTRIBUTE, status);
            request.setAttribute(WebUtils.ERROR_REQUEST_URI_ATTRIBUTE, request.getRequestURI().toString());
            // The original exception is already saved as an attribute request
            Exception exception = (Exception) request.getAttribute(DispatcherServlet.EXCEPTION_ATTRIBUTE);
            if (exception != null) {
              request.setAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE, exception);
              request.setAttribute(WebUtils.ERROR_MESSAGE_ATTRIBUTE, exception.getMessage());
            }
            new ObjectMapper().writeValue(response.getOutputStream(),
                MockMvcRestExceptionConfiguration.this.errorController.error(request).getBody());
          }
        }
      });
    }
  }

@rubensa
Copy link

rubensa commented May 3, 2021

The HandlerInterceptor solution only works if the exception has already been handled by some one else. For example, if you have an Exception that is annotated with @ResponseStatus then it is handled by the preresgistered ResponseStatusExceptionResolver. But if, for example, a ConstraintViolationException is thrown, then the Exception bubbles up and is thrown in your test class as there is no default handler registered to handle it.

The ControllerAdvice mimics the standard Spring Boot solution much better as It handles any Exception.

@chrylis
Copy link
Contributor

chrylis commented Sep 7, 2021

Unfortunately, the workarounds suggested for this bug appear to be limited to ResponseEntity responses; I tried to mimic the handler for HTML responses but couldn't get the infrastructure to cooperate.

@ghost
Copy link

ghost commented Aug 17, 2022

	/**
	 * mvn test -Dtest=AgreementFormControllerTest#saveNewAgreementNoJob
	 * @throws Exception
	 * 
	 * Not supplying a job should result in a field error
	 */
	@Test
	public void saveNewAgreementNoJob() throws Exception {


		  
		Awarded awarded = new Awarded();
		when(awardMgr.getAwarded(eq(7281))).thenReturn(awarded);

		ResultActions post = this.mockMvc.perform(post("/agreementForm.html/save"));
		
		MvcResult mvcResult = post
				.andDo(print())
				.andExpect(error("org.springframework.validation.BindingResult.agreementFormBacking", "NotNull.agreementFormBacking.job"))
				.andExpect(status().isOk())
				.andReturn();
	}
	

	static ResultMatcher error(String attribute, String validation) {
	    return new ResultMatcher() {

			@Override
			public void match(MvcResult result) throws Exception {
				Map<String, Object> model = result.getModelAndView().getModel();// .get(BINDING_RESULT);

				BeanPropertyBindingResult x = (BeanPropertyBindingResult) model
						.get(attribute);
				
				Boolean constraint = x.getAllErrors().stream().map(error->error.getCodes())
					.flatMap(s->Stream.of(s))
					.anyMatch(s->s.equals(validation));
				
				assertTrue(constraint, validation + " not found ");
			}

	    	
	    };
	}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
status: declined A suggestion or change that we don't feel we should currently apply
Projects
None yet
Development

No branches or pull requests