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とは-](http://terasolunaorg.github.io/guideline/5.4.1.RELEASE/ja/UnitTest/ImplementsOfUnitTest/UsageOfLibraryForTest.html#usageoflibraryfortestmockmvcoverview)

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

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

```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;
    }
}
```

```java: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;
}
```

```java: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;
	}
}
```


```xml: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`オブジェクトが返ってくることを確認します。

```java: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:

```text
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/todos`に`POST`すると`Todo`が作成され201が返却されます。

![postmanでの結果](https://qiita-image-store.s3.amazonaws.com/0/162142/baa7334b-6b32-dcad-6fbd-f6b0f12c8d7f.png)

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

#### 解決方法
##### TestDispatcherServletを調べる
[単体テストで利用するOSSライブラリの使い方-MockMvcとは-](https://terasolunaorg.github.io/guideline/5.4.1.RELEASE/ja/UnitTest/ImplementsOfUnitTest/UsageOfLibraryForTest.html#usageoflibraryfortestmockmvcoverview)を読んでいて気づいたのですが、`MockMVC`は`TestDispatcherServlet`を使って擬似的なリクエストを実現するようです。
ここで実際に`TestDispatcherServlet.java`を見てみましょう。
[TestDispatcherServlet.java-4.3.x-](https://github.com/spring-projects/spring-framework/blob/4.3.x/spring-test/src/main/java/org/springframework/test/web/servlet/TestDispatcherServlet.java#L397)

```java: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-](https://github.com/spring-projects/spring-framework/blob/4.3.x/spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java#L397)

```java: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-](https://github.com/spring-projects/spring-framework/blob/4.3.x/spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java#L353)

```java: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?](https://stackoverflow.com/questions/14026111/does-the-new-spring-mvc-test-framework-released-in-spring-3-2-test-the-web-xml-c)

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

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

```java: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](https://qiita-image-store.s3.amazonaws.com/0/162142/02244da5-48dc-7b91-c248-5c12202a40b7.png)

やったぜ！

#### web.xmlの内容をテストコードに反映させたい……
↑で動いたのはよかったのですが、実際のアプリケーションのパスが`/api/v1/todos`でテストのパスが`/todos`っていうのは少し気持ちが悪いですね。
フィルタは以下のように`addFilter`で追加できることがわかっているのですが :sos: 。

```java: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](https://docs.spring.io/spring/docs/4.3.x/spring-framework-reference/html/integration-testing.html#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.xml`は`MockMVC`というか、`Spring MVC Test`とは関係がないみたいです。