開発環境
- 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コンテナを使う方法がありますが、今回は前者を採用します。
詳しくはTERASOLUNAのガイドラインに書かれているので、参考にしてください。
単体テストで利用するOSSライブラリの使い方 -MockMvcとは-
テストの実施
テスト対象コード
TERASOLUNAのTodoアプリケーション(REST編)を例に説明します。
テストするAPIはTodoを作成するpostTodos
です。
TodoResource
オブジェクトにTitle
を設定してJSON形式でpost
すると、idと日付が付与され、未完状態のTodoResource
が返却されます。
パッケージやインポート、ゲッター&セッターは省略しています。テスト対象コードは、この記事では重要ではないので、
Todo.java
やTodoService.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;
}
}
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;
}
@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 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
オブジェクトが返ってくることを確認します。
@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)が返ってきていますね……
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が返却されます。
この後、URL
や設定にミスが無いことを念入りに確認しましたが、時間だけが過ぎていきました……
解決方法
TestDispatcherServletを調べる
単体テストで利用するOSSライブラリの使い方-MockMvcとは-を読んでいて気づいたのですが、MockMVC
はTestDispatcherServlet
を使って擬似的なリクエストを実現するようです。
ここで実際にTestDispatcherServlet.java
を見てみましょう。
TestDispatcherServlet.java-4.3.x-
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
を見てみましょう。
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
を作成する」とありますね。
このコードの上にある、引数がないコンストラクタも見てみましょう
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
になります。というわけで、テストコードを修正して再実行してみます。
MvcResult result = mockMvc
.perform(
MockMvcRequestBuilders.post("/todos").content(mapper.writeValueAsString(todoRequest))
.contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON))
.andExpect(status().isCreated()).andReturn();
やったぜ!
web.xmlの内容をテストコードに反映させたい……
↑で動いたのはよかったのですが、実際のアプリケーションのパスが/api/v1/todos
でテストのパスが/todos
っていうのは少し気持ちが悪いですね。
フィルタは以下のようにaddFilter
で追加できることがわかっているのですが 。
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.xml
はMockMVC
というか、Spring MVC Test
とは関係がないみたいです。