LoginSignup
134

More than 5 years have passed since last update.

Spring-Boot の @RestController の単体テストを記述する

Last updated at Posted at 2015-03-14

最新(1.4.x)の Spring Boot ではこの方法を使う必要は無くなっています。 @WebMvcTest@MockBean を使用するのが良いかと思います。

最近、Spring-Boot を触っています。
Spring-Boot 自体の使い方は、Google 先生に聞けばだいたい教えてくれるので、@RestController なコントローラの単体テストについて書いておきます。
使用した Spring-Boot のバージョンは 1.2.1.RELEASE になります。

「単体テスト」といえば対象クラスの動作に対するテストの事かと思いますが、コントローラの場合はフレームワークの設定と動作もある程度加味しないとテストから漏れてしまう箇所が多くなる or 結合テスト(手動/自動問わず)で細かな部分までテストすることになってしまうかと思います。

Spring-Boot には spring-boot-starter-test というテスト用モジュールがあり、これの中にあるコントローラに対するテスト用のヘルパクラスを利用することで、上述したフレームワークの設定と動作も含めたコントローラのテストを容易に記述できます。

pom.xml

pom.xml の依存モジュールに、spring-boot-starter-test を追加します。また、レスポンスボディを JSON として検証する場合は org.skyscreamer:jsonassert も必要なようです。

pom.xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.skyscreamer</groupId>
    <artifactId>jsonassert</artifactId>
    <version>1.2.3</version>
    <scope>test</scope>
</dependency>

scopetest にして、リリースパッケージに含まれないようにします。

単順な RestController のテストの場合

https://spring.io/guides/gs/spring-boot/ で説明されている内容とほぼ変わらないです。

FooControllerTest.java
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.Before;
import org.junit.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

public class FooControllerTest {

    private MockMvc mvc;

    @Before
    public void before() throws Exception {
        mvc = MockMvcBuilders.standaloneSetup(new FooController()).build();
    }

    @Test
    public void testGet__Ok() throws Exception {
        int id = 123;
        mvc.perform(get("/fooes/{id}", id))
            .andExpect(status().isOk())
            .andExpect(content().string("foo-" + id));
    }
}

MockMvcBuilders.standaloneSetup(...) を使うことで、Spring MVC のモックを使用したコントローラのテストをすることができます。

上記の例では、レスポンスは文字列になっていますが、実際には JSON オブジェクトの場合が多いかと思います。レスポンスが JSON の場合は content().json(String jsonContent) を使うことで検証ができます。ただ、この検証メソッドですが期待値に JSON 文字列を要求するので、実際の場合はコントローラで返したはずのエンティティから JSON 文字列への変換が必要です。
Spring Boot ではデフォルトで Jackson を使用した JSON 文字列への変換がサポートされているので、テストケースでも Spring Boot で設定されている Jackson を使用したいと思います。

FooControllerTest.java
import org.junit.runner.RunWith;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import com.fasterxml.jackson.databind.ObjectMapper;
// 省略

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = JacksonAutoConfiguration.class)
public class FooControllerTest {

    @Autowired
    private ObjectMapper mapper;

    // 省略

    @Test
    public void testGet__Ok() throws Exception {
        int id = 123;
        mvc.perform(get("/fooes/{id}", id))
            .andExpect(status().isOk())
            .andExpect(content().json(mapper.writeValueAsString(new Foo(id, "foo-" + id)));
    }
}

通常は @EnableAutoConfiguration の指定によって自動ロードする Jackson の設定クラスですが、@Configuration なクラスであることに変わりはないので、@SpringApplicationConfiguration を使用して明示的にロードしています。
TestRunner として SpringJUnit4ClassRunner を使用することで、@SpringApplicationConfiguration によるテスト用 Context を指定することができます。

Service をモックする

実際のコントローラの実装では、Service やその他いろいろなコンポーネントを利用しているかと思います。それらのコンポーネントをモックに入れ替えてテストします。

FooControllerTest.java
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.Rule;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRule;
// 省略

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = JacksonAutoConfiguration.class)
public class FooControllerTest {

    // 省略

    @Rule
    public MockitoJUnitRule mockitoJUnitRule = new MockitoJUnitRule(this);
    @InjectMocks
    private FooController target;
    @Mock
    private BarService barService;

    @Before
    public void before() throws Exception {
        mvc = MockMvcBuilders.standaloneSetup(target).build();
    }

    @Test
    public void testGet__Ok() throws Exception {
        int id = 123;
        Bar bar = new Bar(id, "bar-" + id);
        when(barService.getBar(id)).thenReturn(bar);

        mvc.perform(get("/fooes/{id}", id))
            .andExpect(status().isOk())
            .andExpect(content().json(mapper.writeValueAsString(bar)));

        verify(barService).getBar(id);
    }
}

spring-boot-starter-test ではモックライブラリとして Mockito が使えるようになっているでの、それを使用します。

@RunWith にはすでに SpringJUnit4ClassRunner.class を指定しているので、MockitoJUnitRunner.class は指定できません。かわりに @Rule を使用して MockitoJUnitRule 1 を設定しています。

@ControllerAdvice による例外ハンドラを有効にする

最近の Spring MVC では、複数のコントローラで共通になる @ExceptionHandler@InitBinder のメソッドを、@ControllerAdvice をつけたクラスで定義できます。
なのですが、MockMvcBuilders.standaloneSetup で対応されていないため、そのままでは単体テスト時に @ControllerAdvice による設定が有効になりません。

ExceptionHandlerExceptionResolver のインスタンスを MockMvcBuilder に設定することで、@ControllerAdvice による例外ハンドラがテスト時も有効になります。
WebMvcConfigurationSupport を使用することで HandlerExceptionResolver を含むいくつかのコンポーネントを利用出来るようになります。

@ControllerAdvice なコンポーネントと、WebMvcConfigurationSupport を有効にするために、テスト用の @Configuration クラスを作成します。

TestContext.java
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;

@Configuration
@ComponentScan(
        basePackageClasses = FooController.class,
        useDefaultFilters = false,
        includeFilters = @ComponentScan.Filter(ControllerAdvice.class))
public class TestContext extends WebMvcConfigurationSupport {
}

@ControllerAdvice のコンポーネントだけが有効になるよう、useDefaultFiltersincludeFilters を指定しています。

上記の TestContext.class をテストクラスに設定し、また WebMvcConfigurationSupportServletContext を必要とするため、@WebAppConfiguration も指定します。

FooControllerTest.java
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.web.servlet.HandlerExceptionResolver;
// 省略

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = {JacksonAutoConfiguration.class, TestContext.class})
@WebAppConfiguration
public class FooControllerTest {

    @Autowired
    private HandlerExceptionResolver handlerExceptionResolver;

    // 省略

    @Before
    public void before() throws Exception {
        mvc = MockMvcBuilders.standaloneSetup(target)
            .setHandlerExceptionResolvers(handlerExceptionResolver)
            .build();
    }

    // 省略

    @Test
    public void testGet__NotFound() throws Exception {
        String message = "__message__";
        when(barService.getBar(anyInt())).thenThrow(new EntityNotFoundException(message));

        int id = 123;
        mvc.perform(get("/fooes/{id}", id))
            .andExpect(status().isNotFound())
            .andExpect(content().json(mapper.writeValueAsString(new ErrorResponse("EntityNotFoundException", message))));

        verify(barService).getBar(id);
    }
}

@ControllerAdvice による WebDataBinder 設定を有効にする

@ExceptionHandler の場合は StandaloneMockMvcBuilder にそのための設定メソッドがあったのですが、@InitBinder のための設定メソッドは残念ながら無いようです。
そのため StandaloneMockMvcBuilder を継承して、やや無理やりな方法ですが RequestMappingHandlerAdapter@ControllerAdvice なコンポーネントが設定されるようにします。

FooControllerTest.java
import org.springframework.beans.factory.BeanFactoryUtils;
import org.springframework.beans.factory.support.StaticListableBeanFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;
// 省略

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = {JacksonAutoConfiguration.class, TestContext.class})
@WebAppConfiguration
public class FooControllerTest {

    @Autowired
    private ApplicationContext applicationContext;

    // 省略

    @Before
    public void before() throws Exception {
        StandaloneMockMvcBuilder builder = new StandaloneMockMvcBuilder(target) {
            @Override
            protected WebApplicationContext initWebAppContext() {
                WebApplicationContext context = super.initWebAppContext();
                StaticListableBeanFactory beanFactory = (StaticListableBeanFactory)context.getAutowireCapableBeanFactory();

                Arrays.stream(BeanFactoryUtils.beanNamesForTypeIncludingAncestors(applicationContext, Object.class))
                    .filter(name -> applicationContext.findAnnotationOnBean(name, ControllerAdvice.class) != null)
                    .forEach(name -> beanFactory.addBean(name, applicationContext.getBean(name)));

                context.getBean(RequestMappingHandlerAdapter.class).afterPropertiesSet();
                return context;
            }
        };

        mvc = builder.setHandlerExceptionResolvers().build();
    }

    // 省略

    @Test
    public void testList__Ok() throws Exception {
        String name = "bar-123";
        Bar bar = new Bar(123, name);
        when(barService.findBarByName(anyString())).thenReturn(Arrays.asList(bar));

        mvc.perform(get("/fooes/").param("name", " " + name + " "))
            .andExpect(status().isOk())
            .andExpect(content().json(mapper.writeValueAsString(new Bar[]{bar})));

        verify(barService).findBarByName(name);
    }
}   

@ControllerAdvice なクラスの @InitBinder メソッドで、StringTrimmerEditor を設定しています。そのため、クエリパラメータの前後の空白は除去されてからコントローラのメソッドに渡されています。

再利用しやすい形にする

上述した設定や修正を、複数のコントローラ用テストで使えるよう、ヘルパクラスを用意します。
また、いくつかの設定については、テスト用コンテキストクラスに移動します。

TestHelper.java
import java.util.Arrays;

import org.springframework.beans.factory.BeanFactoryUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.support.StaticListableBeanFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

public class TestHelper {
    @Autowired
    private ApplicationContext applicationContext;
    @Autowired
    private HandlerExceptionResolver handlerExceptionResolver;
    @Autowired
    private ObjectMapper objectMapper;

    public MockMvc mvc(Object controller) {
        StandaloneMockMvcBuilder builder = new StandaloneMockMvcBuilder(controller) {
            @Override
            protected WebApplicationContext initWebAppContext() {
                WebApplicationContext context = super.initWebAppContext();
                StaticListableBeanFactory beanFactory = (StaticListableBeanFactory)context.getAutowireCapableBeanFactory();

                Arrays.stream(BeanFactoryUtils.beanNamesForTypeIncludingAncestors(applicationContext, Object.class))
                    .filter(name -> applicationContext.findAnnotationOnBean(name, ControllerAdvice.class) != null)
                    .forEach(name -> beanFactory.addBean(name, applicationContext.getBean(name)));

                context.getBean(RequestMappingHandlerAdapter.class).afterPropertiesSet();
                return context;
            }
        };
        return builder.setHandlerExceptionResolvers(handlerExceptionResolver).build();
    }

    public String toJson(Object value) throws JsonProcessingException {
        return objectMapper.writeValueAsString(value);
    }
}
TestContext.java
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;

@Configuration
@ComponentScan(
        basePackageClasses = FooController.class,
        useDefaultFilters = false,
        includeFilters = @ComponentScan.Filter(ControllerAdvice.class))
@Import(JacksonAutoConfiguration.class)
public class TestContext extends WebMvcConfigurationSupport {
    @Bean
    public TestHelper testHelper() {
        return new TestHelper();
    }
}

上記を利用することで、テスト対象に直接関係しない処理をテストケースから追い出すことができます。

FooControllerTest.java
import static org.mockito.Matchers.anyInt;
import static org.mockito.Matchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import java.util.Arrays;

import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRule;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = TestContext.class)
@WebAppConfiguration
public class FooControllerTest {

    @Rule
    public MockitoJUnitRule mockitoJUnitRule = new MockitoJUnitRule(this);
    @InjectMocks
    private FooController target;
    @Mock
    private BarService barService;

    @Autowired
    private TestHelper helper;

    @Test
    public void testGet__Ok() throws Exception {
        int id = 123;
        Bar bar = new Bar(id, "bar-" + id);
        when(barService.getBar(anyInt())).thenReturn(bar);

        helper.mvc(target)
            .perform(get("/fooes/{id}", id))
            .andExpect(status().isOk())
            .andExpect(content().json(helper.toJson(bar)));

        verify(barService).getBar(id);
    }

    @Test
    public void testGet__NotFound() throws Exception {
        String message = "__message__";
        when(barService.getBar(anyInt())).thenThrow(new EntityNotFoundException(message));

        int id = 123;
        helper.mvc(target)
            .perform(get("/fooes/{id}", id))
            .andExpect(status().isNotFound())
            .andExpect(content().json(helper.toJson(new ErrorResponse("EntityNotFoundException", message))));

        verify(barService).getBar(id);
    }

    @Test
    public void testList__Ok() throws Exception {
        String name = "bar-123";
        Bar bar = new Bar(123, name);
        when(barService.findBarByName(anyString())).thenReturn(Arrays.asList(bar));

        helper.mvc(target)
            .perform(get("/fooes/").param("name", " " + name + " "))
            .andExpect(status().isOk())
            .andExpect(content().json(helper.toJson(new Bar[]{bar})));

        verify(barService).findBarByName(name);
    }
}

使用したテストコード

https://github.com/NetPenguin/spring-boot-controller-test-example においてあります。
git clone https://github.com/NetPenguin/spring-boot-controller-test-example した後に mvn test で実際にテストできます。


  1. 最近の Mockito では、MockitoJUnitRule は Deprecated になっており、代わりに MockitoJUnit.rule() を使用するようです。詳しくは MockitoRule の Javadoc を参照してください。 

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
134