Search code examples
spring-mvcjunit5mockmvcspring-mvc-test

Spring 5 MVC Test with MockMvc, test-context.xml, and annotation-based WebAppConfig (ie, in Java)


Versions (SpringBoot is not involved):

Spring: 5.2.16
web-app / servlet API: 4.0
JUnit: 5.8

Spring MVC Testing is not working for controller endpoint that returns ResponseEntity<ReturnStatus>, where ReturnStatus is a POJO with appropriate getters/setters. The exception triggered indicates that JSON conversion is not working for ReturnStatus. My research indicates that the annotation-based Java configuration for the WebApplicationContext is not loaded (and therefore the Jackson JSON converter is not recognized). Curiously, in a non-testing deployment in Tomcat, the controller endpoint works fine, presumably because the web.xml in the war-file is parsed by Tomcat.

QUESTION:
How can I adjust the setup for Spring MVC Test for this application so that the annotation-based Java configuration for the WebApplicationContext is properly loaded? Can this, for example, be done explicitly in the endpoint-test logic (ie, the JUnit test)?

Exception:

14:33:57,765  WARN DefaultHandlerExceptionResolver:199 - Resolved [org.springframework.http.converter.HttpMessageNotWritableException: No converter for [class com.acme.myapp.io.ReturnStatus] with preset Content-Type 'null']
14:33:57,765 DEBUG TestDispatcherServlet:1131 - Completed 500 INTERNAL_SERVER_ERROR

The Spring MVC app incorporates the following configurations:

  1. test-context.xml, which houses Spring bean-configuration for access to data store:
  2. web.xml, which declares and maps the DispatcherServlet with relevant setup for WebApplicationContext.
  3. Annotation-based configuration in Java implementation of WebMvcConfigurer.

Relevant excerpt from test-context.xml:

  <context:component-scan base-package="com.acme.myapp"/>
  <jpa:repositories base-package="com.acme.myapp.repos"/>

  <context:property-placeholder location="classpath:/application.properties" />

  <!-- Data persistence configuration -->
  <bean id="transactionManager" class="org.springframework.orm.jpa.JpaTransactionManager">
    <property name="entityManagerFactory" ref="entityManagerFactory" />
  </bean>
  <tx:annotation-driven transaction-manager="transactionManager" />

  <bean id="entityManagerFactory" class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean">
    <property name="dataSource" ref="dataSource" />
    <property name="jpaVendorAdapter">
      <bean class="org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter">
        <property name="showSql" value="${db.showSql}" />
        <property name="databasePlatform" value="${db.dialect}" />
        <property name="generateDdl" value="${db.generateDdl}" />
      </bean>
    </property>
    <property name="packagesToScan">
      <list>
        <value>com.acme.myapp.dao</value>
      </list>
    </property>
  </bean>

  <bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close">
    <property name="driverClassName" value="${db.driver}" />
    <property name="url" value="${db.url}" />
    <property name="username" value="${db.user}" />
    <property name="password" value="${db.pass}" />
    <property name="initialSize" value="2" />
    <property name="maxActive" value="5" />
    <property name="accessToUnderlyingConnectionAllowed" value="true"/>
  </bean>

  <!-- Set JVM system properties here. We do this principally for hibernate logging. -->
  <bean class="org.springframework.beans.factory.config.MethodInvokingFactoryBean">
    <property name="targetObject">
      <bean class="org.springframework.beans.factory.config.MethodInvokingFactoryBean">
        <property name="targetClass" value="java.lang.System" />
        <property name="targetMethod" value="getProperties" />
      </bean>
    </property>
    <property name="targetMethod" value="putAll" />
    <property name="arguments">
      <util:properties>
        <prop key="org.jboss.logging.provider">slf4j</prop>
      </util:properties>
    </property>
  </bean>

Relevant excerpt from web.xml (where application-context.xml is our production version of test-context.xml):

  <context-param>
    <param-name>contextConfigLocation</param-name>
    <param-value>classpath:application-context.xml</param-value>
  </context-param>

  <listener>
    <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
  </listener>

  <servlet>
    <servlet-name>central-dispatcher</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <load-on-startup>1</load-on-startup>
    <init-param>
      <param-name>contextClass</param-name>
      <param-value>org.springframework.web.context.support.AnnotationConfigWebApplicationContext</param-value>
    </init-param>
    <init-param>
      <param-name>contextConfigLocation</param-name>
      <param-value>com.acme.myapp.MyAppWebAppConfig</param-value>
    </init-param>
  </servlet>
  <servlet-mapping>
    <servlet-name>central-dispatcher</servlet-name>
    <url-pattern>/api/*</url-pattern>
  </servlet-mapping>

Excerpt from Java implementation of WebMvcConfigurer (ie, where we incorporate Jackson JSON converter):

@EnableWebMvc
@Configuration
@ComponentScan(basePackages = { "com.acme.myapp.controllers" })
public class MyAppWebAppConfig implements WebMvcConfigurer
{
  private static final Logger logger = LoggerFactory.getLogger(MyAppWebAppConfig.class);

  @Override
  public void extendMessageConverters(List<HttpMessageConverter<?>> converters)
  {
    logger.debug("extendMessageConverters ...");
    converters.add(new StringHttpMessageConverter());
    converters.add(new MappingJackson2HttpMessageConverter(new MyAppObjectMapper()));
  }
}

The controller endpoint looks like this (where the root is at /patients):

  @RequestMapping(value = "/{id}", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
  public ResponseEntity<ReturnStatus> readPatient(
    @PathVariable("id") long id
  )
  {
    ReturnStatus returnStatus = new ReturnStatus();
    returnStatus.setVersionId("1.0");
    ...
    return new ResponseEntity<ReturnStatus>(returnStatus, httpStatus);
  }

Using JUnit5 and MockMvc, the endpoint-test looks like this:

@SpringJUnitWebConfig(locations={"classpath:test-context.xml"})
public class PatientControllerTest
{
  private MockMvc mockMvc;

  @BeforeEach
  public void setup(WebApplicationContext wac) {
    this.mockMvc = webAppContextSetup(wac).build();
  }

  @Test
  @DisplayName("Read Patient from /patients API.")
  public void testReadPatient()
  {
    try {
      mockMvc.perform(get("/patients/1").accept(MediaType.APPLICATION_JSON_VALUE))
        .andDo(print())
        .andExpect(status().isOk());
    } catch (Exception ex) {
      ex.printStackTrace();
    }
  }
}

Thanks!


Solution

  • Here are some options, possibly not exhaustive:

    • Per earlier comment, we can simply use <mvc:annotation-driven> directive in test-context.xml. For example:
      <bean id="myappObjectMapper" class="com.acme.myapp.MyAppObjectMapper"/>
      <mvc:annotation-driven>
        <mvc:message-converters register-defaults="true">
          <bean class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter">
            <constructor-arg ref="myappObjectMapper"/>
          </bean>
        </mvc:message-converters>
      </mvc:annotation-driven>
    

    Effectively, this directive obviates the need for loading MyAppWebAppConfig, as <mvc:annotation-driven> in fact is the XML-equivalent of the annotation @EnableWebMvc in Java.

    • Implement WebApplicationInitializer so that effectively does in Java what we configure into web.xml. For example:
    public class MyAppWebApplicationInitializer implements WebApplicationInitializer
    {
      @Override
      public void onStartup(ServletContext container)
      {
        XmlWebApplicationContext appCtx = new XmlWebApplicationContext();
        appCtx.setConfigLocation("classpath:application-context.xml");
        container.addListener(new ContextLoaderListener(appCtx));
    
        AnnotationConfigWebApplicationContext dispatcherCtx = new AnnotationConfigWebApplicationContext();
        dispatcherCtx.register(MyAppWebAppConfig.class);
    
        ServletRegistration.Dynamic registration = container.addServlet("central-dispatcher", new DispatcherServlet(dispatcherCtx));
        registration.setLoadOnStartup(1);
        registration.addMapping("/api/*");
      }  
    }
    

    For this solution, we expunge web.xml from the project; possibly we should parameterize the reference to application-context.xml as well.

    Note that when I run JUnit5 tests, it appears that Spring does not instance MyAppWebApplicationInitializer, and that instead, the Spring context loaded for JUnit5 is the one referenced by the @SpringJUnitWebConfig annotation. I therefore recommend co-locating test-related configuration with test-context.xml, and reserving WebApplicationInitializer for production.

    I'm sure there are other options, but I've only explored these two approaches.