Controllerのテストって特殊なので悩みますよね。。
業務で詰まったSpringBootのControllerのテストの書き方をメモします。
対象Controller
下記controllerについて、テストを書いていきます。
@Controller
@RequestMapping("/")
public class DemoController {
  /**
   * index
   */
  @RequestMapping(path = "home", method = RequestMethod.GET)
  public ModelAndView index(ModelAndView mav) {
    
    // messageに値を設定
    mav.addObject("message", "hello world");
    mav.setViewName("index");
    return mav;
  }
  /**
   * 入力画面表示
   */
  @RequestMapping(path = "form", method = RequestMethod.GET)
  public ModelAndView form(ModelAndView mav, Form form) {
    // formのnameに初期値を設定
    form.setName("hoge");
    mav.addObject("form", form);
    mav.setViewName("demoForm");
    return mav;
  }
  /**
   * 結果受け取り、validation
   */
  @RequestMapping(path = "form", method = RequestMethod.POST)
  public ModelAndView formPost(ModelAndView mav, @Valid @ModelAttribute Form form,
      BindingResult result) {
    // validationのチェック
    if (result.hasFieldErrors()) {
      mav.addObject("errors", result.getFieldErrors());
      mav.addObject("form", form);
      mav.setViewName("demoForm");
      return mav;
    }
    // formの値を保存
    formService.saveData(form);
    mav.setViewName("ok");
    return mav;
  }
}
また、フォームの送受信で使うformクラスは以下のとおりです。
nameに@NotBlankアノテーションでvalidationをかけています。
@Getter
@Setter
public class Form {
  @NotBlank(message = "名前は必須項目です。")
  private String name;
}
準備
SpringMVCのControllerのテストではいくつかのお約束があるので最初にそれを準備します。
次項以降で詳細のテストを記述しますが、この準備が正しく行わないと思わぬErrorに悩まされます。
(私はそれで何時間も無駄にしました。。)
まず、Junit上でもSpringのDI機能を動かす必要があるため、
@Runwith(..)と@SpringBootTestのアノテーションをテストクラスに追加します。
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest
public class DemoControllerTest {
@Autowiredでテスト対象のクラスをDIコンテナに登録。
MockMvcBuilders.standaloneSetup(...)でspringMVCの動作を再現するための準備をします。
@Beforeアノテーションがついているのはすべての@Testの前に行うためです。
以降、このmockMvcインスタンスを利用して、仮想のリクエストを発生させテストを実行します。
  private MockMvc mockMvc;
  @Autowired
  DemoController target;
  @Before
  public void setup() {
    mockMvc = MockMvcBuilders.standaloneSetup(target).build();
  }
mockMVCについては、こちらのサイトにて詳しく解説されていました。
ここまでのコードは以下のとおりです。
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest
public class DemoControllerTest {
  private MockMvc mockMvc;
  @Autowired
  DemoController target;
  @Before
  public void setup() {
    mockMvc = MockMvcBuilders.standaloneSetup(target).build();
  }
}
homeメソッドへのテスト
まず、/homeへのGETメソッドでmessageをmodelに詰めてviewを描画するhome()メソッドへのテストを記載します。
テストする項目は下記のとおりです。
- レスポンスのHTTPステータスコードは正しいか?
 - 指定のviewを返すか?
 - modelに正しい変数を詰められているか?
 
では順に見ていきましょう。
レスポンスのHTTPステータスコードは正しいか?
mockMvcのperformを使ってリクエストを実行します。
mockMvc.perform(メソッド名("指定のurl"))
そして、続いてandExceptメソッドでレスポンスのテストを行います。
.andExpect(テスト項目)
今回はHTTPステータスコードのテストなのでstatus()を使います。
ステータスコード200はstatus().isOkでテストできます。
代表的なステータスコードは以下のように判定ができます。
| ステータス | メソッド | 
|---|---|
| 200 | status().isOk() | 
| 308 | status().isPermanentRedirect() | 
| 404 | status().isNotFound() | 
| 403 | status().isForbidden() | 
| 503 | status().isServiceUnavailable() | 
ここまでのコードは以下のとおりです。
  @Test
  public void getIndexTest() throws Exception {
    // when
    mockMvc.perform(get("/home"))
         .andExpect(status().isOk());
  }
指定のviewを返すか?
"/home"でindex.htmlを返すか確認します。
viewの判定はview().name()を使います.
.andExpect(view().name(テンプレート名))
追加すると以下のようになります。
  @Test
  public void getIndexTest() throws Exception {
    // when
    mockMvc.perform(get("/home"))
        .andExpect(status().isOk())
        .andExpect(view().name("index"));
  }
modelに正しい変数を詰められているか?
次にviewで使う変数を正しくmodelに詰められているかmodelの状態をテストします。
変数をviewに渡しているかのテストはmodel().attribute()を使います
model().attribute(変数名,値)
今回はmessageという変数にhello worldを詰められているかなので、
以下のようになります。
  @Test
  public void getIndexTest() throws Exception {
    // when
    mockMvc.perform(get("/home"))
        .andExpect(status().isOk())
        .andExpect(view().name("index"))
        .andExpect(model().attribute("message", "hello world"));
  }
他にもやりようはあるのでしょうが、一旦ここまででindexのテストはOKとします。
formGetメソッドへのテスト
formメソッドは初期化したformBeanをmodelに詰めてdemoForm.htmlを表示するものです。
ただ、formのviewを返すだけだと、先程と同じなので、formのnameフィールドに初期値を設定できているかをテストします。
    form.setName("hoge");
    mav.addObject("form", form);
formのnameに初期値("hoge")をセットできているか?
モデルに渡したオブジェクトの内容は、mockMvc.perform().andReturn()でリクエストの戻り値を取得することで判定できます。
.andReturnでリクエスト結果のMvcResultを受け取り、そこからgetModelAndViewでviewとモデルを取得、
さらにgetModelでモデルを取得して、getメソッドで"form"の値を取得しています。
ここでの注意はget()の戻り値はobject型なので、(Form)でキャストしましょう。
まとめると以下のような形になります。
  @Test
  public void getFormTest() throws Exception {
    // when
    MvcResult result = mockMvc.perform(get("/form"))
        .andExpect(status().isOk())
        .andExpect(view().name("demoForm"))
        .andReturn();
    // ここでmodelに詰められたformの値を取得
    Form resultForm = (Form) result.getModelAndView().getModel().get("form");
    // then 
    assertEquals(resultForm.getName(),"hoge");
  }
formPostメソッドのテスト
最後にformPostメソッドのテストです。
formPostメソッドはdemoform.htmlからpostリクエストでformの入力値を受け取り、
validationを行い、さらにErrorがなければ
FormService.saveDataを呼び出し、formの内容を保存して、ok.htmlを呼び出しています。
処理が複雑なので、テストする項目を書き出します。
- validation Errorがある場合
- error結果をmodelに詰められているか?
 - demoform.htmlを返すか?
 
 - validation Errorがない場合
- ok.htmlを返すか?
 - formService.saveDataを呼んでいるか?
 
 
一つづつ見ていきましょう。
validation Errorがある場合
まず、validationErrorがある場合のテストです。
そのためには、validationErrorを発生させましょう。
nameの値は@NotBlankなので、何もしない指定しない場合自動的にerrorが発生するのですが、
ここでは明示的にnameに空文字を入れます。
リクエス時のパラメータに値を入れるためには.param()また.flashAttrを使います。
paramの場合は、、
// form.nameにhogeを入れる場合
// mockMvc.perform(post("url名").param(パラメーター名, 値))
 mockMvc.perform(post("/form").param("name", "hoge"))
flashAttrを使う場合は、、
// form.nameにhogeを入れる場合
// mockMvc.perform(post("url名").flashAttr(パラメーター名, オブジェクト))
  Form form = new Form()
  form.setName("hoge")
  mockMvc.perform((post("/form")).flashAttr("form",form))
今回はflashAttrを使いテストします。
validationエラーが発生し、demoFormのviewを表示しているかテストしています。
エラー発生していることは、、
model().hasError()
で判定しています。
  @Test
  public void postFormTestInValid() throws Exception {
    // given
    Form form = new Form();
    form.setName("");
    // when
    mockMvc.perform((post("/form")).flashAttr("form",form))
        .andExpect(model().hasErrors())
        .andExpect(model().attribute("form", form))
        .andExpect(view().name("demoForm"));
  }
validation Errorがない場合
次に、validationErrorが発生しない場合をテストします。
ここでテストしたい項目は、
- formService.saveDataを呼んでいるか?
 - ok.htmlを返すか?
 
の2つです。
指定のhtmlを返すかどうかは前項で説明しているので、
指定のメソッド(formService.saveData)を呼んでいるかテストする方法について説明します。
まず行うことは、対象のサービスをモック化することです。
springMVCでは、@Mockではなく@MockBeanを使うことで、
@Autowiredされるクラスをモック化することができます。
また、@MockBeanアノテーションをつけたクラスは自動的に
テストクラスで@Autowiredされたクラス(ここではDemoController)の実行時にモック化されます。
なので、 @Autowired DemoController targetの前に@MockBeanを追加しましょう。
・
・
  private MockMvc mockMvc;
  // 追加
  @MockBean
  FormService mockFormService;
  @Autowired
  private DemoController target;
・
・
そしてMockitのverifyメソッドでMock化されたオブジェクトの使用状況を判定します。
以下はformService.saveDataがformというインスタンスを引数に1回呼ばれることをテストしています。
// verify(モックオブジェクト名, 使用回数).メソッド名・(引数);
verify(mockFormService, times(1)).saveData(form);
テストコード全体は以下のとおりです。
Errorを発生させないためにform.nameに値を設定しています。
  @Test
  public void postFormTestValid() throws Exception {
    // given
    Form form = new Form();
    form.setName("fuga");
    // when
    mockMvc.perform((post("/form")).flashAttr("form", form))
        .andExpect(model().hasNoErrors())
        .andExpect(model().attribute("form", form))
        .andExpect(view().name("ok"));
    // then
    verify(mockFormService, times(1)).saveData(form);
  }
結果
最終的に以下コードになりました。
これでDemoController.javaのカバレッジはmethod、lineともに100%です。
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest
public class DemoControllerTest {
  private MockMvc mockMvc;
  @MockBean
  FormService mockFormService;
  @Autowired
  private DemoController target;
  @Before
  public void setup() {
    mockMvc = MockMvcBuilders.standaloneSetup(target).build();
  }
  @Test
  public void getIndexTest() throws Exception {
    // when
    mockMvc.perform(get("/home"))
        .andExpect(status().isOk())
        .andExpect(view().name("index"))
        .andExpect(model().attribute("message", "hello world"));
  }
  @Test
  public void getFormTest() throws Exception {
    // when
    MvcResult result = mockMvc.perform(get("/form"))
        .andExpect(status().isOk())
        .andExpect(view().name("demoForm"))
        .andReturn();
    Form resultForm = (Form) result.getModelAndView().getModel().get("form");
    // then
    assertEquals(resultForm.getName(), "hoge");
  }
  @Test
  public void postFormTestInValid() throws Exception {
    // given
    Form form = new Form();
    form.setName("");
    // when
    mockMvc.perform((post("/form")).flashAttr("form", form))
        .andExpect(model().hasErrors())
        .andExpect(model().attribute("form", form))
        .andExpect(view().name("demoForm"));
  }
  @Test
  public void postFormTestValid() throws Exception {
    // given
    Form form = new Form();
    form.setName("hoge");
    // when
    mockMvc.perform((post("/form")).flashAttr("form", form))
        .andExpect(model().hasNoErrors())
        .andExpect(model().attribute("form", form))
        .andExpect(view().name("ok"));
    // then
    verify(mockFormService, times(1)).saveData(form);
  }
}
まだまだ自信を持ってこれが正しいと言えないのですが、
少しでも自分と同じようにテストの書き方で悩んでいる人の助けになれば幸いです。
参考
https://qiita.com/NetPenguin/items/0e06779ecdd48d24a5db
https://ito-u-oti.com/post-129/
http://blog.okazuki.jp/entry/2015/07/14/205627
https://terasolunaorg.github.io/guideline/5.4.1.RELEASE/ja/UnitTest/ImplementsOfUnitTest/UsageOfLibraryForTest.html