Friday, January 18, 2019

Spring REST testing

After too much Internet searching, I was unable to find an easy solution to repeated duplication in my Spring MockMVC tests of REST controller endpoints. For years now, the endpoints we write have typically sent or received JSON. This is what I mean:

mockMvc.perform(post("/some/endpoint")
        .contentType(APPLICATION_JSON_UTF8)
        .accept(APPLICATION_JSON_UTF8)
        .content(someRequestJson))
        .andExpect(status().isCreated())
        .andExpect(header().string(CONTENT_TYPE, APPLICATION_JSON_UTF8_VALUE))
        .andExpect(header().string(LOCATION, "/some/endpoint/name-or-id"))
        .andExpect(content().json(someResponseJson));

All the repeated "APPLICATION_JSON_UTF8"s, in every controller test!

If there is an existing Spring testing solution, I'd love to hear about it. Rather than wait, I wrote up a small extension of @WebMvcTest to default these values.

First, an annotation for Spring to use in setting up a MockMvc (javadoc elided):

@Documented
@Import(JsonMockMvcConfiguration.class)
@Retention(RUNTIME)
@Target(TYPE)
@WebMvcTest
public @interface JsonWebMvcTest {
    @AliasFor(annotation = WebMvcTest.class)
    String[] properties() default {};

    @AliasFor(annotation = WebMvcTest.class)
    Class[] value() default {};

    @AliasFor(annotation = WebMvcTest.class)
    Class[] controllers() default {};

    @AliasFor(annotation = WebMvcTest.class)
    boolean useDefaultFilters() default true;

    @AliasFor(annotation = WebMvcTest.class)
    ComponentScan.Filter[] includeFilters() default {};

    @AliasFor(annotation = WebMvcTest.class)
    ComponentScan.Filter[] excludeFilters() default {};

    @AliasFor(annotation = WebMvcTest.class)
    Class[] excludeAutoConfiguration() default {};
}

Note it is a near exact lookalike of @WebMvcTest (minus the deprecated parameter). The important bits are:

  1. Marking this annotation with @WebMvcTest, a kind of extension through composition.
  2. Adding @Import to bind custom configuration to this annotation.
  3. Tying the same-named annotation parameters to @WebMvcTest, so this annotation is a drop-in replacement of that one.

Next a configuration class, imported by the annotation, to customize MockMvc:

@Configuration
public class JsonMockMvcConfiguration {
    @Bean
    @Primary
    public MockMvc jsonMockMvc(final WebApplicationContext ctx) {
        return webAppContextSetup(ctx)
                .defaultRequest(post("/")
                        .contentType(APPLICATION_JSON_UTF8)
                        .accept(APPLICATION_JSON_UTF8_VALUE))
                .alwaysExpect(header().string(
                        CONTENT_TYPE, APPLICATION_JSON_UTF8_VALUE))
                .build();
    }
}

Some points about this class:

  • @Primary is not necessary for Spring, but helped IntelliJ — perhaps I got lucky with Spring without @Primary, and IntelliJ highlighted a real problem.
  • It took quite a while to get defaultRequest(...) working. I was unable to (re)implement the relevant interfaces, and eventually found that passing any MockHttpServletRequestBuilder sufficed. Spring "merges" (overlays) the actual request builder from the test over this default, replacing POST and "/" with whichever HTTP method and path the test uses (eg, GET "/bob"). Only the header customization is used.

Example usage:

@JsonWebMvcTest(SomeController.class)
class SomeControllerTest {
    @Autowired
    private MockMvc jsonMockMvc;

    @Test
    void shouldCheckSomething()
            throws Exception {
        jsonMockMvc.perform(post("/some/endpoint")
                .content(someRequestJson))
                .andExpect(status().isCreated())
                .andExpect(header()
                        .string(LOCATION, "/some/endpoint/new-name"))
                .andExpect(content().json(someResponseJson));
    }
}

See the Basilisk project for source code and sample usage. (Basilisk is a demonstration project for my team illustrating Spring usage and conventions.)

No comments: