Java
test
spring
Mock

MockMVCを使ってAPIのテストをしたらハマったお話

開発環境

  • Java 1.8.0
  • TERASOLUNA Server Framework for Java (5.4.1.RELEASE)
    • Spring Framework 4.3.14.RELEASE

背景

RestControllerのテストをすることになったのですが、テストの度にアプリケーションをデプロイするのは手間なのでMockを使ってテストしようと思いました。
Springで開発したアプリケーションのテストでMockを使うとなると、いくつか方法やライブラリがありますが、今回はMockMVCを利用します。

MockMVCとは

Spring Testが提供している機能の1つで、サーバ上にアプリケーションをデプロイしなくてもSpring MVCの動作を再現できます。MockMVCには、Webアプリケーション向けのDIコンテナ(WebApplicationContext)を使う方法と、Spring Testが生成するDIコンテナを使う方法がありますが、今回は前者を採用します:grinning:

詳しくはTERASOLUNAのガイドラインに書かれているので、参考にしてください。
単体テストで利用するOSSライブラリの使い方 -MockMvcとは-

テストの実施

テスト対象コード

TERASOLUNAのTodoアプリケーション(REST編)を例に説明します。
テストするAPIはTodoを作成するpostTodosです。
TodoResourceオブジェクトにTitleを設定してJSON形式でpostすると、idと日付が付与され、未完状態のTodoResourceが返却されます。

パッケージやインポート、ゲッター&セッターは省略しています。テスト対象コードは、この記事では重要ではないので、Todo.javaTodoService.javaなどは割愛します。

TodoRestController.java
@RestController
@RequestMapping("todos") 
public class TodoRestController {
    @Inject
    TodoService todoService;
    @Inject
    Mapper beanMapper;

    @RequestMapping(method = RequestMethod.POST)
    @ResponseStatus(HttpStatus.CREATED)
    public TodoResource postTodos(@RequestBody @Validated TodoResource todoResource) {
        Todo createdTodo = todoService.create(beanMapper.map(todoResource, Todo.class));
        TodoResource createdTodoResponse = beanMapper.map(createdTodo, TodoResource.class);
        return createdTodoResponse;
    }
}
TodoResource.java
public class TodoResource implements Serializable {

    private static final long serialVersionUID = 1L;

    private String todoId;

    @NotNull
    @Size(min = 1, max = 30)
    private String todoTitle;

    private boolean finished;

    private Date createdAt;
}
TodoServiceImpl.java
@Service
@Transactional
public class TodoServiceImpl implements TodoService {
    private static final long MAX_UNFINISHED_COUNT = 5;

    @Inject
    TodoRepository todoRepository;
    @Override
    public Todo create(Todo todo) {
        long unfinishedCount = todoRepository.countByFinished(false);
        if (unfinishedCount >= MAX_UNFINISHED_COUNT) {
            ResultMessages messages = ResultMessages.error();
            messages.add("E001", MAX_UNFINISHED_COUNT);
            throw new BusinessException(messages);
        }

        String todoId = UUID.randomUUID().toString();
        Date createdAt = new Date();

        todo.setTodoId(todoId);
        todo.setCreatedAt(createdAt);
        todo.setFinished(false);

        todoRepository.create(todo);

        return todo;
    }
}
web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://java.sun.com/xml/ns/javaee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
    version="3.0">
    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>
    <context-param>
        <param-name>contextConfigLocation</param-name>
        <!-- Root ApplicationContext -->
        <param-value>
            classpath*:META-INF/spring/applicationContext.xml
            classpath*:META-INF/spring/spring-security.xml
        </param-value>
    </context-param>

    <!-- 各種Filterの定義は省略 -->

    <servlet>
        <servlet-name>restApiServlet</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <!-- ApplicationContext for Spring MVC (REST) -->
            <param-value>classpath*:META-INF/spring/spring-mvc-rest.xml</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>

    <servlet-mapping>
        <servlet-name>restApiServlet</servlet-name>
        <url-pattern>/api/v1/*</url-pattern>
    </servlet-mapping>
テストコードの実装

生成したTodoResourceオブジェクトが返ってくることを確認します。

TodoRestControllerTest.java
@RunWith(SpringRunner.class)
@ContextHierarchy({@ContextConfiguration({"classpath:META-INF/spring/applicationContext.xml"}),
    @ContextConfiguration({"classpath:META-INF/spring/spring-mvc-rest.xml"})})
@WebAppConfiguration
public class TodoRestControllerTest {

  @Autowired
  WebApplicationContext webApplicationContext;

  MockMvc mockMvc;

  ObjectMapper mapper;

  @Before
  public void setup() {
    mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).alwaysDo(log()).build();
    mapper = new ObjectMapper();
  }

  @Test
  public void postTodoTest() throws Exception {
    String title = "title";
    TodoResource todoRequest = new TodoResource();
    todoRequest.setTodoTitle(title);
    MvcResult result =
        mockMvc
            .perform(MockMvcRequestBuilders.post("/api/v1/todos")
                .content(mapper.writeValueAsString(todoRequest))
                .contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isCreated()).andReturn();

    TodoResource todoResponce =
        mapper.readValue(result.getResponse().getContentAsString(), TodoResource.class);
    assertThat(todoResponce.getTodoId(), notNullValue());
    assertThat(todoResponce.getTodoTitle(), equalTo(title));
    assertThat(todoResponce.isFinished(), equalTo(false));
    assertThat(todoResponce.getCreatedAt(), notNullValue());
  }
}
テストコードを実行

実行すると、ステータスコードがCreated(201)ではなくOK(200)が返ってきていますね……:weary:

java.lang.AssertionError: Status expected:<201> but was:<200>
    at org.springframework.test.util.AssertionErrors.fail(AssertionErrors.java:54)
    at org.springframework.test.util.AssertionErrors.assertEquals(AssertionErrors.java:81)
    at org.springframework.test.web.servlet.result.StatusResultMatchers$10.match(StatusResultMatchers.java:665)
    at org.springframework.test.web.servlet.MockMvc$1.andExpect(MockMvc.java:171)
    at todo.api.TodoRestControllerTest.postTodoTest(TodoRestControllerTest.java:55)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
    at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
    at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
    at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
    at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26)
    at org.springframework.test.context.junit4.statements.RunBeforeTestMethodCallbacks.evaluate(RunBeforeTestMethodCallbacks.java:75)
    at org.springframework.test.context.junit4.statements.RunAfterTestMethodCallbacks.evaluate(RunAfterTestMethodCallbacks.java:86)
    at org.springframework.test.context.junit4.statements.SpringRepeat.evaluate(SpringRepeat.java:84)
    at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
    at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.runChild(SpringJUnit4ClassRunner.java:252)
    at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.runChild(SpringJUnit4ClassRunner.java:94)
    at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
    at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
    at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
    at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
    at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
    at org.springframework.test.context.junit4.statements.RunBeforeTestClassCallbacks.evaluate(RunBeforeTestClassCallbacks.java:61)
    at org.springframework.test.context.junit4.statements.RunAfterTestClassCallbacks.evaluate(RunAfterTestClassCallbacks.java:70)
    at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
    at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.run(SpringJUnit4ClassRunner.java:191)
    at org.eclipse.jdt.internal.junit4.runner.JUnit4TestReference.run(JUnit4TestReference.java:86)
    at org.eclipse.jdt.internal.junit.runner.TestExecution.run(TestExecution.java:38)
    at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:538)
    at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:760)
    at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.run(RemoteTestRunner.java:460)
    at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.main(RemoteTestRunner.java:206)

ちなみに実際にサーバにデプロイしてPostmanを使って/api/v1/todosPOSTするとTodoが作成され201が返却されます。

postmanでの結果

この後、URLや設定にミスが無いことを念入りに確認しましたが、時間だけが過ぎていきました……

解決方法

TestDispatcherServletを調べる

単体テストで利用するOSSライブラリの使い方-MockMvcとは-を読んでいて気づいたのですが、MockMVCTestDispatcherServletを使って擬似的なリクエストを実現するようです。
ここで実際にTestDispatcherServlet.javaを見てみましょう。
TestDispatcherServlet.java-4.3.x-

TestDispatcherServlet.java
final class TestDispatcherServlet extends DispatcherServlet {

    private static final String KEY = TestDispatcherServlet.class.getName() + ".interceptor";


    /**
     * Create a new instance with the given web application context.
     */
    public TestDispatcherServlet(WebApplicationContext webApplicationContext) {
        super(webApplicationContext);
    }

コンストラクタで親クラスであるDispatcherServletのコンストラクタを呼んでいますね。
次にDispatcherServlet.javaを見てみましょう。

DispatcherServlet.java-4.3.x-

DispatcherServlet.java
public class DispatcherServlet extends FrameworkServlet {

    /**
     * Create a new {@code DispatcherServlet} with the given web application context. This
     * constructor is useful in Servlet 3.0+ environments where instance-based registration
     * of servlets is possible through the {@link ServletContext#addServlet} API.
     * <p>Using this constructor indicates that the following properties / init-params
     * will be ignored:
     * ~略~
     */
    public DispatcherServlet(WebApplicationContext webApplicationContext) {
        super(webApplicationContext);
        setDispatchOptionsRequest(true);
    }

「指定されたアプリケーションコンテキストでDispatcherServletを作成する」とありますね。
このコードの上にある、引数がないコンストラクタも見てみましょう

DispatcherServlet.java-4.3.x-

DispatcherServlet.java
public class DispatcherServlet extends FrameworkServlet {

    /**
     * Create a new {@code DispatcherServlet} that will create its own internal web
     * application context based on defaults and values provided through servlet
     * init-params. Typically used in Servlet 2.5 or earlier environments, where the only
     * option for servlet registration is through {@code web.xml} which requires the use
     * of a no-arg constructor.
     * ~略~
     */
    public DispatcherServlet(WebApplicationContext webApplicationContext) {
        super(webApplicationContext);
        setDispatchOptionsRequest(true);
    }

「独自の内部Webアプリケーションコンテキストを提供する新しいDispatcherServletを作成します。」の次に、「サーブレットを登録する方法は、引数なしのコンストラクタの使用を必要とするweb.xmlだけです。」とあります(意訳)。
……つまり、MockMVCが利用するTestDispatcherServletは、DispatcherServletの引数ありのコンストラクタを呼びだすためweb.xmlの記述内容が反映されない。ということでしょうか。
ためしに関係ありそうな単語で調べてみると、Spring Frameworkのバージョンが3.2ですが、MockMvcを使ったテストでweb.xmlを読み込む方法について質問している人がいました。

Does the new Spring MVC Test Framework released in Spring 3.2 test the web.xml configuration?

回答者は、「Spring-mvc-test does not read the web.xml file」と言っていますね。

テスト修正

web.xmlに記述した内容が反映されないとすると、servlet-mappingが効いていないことになるので、実際のパスは/api/v1/todosではなく/todosになります。というわけで、テストコードを修正して再実行してみます。

TodoRestControllerTest.java
    MvcResult result = mockMvc
        .perform(
            MockMvcRequestBuilders.post("/todos").content(mapper.writeValueAsString(todoRequest))
                .contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON))
        .andExpect(status().isCreated()).andReturn();

result.PNG

やったぜ!

web.xmlの内容をテストコードに反映させたい……

↑で動いたのはよかったのですが、実際のアプリケーションのパスが/api/v1/todosでテストのパスが/todosっていうのは少し気持ちが悪いですね。
フィルタは以下のようにaddFilterで追加できることがわかっているのですが :sos:

TodoRestControllerTest.java
    mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
        .addFilter(new XTrackMDCPutFilter(), "/**").alwaysDo(log()).build();

投稿に至った経緯

この記事で記載しているコードはサンプルから引用しましたが、実際の開発ではURLの指定が正しいはずなのに404になってしまい、原因究明に多くの時間を割いてしまったので、結果をまとめました。
TodoアプリケーションREST編で試したら200が返ってきましたが、たぶん想定していない設定が効いていたのでしょう(適当)。

MockMVCに対してweb.xmlに記述していたフィルタなどを適用することで、想定した動きになることは確認できましたが、「MockMVCを使う場合はweb.xmlは読み込まれない」と明言しているコメントはあってもドキュメントが見つからなくて困っていました……が!公式ドキュメントの15.6 Spring MVC Test Frameworkを読んでいたら、

The Spring MVC Test framework provides first class support for testing Spring MVC code using a fluent API that can be used with JUnit, TestNG, or any other testing framework. It’s built on the Servlet API mock objects from the spring-test module and hence does not use a running Servlet container.

とあり、サーブレットコンテナ上では動かないことが書かれているので、web.xmlMockMVCというか、Spring MVC Testとは関係がないみたいです。