[v3.1.2対応]Spring Boot入門:Controllerをユニットテスト

プログラム
スポンサーリンク

アフィリエイト広告を利用しています

システム開発をする上で、ユニットテスト(単体テスト)は欠かせない要素です。Spring Bootを用いた開発でもそれは代わりありません。

@Serviceや@Componentアノテーションを付けたクラスに関しては、特に苦労はしないと思います。普通にMockitoなどでMockオブジェクトを作成して、インスタンス生成すればよいだけです。

参考記事→JavaのモックフレームワークMockitoの導入方法と使い方

しかし、@Controllerについてはそうも行きません。@GetMappingなどで指定したURLにアクセスした際のエミュレーションは単純にControllerのインスタンスを作成するだけでは実行不可能です。

そこで、この記事ではControllerのテスト方法を説明していきたいと思います。Controllerに定義したURLへのアクセスをJUnitを用いて行う方法です。

なお、システムによっては認証が必要なものもあるかと思います。しかし、今回の記事ではややこしくなるので認証は前提としません。まずはアクセス制限のないURLが対象です。

認証が絡むURLへのテストは、次回行います。(Spring Securityの認証をテスト

この記事はSpring Securityで認証と認可の続きとなります。

この記事を読むとわかること
  • Controllerのユニットテストの仕方
  • DIするインスタンスをMockにする方法
前提条件
  • Spring Bootの開発環境が整っている
  • 解説ではPleiades All in One Eclipseを使う
  • JUnit5の知識を持っている
  • Javaのバージョンは17
  • Spring Bootのバージョンは3.1.2
関連記事

この記事はSpring Boot入門の一部です。環境構築の方法から初めて基本的なWebアプリケーションの開発に必要なことを説明しています。

この記事のソースコード

この記事のソースコードはGithubに公開しています。

GitHub - gsg0222/spring-boot-tutorial-step8
Contribute to gsg0222/spring-boot-tutorial-step8 development...

GithubからSpring BootプロジェクトをEclipseにインポートする方法は次の記事を参考にしてください。

Spring Bootプロジェクトを作成

今回は依存関係に以下のものを追加してプロジェクトを作成しています。

  • Spring Boot DevTools
  • lombok
  • Validation
  • Thymeleaf
  • Spring Web
Springの依存関係。DevTools, Lombok, Validation, Thymeleaf, Webを選択した。

新しいものはないので、特に説明はありません。テストに関する依存関係は何も追加しなくても入っているので、特に設定不要です。

テスト対象のクラスとHTML

テストの説明をする前に、一通りテスト対象になるクラスとHTMLを貼り付けておきます。

正直必要な部分だけGithubで確認すれば良いと思うので、次の章までスキップしても問題ありません。全部コードを自分で手打ちしたい派の人だけ利用してもらえば良いと思います。

クラス

package blog.tsuchiya.step8.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

import blog.tsuchiya.step8.controller.form.SampleForm;
import blog.tsuchiya.step8.service.SampleService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@Controller
@RequiredArgsConstructor
public class MainController {

	private final SampleService ss;
	@GetMapping
	public String index(Model model) {
		model.addAttribute("sampleForm", new SampleForm());
		return "index";
	}
	
	@PostMapping("input")
	public String input(@Valid SampleForm sampleForm, BindingResult result, Model model) {
		if (result.hasErrors()) {
			return "index";
		}
		int textLength = ss.length(sampleForm.getText());
		model.addAttribute("textLength", textLength);
		model.addAttribute("integer", sampleForm.getInteger());
		return "input";
	}
}

まずはテスト対象のControllerから。と言っても目新しいことは何もしていません。サービスをコンストラクタインジェクションでDIしているだけです。

アクセス対象のURLはGetで「/」とPostで「/input」の合計2つです。

package blog.tsuchiya.step8.controller.form;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;

@Data
public class SampleForm {

	@NotBlank
	@Size(max=10)
	private String text;
	
	@Max(10)
	@Min(1)
	@NotNull
	private Integer integer;
}

/inputで受け取るフォームです。少しだけValidationをしています。

package blog.tsuchiya.step8.service;

import org.springframework.stereotype.Service;

/**
 * ControllerにDIする対象。
 * 実装自体に意味はない。
 */
@Service
public class SampleService {

	public int length(String target) {
		return target.length();
	}
}

ControllerにDIするサービスです。なにか適当にDIしたかったから作ったもので、処理自体にあんまり意味はありません。

HTML

src/main/resources/templates/index.html:

<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>初期画面</title>
<link
	href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/css/bootstrap.min.css"
	rel="stylesheet">
</head>
<body>
	<main class="container">
		<section class="border p-1 mb-3">
			<form method="post" th:action="@{/input}" th:object="${sampleForm}">
				<div class="mb-3">
					<label for="text" class="form-label">文字列(空でない、10文字以下)</label> <input
						type="text" class="form-control" th:errorclass="is-invalid"
						th:field="*{text}">
					<p class="invalid-feedback" th:errors="*{text}"></p>
				</div>
				<div class="mb-3">
					<label for="integer" class="form-label">整数(整数、1以上10以下、空白NG)</label>
					<input type="text" class="form-control" th:errorclass="is-invalid"
						th:field="*{integer}">
					<p class="invalid-feedback" th:errors="*{integer}"></p>
				</div>
				<div>
					<button type="submit" class="btn btn-primary">送信</button>
				</div>
			</form>
		</section>
	</main>
</body>
</html>

/inputへPostするフォームを表示します。

src/main/resources/templates/input.html:

<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>結果画面</title>
<link
    href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/css/bootstrap.min.css"
    rel="stylesheet">
</head>
<body>
    <main class="container">
        <section class="border p-1 mb-3">
        	<p>入力された文字の長さは <span th:text="${textLength}">1</span>です。</p>
        	<p>入力された数値は <span th:text="${integer}">1</span>です。</p>
        </section>
    </main>
</body>
</html>

index.htmlからの入力をもとに適当な値を出力します。

テストクラス

さて、本記事の本体であるテストクラスを説明します。実はプロジェクトを作る際に最初からテストクラスが1つあるのですが(私が作ったプロジェクトだとsrc/test/java/blog/tsuchiya/step8/Step8ApplicationTests.java)、プロジェクト全体でテストを実行するときにじゃまになるので最初からあるものは削除してしまってください。

テストはプロジェクトのsrc/test/javaフォルダ以下に書きます。同じパッケージでも異なるフォルダに置くようにして、テスト対象とテストクラスを混同しないようにするためです。

通常はクラスごとにテストクラスを作成します。わかりやすさ優先で、テスト対象のクラスの後ろにTestとつけた名前にすることが多いです。

今回の場合、MainControllerのテストクラスをMainControllerTestというクラス名で作りました。

package blog.tsuchiya.step8.controller;

import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
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.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import blog.tsuchiya.step8.service.SampleService;

// 最重要。テスト対象のサーバを起動して、Controllerの
// テストを行えるようにするアノテーション
@SpringBootTest
class MainControllerTest {
	
	// MainControllerでSampleServiceにMockオブジェクトをDIする
	@MockBean
	private SampleService ss;

	private MockMvc mockMvc;

	@Autowired
	WebApplicationContext webApplicationContext;

	@BeforeEach
	void setup() {
		// @AutoConfigureMockMvcというアノテーションを使うとこの初期化は不要だが、
		// 問題が起きることもあるので手動で初期化している。
		mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
	}

	@Test
	@DisplayName("/を表示した場合")
	void indexSuccess() throws Exception {
		// @formatter:off
		// /にアクセスした場合のテストを行う
        this.mockMvc.perform(get("/"))
        		// modelにsampleFormという名前でオブジェクトが格納されていることを確認
                .andExpect(model().attributeExists("sampleForm"));
        // @formatter:on
	}

	@Test
	@DisplayName("/inputに妥当な入力をした場合")
	void inputSuccess()  throws Exception {
		// @MockBeanで格納されるMockオブジェクトはMockitoのもの。
		// そのため、使い方はMockitoと同じ。
		doReturn(5).when(ss).length("testInput");
		// @formatter:off
		// /inputにPostでアクセス、指定したパラメータを入力
		this.mockMvc.perform(post("/input").param("text", "testInput").param("integer", "4"))
				// modelのtextLengthの値は5
				.andExpect(model().attribute("textLength", 5))
				// modelのintegerの値は4
				.andExpect(model().attribute("integer", 4))
				// メソッドの戻り値はinput
				.andExpect(view().name("input"));
        // @formatter:on
		verify(ss, times(1)).length("testInput");
	}

	@Test
	@DisplayName("/inputに長過ぎる文字列を入力した場合")
	void inputTooLongText()  throws Exception {
		// @formatter:off
		// textに10文字を超える入力をしてみる
		this.mockMvc.perform(post("/input").param("text", "too_long_for_valid").param("integer", "4"))
				// エラーが1つあるはず
				.andExpect(model().errorCount(1))
				// modelに値は格納されていないはず
				.andExpect(model().attributeDoesNotExist("textLength"))
				.andExpect(model().attributeDoesNotExist("integer"))
				// エラー発生時はindexを表示する
				.andExpect(view().name("index"));
        // @formatter:on
		verify(ss, times(0)).length("too_long_for_valid");
		// setupメソッドでmockMvcを初期化しているので、DIしたモックオブジェクトも初期化されている
		verify(ss, times(0)).length("testInput");
	}

}

重要なところを解説していきます。

@SpringBootTestアノテーション

まず最重要なのがこのアノテーションです。

@SpringBootTest

このアノテーションをクラスに付けることによって、Spring Bootを起動した状態でのテストを実行可能になります。

  • application.propertiesの値のロード
  • Controllerで設定したURLへのアクセス受付
  • テストクラスへのSpring Bootが管理しているBeanのDI

などが行えるようにするためのおまじないです。

今回のクラスだと、WebApplicationContextが@Autowiredできていたり、/や/inputに擬似的にアクセスできているのはこの@SpringBootTestのおかげということになります。

@MockBeanアノテーション

@MockBeanアノテーションを付けたフィールドには、MockitoのMockオブジェクトが自動でDIされます。

	// MainControllerでSampleServiceにMockオブジェクトをDIする
	@MockBean
	private SampleService ss;

アノテーションを付けたフィールドだけではなく、すべてのクラスのフィールドが対象です。

今回の例だと、MainControllerTestのSampleService型のフィールドssだけでなく、MainControllerのフィールドssにもMockオブジェクトがDIされます。

DIされるのは同一のインスタンスです。そのため、後でメソッドの呼び出し回数の確認などが簡単にできます。

MockMvc

URLへのアクセスをエミュレートするためにMockMvcというクラスを利用します。

		// @AutoConfigureMockMvcというアノテーションを使うとこの初期化は不要だが、
		// 問題が起きることもあるので手動で初期化している。
		mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();

今回のテストではコードを書いて@BeforeEachで毎回手動で初期化することにしました。

@AutoConfigureMockMvcというアノテーションがあり、これを使うと手動での初期化は不要にできます。ただ、こちらを使うと問題が発生することがあったので、私は手動での初期化をおすすめします。(発生した問題の詳細は→Sesson管理にDBを使っている際のMockMvcによるSessionのテスト

URLへのアクセスエミュレートと結果の検証

それぞれのテストメソッドでは、MockMvcを使ってそれぞれのURLへのアクセスをエミュレートします。また、アクセスをした結果の検証も同時に行います。

この方法でURLにアクセスすると、Controllerに実装したメソッドだけでなく、Spring Bootが提供している仕組み全体がテスト可能になります。

今回のテストではValidationが実装できていることを確認していますし、仮にInterceptorで共通処理を実装していた場合はそれも呼び出される状態でテスト可能です。

		// /にアクセスした場合のテストを行う
        this.mockMvc.perform(get("/"))
        		// modelにsampleFormという名前でオブジェクトが格納されていることを確認
                .andExpect(model().attributeExists("sampleForm"));

		// /inputにPostでアクセス、指定したパラメータを入力
		this.mockMvc.perform(post("/input").param("text", "testInput").param("integer", "4"))
				// modelのtextLengthの値は5
				.andExpect(model().attribute("textLength", 5))
				// modelのintegerの値は4
				.andExpect(model().attribute("integer", 4))
				// メソッドの戻り値はinput
				.andExpect(view().name("input"));

		// textに10文字を超える入力をしてみる
		this.mockMvc.perform(post("/input").param("text", "too_long_for_valid").param("integer", "4"))
				// エラーが1つあるはず
				.andExpect(model().errorCount(1))
				// modelに値は格納されていないはず
				.andExpect(model().attributeDoesNotExist("textLength"))
				.andExpect(model().attributeDoesNotExist("integer"))
				// エラー発生時はindexを表示する
				.andExpect(view().name("index"));

MockMvcのperformメソッドにMockMvcRequestBuildersのpostやgetメソッドの実行結果を渡して、performの結果に対してandExpectメソッドで検証を行います。

コードを見ればだいたい分かると思いますが、Getメソッドのミュレートをする場合はgetメソッドにアクセスしたいパスを渡して、Postの場合はpostメソッドにパスを渡し、その結果をもとにperformを実行する形です。

performメソッドの戻り値に対して実行しているandExpectメソッドには、ResultMatcherのオブジェクトを渡します。MockMvcResultMatchersが持っているstaticメソッドを起点としてオブジェクトを作ることになるでしょう。

主に使うのは以下の内容になるかと思います。

MockMvcResultMatchersのメソッド内容
status().isOk()HTTPステータス200が返ってきたか
model().hasError()エラーがあるか
model().errorCount(int)エラーの数は指定の通りか
model().attribute(name, value)modelに指定した名前と要素が格納されているか
model().attributeDoesNotExist(name)modelに指定した名前が存在しないか
request().sessionAttribute(name, value)sessionに指定した名前と要素が格納されているか
request().sessionAttributeDoesNotExist(name)sessionに指定した名前が存在しないか
view().name(name)テンプレート名が指定の通りか

その他、いろいろな検証が可能なのでより詳しいことが知りたい場合はMockMvcResultMatchersのAPIドキュメントを参照してください。

DIされたMockオブジェクトの検証

@MockBeanでIDされたMockオブジェクトが正しく呼び出されていることも確認できます。

verify(ss, times(1)).length("testInput");

この辺はMockitoの説明で書いた通りの使い方です。

先程も書いたとおり、@MockBeanアノテーションを付けたフィールドだけでなく、すべてのクラスの同じ型のDI対象にモックオブジェクトがDIされます。

ControllerにDIされるインスタンスをすべてMockオブジェクトにすることで、Controllerクラスだけをテストすることが可能です。

例えシステムがRDBを使っていたとしても、アクセスするのがMockオブジェクトになるので実際のDBには影響を与えずControllerのユニットテストが可能になります。

まとめ:認証不要なページはこれでテスト可能に

認証が必要ない状態でのControllerのテスト方法を説明しました。

  • @SpringBootTestアノテーションをクラスに付ける
  • MockMvcを初期化する
  • MockMvcのperformメソッドでアクセスをエミュレートする
  • andExpectで検証する

この流れを忘れないでください。

ControllerのDI対象フィールドにMockオブジェクトを自動でDIするあたりが少しむずかしいと思うので、ご自身の作ったアプリケーションで試しにテストを作るのが習得への近道になると思います。

もしわからないことがあったら、お問い合わせツイッターのDMで連絡いただければ可能な限り対応したいと思っています。記事のコメントも公開しているので、そちらでも大丈夫です。

記事の冒頭にも書いたとおり、認証や認可がかかったURLのテストはSpring Securityの認証をテストで説明します。

関連記事

この記事はSpring Boot入門の一部です。環境構築の方法から初めて基本的なWebアプリケーションの開発に必要なことを説明しています。

MENTAプラン紹介
フリーランスITエンジニアに関する相談にのります

フリーランスITエンジニアになりたい、けれど不安があるという方向けの相談サービスです。

長期ブランクという苦境を乗り越えてフリーランスとして独立した私、土谷俊介があなたの不安を取り除きます。

プログラム
スポンサーリンク
土谷 俊介をフォローする

コメント

タイトルとURLをコピーしました