Spring Bootシリーズ。今回はWeb三層アプリケーションのControllerクラスのテストです。
テスト対象のControllerクラス
今回は、次の3つのメソッドが実装されているControllerクラスをテストします。
package penguin.web.controller; import java.time.LocalDate; import java.time.temporal.ChronoUnit; import java.util.List; import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import penguin.web.controller.form.ProfileForm; import penguin.web.service.ProfileService; @Controller @RequestMapping("profile") public class ProfileController { @Autowired private ProfileService profileService; @GetMapping("list") public String list(Model model) { List<ProfileForm> profileList = profileService.getProfileList() .stream() .map(e -> new ProfileForm(e.getName(), e.getBirthday(), ChronoUnit.YEARS.between(e.getBirthday(), LocalDate.now()))) .collect(Collectors.toList()); model.addAttribute("profiles", profileList); return "profile/list"; } @GetMapping("add") public String add(ProfileForm profileForm) { return "profile/add"; } @PostMapping("add") public String add(@Validated ProfileForm profileForm, BindingResult result) { if (result.hasErrors()) { return "profile/add"; } profileService.addProfile(profileForm.getName(), profileForm.getBirthday()); return "redirect:/profile/list"; } }
Controllerクラスのテストクラス
テストクラスは次のようになります。
このテストクラスは、テストの実装方法を説明するための実装のみなので、テストケースとしては不足しています。
package penguin.web.controller; import java.time.LocalDate; import java.util.Collections; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import penguin.web.controller.form.ProfileForm; import penguin.web.service.ProfileService; import penguin.web.service.dto.ProfileDto; @SpringBootTest @AutoConfigureMockMvc class ProfileControllerTest { @Autowired private MockMvc mockMvc; @MockBean private ProfileService profileService; @Test @DisplayName("プロフィールが0件の場合、Modelに0件のProfileFormのリストが設定され、プロフィール一覧画面に遷移するること") void testGetListNoData() throws Exception { Mockito.when(profileService.getProfileList()).thenReturn(Collections.emptyList()); MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/profile/list")) .andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.view().name("profile/list")) .andReturn(); Map<String, Object> model = result.getModelAndView().getModel(); Assertions.assertTrue(model.containsKey("profiles")); Assertions.assertNotNull(model.get("profiles")); Assertions.assertTrue(model.get("profiles") instanceof List<?>); List<ProfileForm> profileList = (List<ProfileForm>) model.get("profiles"); Assertions.assertTrue(profileList.isEmpty()); } @Test @DisplayName("プロフィールが1件の場合、Modelに1件のProfileFormのリストが設定され、プロフィール一覧画面に遷移するること") void testGetListOneData() throws Exception { Mockito.when(profileService.getProfileList()) .thenReturn(Collections.singletonList(new ProfileDto(1L, "matsuki", LocalDate.of(1998, 1, 1)))); try (MockedStatic<LocalDate> mockedLocalDate = Mockito.mockStatic(LocalDate.class, Mockito.CALLS_REAL_METHODS)) { LocalDate nowDate = LocalDate.of(2020, 7, 31); mockedLocalDate.when(LocalDate::now).thenReturn(nowDate); MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/profile/list")) .andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.view().name("profile/list")) .andReturn(); Map<String, Object> model = result.getModelAndView().getModel(); Assertions.assertTrue(model.containsKey("profiles")); Assertions.assertNotNull(model.get("profiles")); Assertions.assertTrue(model.get("profiles") instanceof List<?>); List<ProfileForm> profileList = (List<ProfileForm>) model.get("profiles"); Assertions.assertEquals(1, profileList.size()); ProfileForm profileForm = profileList.get(0); Assertions.assertEquals("matsuki", profileForm.getName()); Assertions.assertEquals(LocalDate.of(1998, 1, 1), profileForm.getBirthday()); Assertions.assertEquals(22, profileForm.getAge()); } } @Test @DisplayName("プロフィール追加画面に遷移すること") void testGetAdd() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get("/profile/add")) .andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.view().name("profile/add")); } @Test @DisplayName("プロフィール追加で正当な値が入力された場合、プロフィールの追加処理が呼び出され、プロフィール一覧画面に遷移するること") void testPostAdd() throws Exception { mockMvc.perform(MockMvcRequestBuilders.post("/profile/add") .param("name", "penguin") .param("birthday", "1998-01-01")) .andExpect(MockMvcResultMatchers.status().isFound()) .andExpect(MockMvcResultMatchers.view().name("redirect:/profile/list")); Mockito.verify(profileService, Mockito.times(1)) .addProfile("penguin", LocalDate.of(1998, 1, 1)); } @Test @DisplayName("プロフィール追加で生年月日が未入力の場合、生年月日にエラーがバインドされ、プロフィール追加画面に遷移するること") void testPostAddError() throws Exception { mockMvc.perform(MockMvcRequestBuilders.post("/profile/add") .param("name", "penguin") .param("birthday", "")) .andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.view().name("profile/add")) .andExpect(MockMvcResultMatchers.model().attributeHasErrors("profileForm")) .andExpect(MockMvcResultMatchers.model().errorCount(1)) .andExpect(MockMvcResultMatchers.model().attributeHasFieldErrorCode("profileForm", "birthday", "NotNull")); } }
テストクラスの実装内容
以下のようにクラスに2つのアノテーションが指定されています。
@SpringBootTest @AutoConfigureMockMvc class ProfileControllerTest {
@SpringBootTest
このアノテーションにより、Spring Bootのコンフィグレーションを自動検出して、テストに必要な設定がされます。
@AutoConfigureMockMvc
Controllerクラスはブラウザからのリクエストを受け付けるクラスです。
このアノテーションにより、テスト実行時にリクエストを発行するためのモックオブジェクト「MockMvc」が自動構成されます。
テストクラスのフィールド定義
このテストクラスには次の2つのフィールドが定義されています。
@Autowired private MockMvc mockMvc;
クラスに指定した@AutoConfigureMockMvcアノテーションにより自動構成されたMockMvcオブジェクトをインジェクトします。
このオブジェクトを使って、各テストメソッドでControllerにリクエストを発行することになります。
@MockBean private ProfileService profileService;
テスト対象のControllerクラスでインジェクトしているProfileServiceクラスを@MockBeanアノテーションを指定してフィールド定義しています。
@MockBeanアノテーションを指定することで、DIコンテナのProfileServiceクラスがモックオブジェクトになります。
モックオブジェクトのメソッドは呼び出しても何の処理もせず復帰します。復帰値は、オブジェクトの場合はnull、数値のプリミティブ型の場合は0、boolean型の場合はfalseを返すようになります。
テストメソッドの実装内容
個々のテストメソッドを確認していきます。
はじめに一番、単純なaddメソッドのテストメソッド実装から見ていきましょう。
テスト対象のメソッドは次の内容です。 画面表示するテンプレートのファイルパスを返すだけのメソッドです。
@GetMapping("add") public String add(ProfileForm profileForm) { return "profile/add"; }
この実装から、テストで検証するポイントを考えてみましょう。
次のような検証項目が考えられます。
- URLは/profile/addであること
- 受け付けるHTTPメソッドはGETであること
- テンプレート名としてprofile/addを返却すること
これらの検証項目を検証するテストメソッドは次の内容です。
@Test @DisplayName("プロフィール追加画面に遷移すること") void testGetAdd() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get("/profile/add")) .andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.view().name("profile/add")); }
では、テストメソッドの実装内容を分解して確認します。
mockMvc.perform(MockMvcRequestBuilders.get("/profile/add"))
MockMvcを使って、/profile/addに対してGETリクエストを発行します。
.andExpect(MockMvcResultMatchers.status().isOk())
リクエストした結果のHTTPステータスがOK(200)であることを確認します。
.andExpect(MockMvcResultMatchers.view().name("profile/add"));
テンプレート名としてprofile/addが返却されることを確認します。
このように、MockMvcのperformメソッドでリクエストを発行し、andExpectメソッドでリクエストした結果を検証する流れとなります。
次に、プロフィールの追加画面でaddボタンをクリックしたときに呼び出されるaddメソッドのテストメソッド実装を見ていきましょう。
テスト対象のメソッドは次の内容です。 処理内容は大きく2つのルートに分かれます。
- 入力エラーがある場合
- テンプレート名としてprofile/addを返却して、再びプロフィールの追加画面を表示する。
- 入力エラーがない場合
- ProfileServiceクラスのaddProfileメソッドを呼び出してプロフィールを登録する。
- テンプレート名としてredirect:/profile/listを返却して、プロフィール一覧画面にリダイレクトする。
@PostMapping("add") public String add(@Validated ProfileForm profileForm, BindingResult result) { if (result.hasErrors()) { return "profile/add"; } profileService.addProfile(profileForm.getName(), profileForm.getBirthday()); return "redirect:/profile/list"; }
テストメソッドは、入力エラーがある場合とない場合で分けて実装します。
入力エラーがないケースのテストメソッドは次の内容です。
@Test @DisplayName("プロフィール追加で正当な値が入力された場合、プロフィールの追加処理が呼び出され、プロフィール一覧画面に遷移するること") void testPostAdd() throws Exception { mockMvc.perform(MockMvcRequestBuilders.post("/profile/add") .param("name", "penguin") .param("birthday", "1998-01-01")) .andExpect(MockMvcResultMatchers.status().isFound()) .andExpect(MockMvcResultMatchers.view().name("redirect:/profile/list")); Mockito.verify(profileService, Mockito.times(1)) .addProfile("penguin", LocalDate.of(1998, 1, 1)); }
では、テストメソッドの実装内容を分解して確認します。
mockMvc.perform(MockMvcRequestBuilders.post("/profile/add") .param("name", "penguin") .param("birthday", "1998-01-01"))
MockMvcを使って、/profile/addに対してPOSTリクエストを発行します。 POSTするパラメータはparamメソッドで指定します。
.andExpect(MockMvcResultMatchers.status().isFound())
リクエストした結果のHTTPステータスがFound(302)であることを確認します。
リダイレクトの確認なので、OK(200)ではなく、Found(302)であることがポイントです。
.andExpect(MockMvcResultMatchers.view().name("redirect:/profile/list"));
テンプレート名としてredirect:/profile/listが返却されることを確認します。
Mockito.verify(profileService, Mockito.times(1)) .addProfile("penguin", LocalDate.of(1998, 1, 1));
profileServiceはモックオブジェクトです。モックオブジェクトのメソッドは実装された処理が実行されないだけではなく、メソッドの呼び出しをトレースすることができます。
テスト対象の実装を確認すると、画面の入力内容を引数に、ProfileServiceのaddProfileメソッドが呼び出されるはずなので、実際に呼び出されたかを検証します。
Mockito.verifyメソッドを使って、profileServiceのaddProfileメソッドが、引数"penguin"とLocalDate.of(1998, 1, 1)で1回だけ呼び出されたことを検証できます。
次に、入力エラーがあるケースのテストメソッドです。内容は次の通りです。
@Test @DisplayName("プロフィール追加で生年月日が未入力の場合、生年月日にエラーがバインドされ、プロフィール追加画面に遷移するること") void testPostAddError() throws Exception { mockMvc.perform(MockMvcRequestBuilders.post("/profile/add") .param("name", "penguin") .param("birthday", "")) .andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.view().name("profile/add")) .andExpect(MockMvcResultMatchers.model().attributeHasErrors("profileForm")) .andExpect(MockMvcResultMatchers.model().errorCount(1)) .andExpect(MockMvcResultMatchers.model().attributeHasFieldErrorCode("profileForm", "birthday", "NotNull")); } }
入力エラーを発生させるために、必須入力項目である生年月日を未入力としています。
mockMvc.perform(MockMvcRequestBuilders.post("/profile/add") .param("name", "penguin") .param("birthday", ""))
では、テストメソッドの実装内容を分解して確認します。
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.view().name("profile/add"))
HTTPステータスがOK(200)であることと、テンプレート名としてprofile/addが返されることを検証します。
.andExpect(MockMvcResultMatchers.model().attributeHasErrors("profileForm"))
Modelオブジェクトに格納されているprofileFormにエラーが含まれることを検証します。
.andExpect(MockMvcResultMatchers.model().errorCount(1))
Modelオブジェクトにエラーが1件、含まれることを検証します。
.andExpect(MockMvcResultMatchers.model().attributeHasFieldErrorCode("profileForm", "birthday", "NotNull"));
Modelオブジェクトに格納されているprofileFormのbirthday項目にNotNullのエラーがバインドされていることを検証します。
最後に、プロフィール一覧画面を表示するときに呼び出されるlistメソッドのテストメソッド実装を見ていきましょう。
テスト対象のメソッドは次の内容です。
ProfileServiceのgetProfileListメソッドによって、データベースから取得されたプロフィール一覧をProfileFormに詰め替えて、テンプレートファイルprofile/listによって、プロフィールの一覧を表示するものです。
このメソッドの最大の検証ポイントは、生年月日と現在の日付から年齢を算出する部分です。
@GetMapping("list") public String list(Model model) { List<ProfileForm> profileList = profileService.getProfileList() .stream() .map(e -> new ProfileForm(e.getName(), e.getBirthday(), ChronoUnit.YEARS.between(e.getBirthday(), LocalDate.now()))) .collect(Collectors.toList()); model.addAttribute("profiles", profileList); return "profile/list"; }
テストメソッドは、プロフィール一覧が0件のケースと1件のケースで分けています。
プロフィール一覧が0件のケースのテストメソッドは以下の内容です。
@Test @DisplayName("プロフィールが0件の場合、Modelに0件のProfileFormのリストが設定され、プロフィール一覧画面に遷移するること") void testGetListNoData() throws Exception { Mockito.when(profileService.getProfileList()).thenReturn(Collections.emptyList()); MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/profile/list")) .andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.view().name("profile/list")) .andReturn(); Map<String, Object> model = result.getModelAndView().getModel(); Assertions.assertTrue(model.containsKey("profiles")); Assertions.assertNotNull(model.get("profiles")); Assertions.assertTrue(model.get("profiles") instanceof List<?>); List<ProfileForm> profileList = (List<ProfileForm>) model.get("profiles"); Assertions.assertTrue(profileList.isEmpty()); }
では、テストメソッドの実装内容を分解して確認します。
Mockito.when(profileService.getProfileList()).thenReturn(Collections.emptyList());
このテストケースの肝です。
profileServiceはモックオブジェクトです。モックオブジェクトのメソッドの復帰値はオブジェクトの場合はnull、プリミティブ型の数値の場合は0、booleanの場合はfalseですが、変更可能です。
ここでは、profileServiceオブジェクトのgetProfileListメソッドを呼び出した時の復帰値を0件のListオブジェクト(Collections.emptyList())に変更しています。
これによって、テスト対象メソッド内でprofileServiceオブジェクトのgetProfileListメソッドを呼び出した時に、必ず0件のListを返すようになります。
MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/profile/list")) .andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.view().name("profile/list")) .andReturn();
HTTPステータスがOK(200)であることと、テンプレート名としてprofile/listが返されることを検証します。
andReturnメソッドを呼び出して、リクエストの実行結果が格納されているMvcResultオブジェクトを取得します。このMvcResultオブジェクトを使用して、詳細な検証をします。
Map<String, Object> model = result.getModelAndView().getModel();
MvcResultオブジェクトからModelを取得します。
Assertions.assertTrue(model.containsKey("profiles")); Assertions.assertNotNull(model.get("profiles")); Assertions.assertTrue(model.get("profiles") instanceof List<?>); List<ProfileForm> profileList = (List<ProfileForm>) model.get("profiles"); Assertions.assertTrue(profileList.isEmpty());
Modelオブジェクトに格納されているprofilesの内容を検証します。
プロフィール一覧が1件のケースのテストメソッドは以下の内容です。
基本的にはプロフィール一覧が0件のケースと同じ検証内容ですが、現在の日付がわかると年齢の期待値も変わってしまうので、現在の日付を固定化する必要があります。
@Test @DisplayName("プロフィールが1件の場合、Modelに1件のProfileFormのリストが設定され、プロフィール一覧画面に遷移するること") void testGetListOneData() throws Exception { Mockito.when(profileService.getProfileList()) .thenReturn(Collections.singletonList(new ProfileDto(1L, "matsuki", LocalDate.of(1998, 1, 1)))); try (MockedStatic<LocalDate> mockedLocalDate = Mockito.mockStatic(LocalDate.class, Mockito.CALLS_REAL_METHODS)) { LocalDate nowDate = LocalDate.of(2020, 7, 31); mockedLocalDate.when(LocalDate::now).thenReturn(nowDate); MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/profile/list")) .andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.view().name("profile/list")) .andReturn(); Map<String, Object> model = result.getModelAndView().getModel(); Assertions.assertTrue(model.containsKey("profiles")); Assertions.assertNotNull(model.get("profiles")); Assertions.assertTrue(model.get("profiles") instanceof List<?>); List<ProfileForm> profileList = (List<ProfileForm>) model.get("profiles"); Assertions.assertEquals(1, profileList.size()); ProfileForm profileForm = profileList.get(0); Assertions.assertEquals("matsuki", profileForm.getName()); Assertions.assertEquals(LocalDate.of(1998, 1, 1), profileForm.getBirthday()); Assertions.assertEquals(22, profileForm.getAge()); } }
では、テストメソッドの実装内容を分解して確認します。
Mockito.when(profileService.getProfileList()) .thenReturn(Collections.singletonList(new ProfileDto(1L, "matsuki", LocalDate.of(1998, 1, 1))));
0件のケースと同様、ProfileServiceのモッククラスのgetProfileListメソッドの復帰値を変更して、必ず1件のListを返すようになります。
try (MockedStatic<LocalDate> mockedLocalDate = Mockito.mockStatic(LocalDate.class, Mockito.CALLS_REAL_METHODS)) { LocalDate nowDate = LocalDate.of(2020, 7, 31); mockedLocalDate.when(LocalDate::now).thenReturn(nowDate);
テスト対象のメソッドでは、LocalDateクラスのstaticメソッドであるnowメソッドを呼び出して、現在の日付を取得しています。このnowメソッドの復帰値を固定化するために、Mockitoの機能を使用します。
MockitoのmockStaticメソッドの引数にモック化したいLocalDateのクラスを指定します。通常、モック化したクラスのすべてのメソッドは、何も処理をせず、復帰値はオブジェクトの場合はnull、プリミティブ型の数値型は0、booleanはfalseを返すようになります。
ただし、今回、復帰値を変更したいメソッドはnowメソッドだけで、ほかのメソッドは今まで通りの動作をしてほしいので、MockitoのmockStaticメソッドの第2引数にMockito.CALLS_REAL_METHODSを指定します。
MockitoのmockStaticメソッドの復帰値であるMockedStaticは、最後にcloseメソッドを呼ぶ必要があります。closeメソッドを呼ばないと、モック化が解除されず、ほかのテストクラスの実行に影響を及ぼします。MockedStaticはAutoCloseableを実装しているので、try with resources構文を使ってクローズします。
LocalDate nowDate = LocalDate.of(2020, 7, 31); mockedLocalDate.when(LocalDate::now).thenReturn(nowDate);
現在の日付を固定化する部分です。
MockitoのmockStaticメソッドの復帰値のMockedStaticオブジェクトを使用します。whenメソッドでモック化するメソッドをラムダ式で指定します。そして、thenReturnメソッドでモックメソッドの復帰値を指定します。
この実装を見て、ローカル変数「nowDate」はここでしか使用されていないので、以下のように1行にまとめたくなるかもしれません。
mockedLocalDate.when(LocalDate::now).thenReturn(LocalDate.of(2020, 7, 31));
全く問題のないコードに見えますが、Mockito的に、このコードはNGです。実際に実行すると、例外が発生してしまいます。
理由は、モック化されたメソッドの復帰値の定義中にモック化されたメソッドを呼び出してはいけないからです。
1行にまとめた記述方法では、LocalDateのnowメソッドの復帰値の定義内で、モック化されたLocalDateのofメソッドを呼び出してしまいます。なので、ofメソッドをあらかじめ呼び出して、その復帰値をモックの復帰値の定義で指定する必要があるのです。
別の解決方法で、thenReturnではなく、thenAnswerを使う方法があります。thenAnswerの使い方は、今回の本題から外れてしまうので、別の機会に解説します。
MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/profile/list")) .andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.view().name("profile/list")) .andReturn(); Map<String, Object> model = result.getModelAndView().getModel(); Assertions.assertTrue(model.containsKey("profiles")); Assertions.assertNotNull(model.get("profiles")); Assertions.assertTrue(model.get("profiles") instanceof List<?>); List<ProfileForm> profileList = (List<ProfileForm>) model.get("profiles"); Assertions.assertEquals(1, profileList.size()); ProfileForm profileForm = profileList.get(0); Assertions.assertEquals("matsuki", profileForm.getName()); Assertions.assertEquals(LocalDate.of(1998, 1, 1), profileForm.getBirthday()); Assertions.assertEquals(22, profileForm.getAge());
残りの処理は、0件のケースと同様です。処理を呼び出して、処理結果の検証をします。
次回予告
今回は、Spring Bootで作成したWeb三層アプリケーションのApplication層にあたるControllerクラスのユニットテストを実装しました。
次回は、Business層にあたるServiceクラスのユニットテストを実装します。
では、次回の「Spring BootでテストするWebアプリケーション③ - Serviceクラスのテスト」でお会いしましょう!!