Spring BootでValidationメッセージが表示されない原因を解説する記事のアイキャッチ。Java道場風の空間で若手エンジニアがth:errorsを確認するイラスト。

実装・エラー解決

Spring Bootでバリデーションメッセージが表示されない|BindingResultとThymeleafの切り分け

Spring BootとThymeleafの登録画面で名前を空欄にして送信したところ、同じ画面には戻るのに「名前を入力してください。」が表示されない。最初に見るのは、ControllerのBindingResultにnameのエラーがあるかです。エラーが1件あるなら、同じ制約を付け直しても、表示側が別の項目を読んでいる問題は直りません。

この記事では、架空のユーザー登録画面に、名前のエラーがあるのにメールのエラーを表示しようとする誤りを入れます。同じPOSTを故障版と修正版へ送り、Controllerの判定と返されたHTMLを別々に確認します。

この記事でわかること

  • BindingResultで、入力チェックが動いているかを見分ける。
  • th:errorsの参照先を直し、日本語の文言と入力保持を確認する。
  • エラー時の画面返却と成功後のリダイレクトを分け、誤って保存しないこともテストする。

同じPOSTで「エラーはあるのに出ない」を再現する

先に、どんな入力で何が起きてほしいかを決めます。この例の名前は必須で50文字以内、メールは任意で、値が入っていればメール形式を確認します。比較に使うのは POST /users に名前 name=""、メール email="test@example.com" を送るケースです。メールは正常にしておくので、表示されるべきエラーは名前の1件だけです。両方を不正にすると、誤った参照先のメールにもエラーがあり、何を読んで表示したのか判別しづらくなります。

確認する場所故障版修正版
HTTP応答と画面名200、users/input200、同じ画面
モデルの入力エラーuserForm.nameに1件同じ1件
返されたHTMLname-error要素も日本語文言もないname-error要素に「名前を入力してください。」
入力と保存メール保持、Service呼出し0回メール保持、Service呼出し0回

この表で変わるのは、返されたHTMLのエラー表示だけです。200だから登録できた、入力画面に戻ったから文言も出る、とは判断できません。Controllerが保存を止めることと、利用者に直すべき欄を示すことは、別の約束です。業務画面のレビューでも、同じ入力を残して一箇所ずつ比較すると、設定を手当たり次第に変更せず原因を絞れます。

まずControllerで、名前のエラーが作られたか確認する

送信時にブレークポイントを置くなら、create内のif (bindingResult.hasErrors())です。ここでbindingResult.getFieldErrors("name")を見ます。空欄の例では名前のエラーが1件あり、userService.create(form)へ進まずusers/inputを返します。Spring MVCはこのFormと対応する検証結果をモデルとして扱うため、テンプレートは同じ名前で参照できます。

package info.syachiku.validation;

import jakarta.validation.Valid;
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.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
public class UserController {
    private final UserService userService;
    public UserController(UserService userService) { this.userService = userService; }

    @GetMapping("/users/new")
    public String input(Model model) {
        model.addAttribute("userForm", new UserForm());
        return "users/input";
    }

    @PostMapping("/users")
    public String create(@Valid @ModelAttribute("userForm") UserForm form,
                         BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "users/input";
        }
        userService.create(form);
        return "redirect:/users";
    }

    @GetMapping("/users")
    public String list(Model model) {
        model.addAttribute("users", userService.users());
        return "users/list";
    }
}

@Valid @ModelAttribute("userForm") UserForm formの直後にBindingResultを置きます。たとえば表示用のModel引数を追加するときも、Formと結果の間へ挟まず、その後ろへ置きます。これは結果をどの引数に対応させるかの約束です。Formに@NotBlankがあることと、Controllerが検証結果を受け取って画面に戻せることは同じではありません。

getFieldErrors("name")が空なら、テンプレートより先に、受け取ったFormと検証条件を照合します。例えば@NotNullは空文字を拒否しないため、HTMLからname=が送られた場合は必須エラーになりません。このサンプルは空文字と空白だけの入力を拒否する仕様なので@NotBlankを使います。51文字のときは名前がないというメッセージではなく、@Sizeの文言が必要です。Formに値が入っていないと思ったら、ブラウザの送信データの名前と、Formのプロパティ名・setterも確認します。

Formクラス UserForm.java の全文を開く
package info.syachiku.validation;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public class UserForm {
    @NotBlank(message = "{user.name.required}")
    @Size(max = 50, message = "{user.name.max}")
    private String name;

    @Email(message = "{user.email.invalid}")
    private String email;

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
}

制約を付けたのにエラーが0件なら、@Validの対象と依存関係を確認します。このプロジェクトにはspring-boot-starter-validationがあり、制約を実行する実装を含みます。画面に空文字が見えたという観測だけでなく、POST後のFormの値とフィールド名を見て、@NotBlankの期待と対応させます。

関連記事BindingResultの使い方と注意点|@Validの直後に置く理由 BindingResultがどの入力オブジェクトに対応するか、@Validの直後に置く理由から確認できます。 記事を読む

エラーが1件あるなら、th:errorsをnameへ揃える

故障版では、名前の欄の下でth:ifがnameのエラーを確認している一方、th:errorsはemailの文言を取りに行っています。メールにはエラーがありません。条件式がtrueになることと、出力する文言が見つかることは別です。テストで比較した故障版と修正版の差は次の一行です。

故障版:nameのエラーがあるときにemailの文言を読んでいる。

  <p id="name-error" th:if="${#fields.hasErrors('name')}" th:errors="*{email}"></p>

修正版:表示する対象もnameに揃える。

  <p id="name-error" th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></p>

修正版でnameに揃えると、同じPOSTに対して<p id="name-error">名前を入力してください。</p>が返ります。メールの値を取り直したり、@NotBlankのメッセージを変更したりする必要はありません。比較用テンプレートは修正版からこの属性だけ変えたもので、テストはソースの差も確認します。

実際に起動する修正版 input.html の全文を開く
<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>ユーザー登録</title></head>
<body>
<h1>ユーザー登録</h1>
<form th:action="@{/users}" th:object="${userForm}" method="post">
  <label for="name">名前</label>
  <input type="text" th:field="*{name}">
  <p id="name-error" th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></p>
  <label for="email">メール(任意)</label>
  <input type="email" th:field="*{email}">
  <p id="email-error" th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></p>
  <button type="submit">登録</button>
</form>
</body>
</html>
テスト専用の故障版 input.html の全文を開く
<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>ユーザー登録</title></head>
<body>
<h1>ユーザー登録</h1>
<form th:action="@{/users}" th:object="${userForm}" method="post">
  <label for="name">名前</label>
  <input type="text" th:field="*{name}">
  <p id="name-error" th:if="${#fields.hasErrors('name')}" th:errors="*{email}"></p>
  <label for="email">メール(任意)</label>
  <input type="email" th:field="*{email}">
  <p id="email-error" th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></p>
  <button type="submit">登録</button>
</form>
</body>
</html>

th:object="${userForm}"は参照するFormを指定します。その中のth:field="*{name}"が名前の入力欄、th:errors="*{name}"が名前のエラー欄です。例のように実在する別項目を参照すると、例外が出ないまま文言が欠けるため見逃しやすくなります。逆にForm名そのものが異なる場合は、テンプレートが対応する値を取得できず描画エラーになる場合があります。「文言が出ない」と「画面自体を生成できない」は分けて確認します。

また、名前のようなフィールドエラーと、二項目間の矛盾などをForm全体へ付けるエラーは別です。BindingResultにエラーがあってもgetFieldErrors("name")が空なら、名前のエラー欄に出すべきか決める前に、どの対象へ登録されたエラーか確認します。すべてのエラーを一箇所へ集める方法もありますが、名前の下へ名前の文言を出す今回の要件では、対象の一行を合わせる方が原因と修正が対応します。

エラー欄はあるのに文言が違う場合は、キー解決を確認する

名前のエラーがあること、th:errorsが名前を参照することまで確認したら、次にgetFieldError("name").getDefaultMessage()で解決後の文言を見ます。このサンプルは@NotBlank(message = "{user.name.required}")と、src/main/resources/messages.propertiesのキーを対応させています。

user.name.required=名前を入力してください。
user.name.max=名前は50文字以内で入力してください。
user.email.invalid=メールアドレスの形式で入力してください。

設定ではメッセージファイル、Thymeleaf、Servlet応答の文字コードをUTF-8にしています。キーの文字列が解決されず残る場合は、ファイルの配置・basename・キーの綴りを確認します。解決後の日本語は正しいのに応答だけ崩れる場合は、HTTP応答のContent-Typeや文字コードを見ます。今回の故障版は名前の日本語メッセージ自体は作られているので、ファイルの文字コードを変えても名前の場所からメールを読む誤りは直りません。

ValidationMessages.propertiesはJakarta Validation側の既定バンドルです。ここで使うmessages.propertiesはSpring Bootが構成するMessageSource側のファイルです。既存案件に持ち込むなら、同じキーを複数のファイルに追加して動いたように見せる前に、実際に利用している解決経路と文言の管理場所を確認してください。

エラー時は同じ画面を返し、成功後だけリダイレクトする

エラー時にreturn "users/input"とすると、そのPOSTのFormとBindingResultを使ってHTMLを返します。入力したメールが残るのはth:fieldがそのFormの値を読めるからです。反対に、入力エラーでもreturn "redirect:/users/new"にすると、ブラウザは新しいGETを行います。GETのinputは新しいUserFormを作るので、Flash Attribute等で明示的に渡さない今回の条件では、元のエラーとメールが見えなくなります。

この違いは、テスト専用Controllerでも確認しています。名前を空欄にしたPOSTの時点ではフィールドエラーが1件ありますが、302の次にGET /users/newを行うと名前のメッセージはなく、メール欄も空です。テスト用のURLは/comparison/redirect/usersで、本番用の起動JARには入りません。実際の案件で入力値が消えたなら、テンプレートを直す前に開発者ツールのNetworkでPOSTの後に302とGETが発生していないかを見ます。

成功した場合は、UserService.createを1回呼んでから/usersへリダイレクトします。成功後の再読み込みが一覧のGETになるため、同じPOSTをブラウザの再読み込みで繰り返す事故を避けやすくなります。ただし、連打や同時実行まで防ぐ機構ではありません。このサンプルでは、エラー時は呼出し0回、正常時は1回、一覧のGETでは増えないところを確認します。

関連記事Thymeleafで入力エラー後に値が消える|Formとselectを保持する方法 氏名とエラー文は残るのに部署を選べないとき、同じPOSTの再表示に候補一覧を戻す修正を試せます。 記事を読む

MockMvcでモデル・HTML・保存の3つを確認する

model().attributeHasFieldErrors("userForm", "name")だけを確認すると、故障版でもテストは通ります。HTMLに文言が出ない不具合を捕まえるには、同じPOSTの応答からname-error要素と日本語文言を確認します。入力保持もvalueの出力で確認し、Serviceの呼出し回数とメモリ上の登録結果を別に見ます。

    @Test
    void blankNameShowsJapaneseMessageAndKeepsEmailWithoutSaving() throws Exception {
        MvcResult result = submitInvalidName("");
        assertThat(html(result)).contains("<p id=\"name-error\">" + REQUIRED + "</p>")
                .contains("name=\"email\" value=\"" + EMAIL + "\"");
        assertNoRegistration();
    }

比較テストは通常のSpring MVC構成とThymeleafを起動し、比較用テンプレートの参照先だけを変更します。Form、Controller、依存、送信パラメーターを固定し、キャッシュを消して同じPOSTを送ります。最後はfinallyで元の参照先に戻すため、後続ケースは通常のテンプレートを使います。参照先を切り替える実験を並列に走らせないよう、テストクラスでは同じスレッドで実行します。

ケース確かめる結果
初期表示200、空の名前欄あり、エラー要素なし
名前が空名前エラー1件、日本語文言とメール保持、保存なし
名前が空白だけ必須エラー、保存なし
名前が51文字長さの文言が出る、必須の文言は出ない、保存なし
メールが不正メールエラー1件、名前保持、保存なし
正常送信302、Service呼出し1回と登録内容、一覧GETで増えない
故障版と修正版両方に名前エラー1件、文言は修正版だけ、保存なし
エラー時に誤ってredirectPOSTには名前エラー、次のGETには文言・メールなし
MockMvcテスト UserFormHtmlTest.java の全文を開く
package info.syachiku.validation;

import java.nio.charset.StandardCharsets;
import java.util.Locale;
import jakarta.validation.Valid;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestComponent;
import org.springframework.context.annotation.Import;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Controller;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.thymeleaf.spring6.templateresolver.SpringResourceTemplateResolver;

import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest(classes = ValidationApplication.class)
@AutoConfigureMockMvc
@Import(UserFormHtmlTest.RedirectOnErrorController.class)
@Execution(ExecutionMode.SAME_THREAD)
class UserFormHtmlTest {
    private static final String EMAIL = "test@example.com";
    private static final String REQUIRED = "名前を入力してください。";
    @Autowired MockMvc mockMvc;
    @Autowired UserService userService;
    @Autowired SpringTemplateEngine templateEngine;
    @Autowired @Qualifier("defaultTemplateResolver")
    SpringResourceTemplateResolver templateResolver;

    @BeforeEach
    void resetInMemoryService() { userService.reset(); }

    @Test
    void initialGetDisplaysFormWithoutErrors() throws Exception {
        mockMvc.perform(get("/users/new").locale(Locale.JAPANESE))
                .andExpect(status().isOk())
                .andExpect(view().name("users/input"))
                .andExpect(model().attributeExists("userForm"))
                .andExpect(content().string(containsString("name=\"name\" value=\"\"")))
                .andExpect(content().string(not(containsString("id=\"name-error\""))));
        assertNoRegistration();
    }

    @Test
    void blankNameShowsJapaneseMessageAndKeepsEmailWithoutSaving() throws Exception {
        MvcResult result = submitInvalidName("");
        assertThat(html(result)).contains("<p id=\"name-error\">" + REQUIRED + "</p>")
                .contains("name=\"email\" value=\"" + EMAIL + "\"");
        assertNoRegistration();
    }

    @Test
    void whitespaceOnlyNameAlsoFailsNotBlank() throws Exception {
        MvcResult result = submitInvalidName("   ");
        assertThat(html(result)).contains(REQUIRED);
        assertNoRegistration();
    }

    @Test
    void tooLongNameShowsLengthMessageInsteadOfRequiredMessage() throws Exception {
        MvcResult result = submitInvalidName("a".repeat(51));
        assertThat(html(result)).contains("名前は50文字以内で入力してください。")
                .doesNotContain(REQUIRED);
        assertNoRegistration();
    }

    @Test
    void invalidEmailShowsItsOwnErrorAndKeepsName() throws Exception {
        mockMvc.perform(post("/users").locale(Locale.JAPANESE)
                        .param("name", "山田太郎").param("email", "invalid"))
                .andExpect(status().isOk())
                .andExpect(view().name("users/input"))
                .andExpect(model().attributeHasFieldErrorCode("userForm", "email", "Email"))
                .andExpect(model().errorCount(1))
                .andExpect(content().string(containsString(
                        "<p id=\"email-error\">メールアドレスの形式で入力してください。</p>")))
                .andExpect(content().string(containsString("name=\"name\" value=\"山田太郎\"")));
        assertNoRegistration();
    }

    @Test
    void validPostCallsServiceOnceAndRedirectsToList() throws Exception {
        mockMvc.perform(post("/users").locale(Locale.JAPANESE)
                        .param("name", "山田太郎").param("email", EMAIL))
                .andExpect(status().isFound())
                .andExpect(redirectedUrl("/users"));
        assertThat(userService.createCalls()).isEqualTo(1);
        assertThat(userService.users())
                .containsExactly(new UserService.RegisteredUser("山田太郎", EMAIL));
        mockMvc.perform(get("/users").locale(Locale.JAPANESE))
                .andExpect(status().isOk())
                .andExpect(content().string(containsString("<li>山田太郎</li>")));
        assertThat(userService.createCalls()).isEqualTo(1);
    }

    @Test
    void brokenAndFixedTemplatesHaveSameFieldErrorButDifferentHtml() throws Exception {
        String broken = resource("comparison/broken/users/input.html");
        String fixed = resource("comparison/fixed/users/input.html");
        assertThat(fixed).isEqualTo(resource("templates/users/input.html"));
        assertThat(broken).isEqualTo(fixed.replace("th:errors=\"*{name}\"", "th:errors=\"*{email}\""));

        String originalPrefix = templateResolver.getPrefix();
        try {
            templateResolver.setPrefix("classpath:/comparison/broken/");
            templateEngine.clearTemplateCache();
            MvcResult before = submitInvalidName("");

            templateResolver.setPrefix("classpath:/comparison/fixed/");
            templateEngine.clearTemplateCache();
            MvcResult after = submitInvalidName("");

            assertThat(html(before)).doesNotContain("id=\"name-error\"").doesNotContain(REQUIRED);
            assertThat(html(after)).contains("<p id=\"name-error\">" + REQUIRED + "</p>");
            assertThat(html(before)).contains("name=\"email\" value=\"" + EMAIL + "\"");
            assertThat(html(after)).contains("name=\"email\" value=\"" + EMAIL + "\"");
            assertNoRegistration();
        } finally {
            templateResolver.setPrefix(originalPrefix);
            templateEngine.clearTemplateCache();
        }
    }

    @Test
    void redirectWithoutFlashLosesFieldErrorAndInputOnNextGet() throws Exception {
        mockMvc.perform(post("/comparison/redirect/users").locale(Locale.JAPANESE)
                        .param("name", "").param("email", EMAIL))
                .andExpect(status().isFound())
                .andExpect(redirectedUrl("/users/new"));
        mockMvc.perform(get("/users/new").locale(Locale.JAPANESE))
                .andExpect(status().isOk())
                .andExpect(view().name("users/input"))
                .andExpect(content().string(not(containsString(REQUIRED))))
                .andExpect(content().string(containsString("name=\"email\" value=\"\"")));
        assertNoRegistration();
    }

    private MvcResult submitInvalidName(String name) throws Exception {
        return mockMvc.perform(post("/users").locale(Locale.JAPANESE)
                        .param("name", name).param("email", EMAIL))
                .andExpect(status().isOk())
                .andExpect(view().name("users/input"))
                .andExpect(model().attributeHasFieldErrors("userForm", "name"))
                .andExpect(model().errorCount(1))
                .andReturn();
    }

    private void assertNoRegistration() {
        assertThat(userService.createCalls()).isZero();
        assertThat(userService.users()).isEmpty();
    }

    private static String resource(String path) throws Exception {
        return new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
    }

    private static String html(MvcResult result) throws Exception {
        return result.getResponse().getContentAsString(StandardCharsets.UTF_8);
    }

    @TestComponent
    @Controller
    static class RedirectOnErrorController {
        @PostMapping("/comparison/redirect/users")
        String redirect(@Valid @ModelAttribute("userForm") UserForm form,
                        BindingResult result) {
            assertThat(result.getFieldErrorCount("name")).isEqualTo(1);
            return "redirect:/users/new";
        }
    }
}

配布ZIPの展開先で、mvn "-Dtest=UserFormHtmlTest#blankNameShowsJapaneseMessageAndKeepsEmailWithoutSaving" testを実行します。名前が空欄のPOSTに対して、HTTP 200、モデルのnameエラー1件、HTMLの名前欄直下の文言、メールの保持、登録0件を確かめるテストです。修正版のままなら1件成功します。

故障時と比べるには、展開先のsrc/main/resources/templates/users/input.htmlを開き、id="name-error"の行にあるth:errors="*{name}"だけをth:errors="*{email}"へ変えます。th:ifはそのままにして、同じコマンドを実行してください。ステータスとモデルの検証を通過したあと、HTMLに<p id="name-error">名前を入力してください。</p>がないため失敗します。属性を元に戻してmvn testを実行すると、8件が成功します。入力と期待結果はサンプルの仕様表にもあります。

テスト対象のUserServiceは呼出し回数を記録するメモリ実装です。検証後のService呼出しを観測できますが、実DBでのINSERTやロールバックを確認したことにはなりません。ここでは保存先を構築するより、異常時にそもそも保存処理へ進まないことを短いサンプルで示す方が、画面修正の影響を追いやすくなります。

UserService.java の全文を開く
package info.syachiku.validation;

import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;

@Service
public class UserService {
    public record RegisteredUser(String name, String email) {}
    private final List<RegisteredUser> users = new ArrayList<>();
    private int createCalls;

    public synchronized void create(UserForm form) {
        createCalls++;
        users.add(new RegisteredUser(form.getName(), form.getEmail()));
    }
    public synchronized List<RegisteredUser> users() { return List.copyOf(users); }
    public synchronized int createCalls() { return createCalls; }
    public synchronized void reset() { users.clear(); createCalls = 0; }
}

ZIPを展開して、テストと実画面を試す

ZIPを展開し、pom.xmlがあるフォルダで次を実行します。mvn testはMockMvcの8件、mvn packageはテストと起動用JARの作成です。Mavenの依存が手元になければ、初回は取得できるネットワークが必要です。

mvn -B test
mvn -B package
java -jar target/validation-message-display-1.0.0.jar

起動後、ブラウザでhttp://127.0.0.1:8080/users/newを開きます。名前を空欄にし、メールへtest@example.comを入力して登録を押します。名前の直下に文言が出て、メールは残るはずです。続けて名前へ「山田太郎」を入れて送信すると一覧に移ります。名前を空欄にした送信が登録されていないため、一覧には正常送信した1件だけが出ます。ポート8080を使用中なら、JARの実行に--server.port=18775を追加できます。

検証環境はJava 17.0.8、Maven 3.9.5、Spring Boot 3.5.16、Thymeleaf 3.1.5.RELEASE、Hibernate Validator 8.0.3.Final、JUnit 5.12.2です。MockMvcの8件は失敗・エラー・スキップが0でした。起動したJARへのローカルHTTPでも、初期表示200、空欄POSTの200と日本語の文言・メール保持、正常POSTの302、一覧に登録1件を確認しました。実行版はこの組み合わせで固定しており、既存案件のバージョンを置き換える手順ではありません。

起動クラス ValidationApplication.java の全文を開く
package info.syachiku.validation;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ValidationApplication {
    public static void main(String[] args) {
        SpringApplication.run(ValidationApplication.class, args);
    }
}
起動・文字コード設定 application.properties の全文を開く
spring.messages.encoding=UTF-8
spring.thymeleaf.encoding=UTF-8
server.servlet.encoding.charset=UTF-8
server.servlet.encoding.force=true

server.address=127.0.0.1
登録後の一覧テンプレート list.html の全文を開く
<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>登録済みユーザー</title></head>
<body>
<h1>登録済みユーザー</h1>
<ul><li th:each="user : ${users}" th:text="${user.name}"></li></ul>
<a th:href="@{/users/new}">入力へ戻る</a>
</body>
</html>
依存関係とビルド設定 pom.xml の全文を開く
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.5.16</version><relativePath/></parent>
  <groupId>info.syachiku.samples</groupId><artifactId>validation-message-display</artifactId><version>1.0.0</version>
  <properties><java.version>17</java.version><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding><junit-jupiter.version>5.12.2</junit-jupiter.version></properties>
  <dependencies>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-thymeleaf</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope>
      <exclusions>
        <exclusion><groupId>org.mockito</groupId><artifactId>mockito-core</artifactId></exclusion>
        <exclusion><groupId>org.mockito</groupId><artifactId>mockito-junit-jupiter</artifactId></exclusion>
      </exclusions>
    </dependency>
  </dependencies>
  <build><plugins>
    <plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin>
    <plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.6</version></plugin>
  </plugins></build>
</project>

MockMvcでは実際にレンダリングされたHTMLを検証しますが、CSSによる非表示やJavaScriptの書き換えは対象外です。既存の画面でレスポンスHTMLに文言があるのに利用者には見えない場合、次の調査対象はブラウザ上の表示です。Spring Securityを含む案件では、403などでControllerまで届かない症状も分け、認証・CSRFを含めたテストを用意します。この配布例には認証、DB、JavaScriptを含めていません。

レビューでは「エラーがある」だけで表示済みにしない

レビューコメント例:「BindingResultでnameのエラー1件を確認しました。このassertionは故障テンプレートでも通ります。名前欄の下の文言、メール入力値、保存呼出し0回を、同じPOSTのテストに追加してください。」

修正を返すときは、「表示確認しました」だけでなく、name=""と正常なメールを送ったこと、故障時と修正後で名前のエラー件数は同じこと、違いはth:errorsの参照先と返されたHTMLであることを添えます。要件は名前を入力し直せることです。制約やメッセージファイルの追加変更を避けた理由まで伝えると、次の担当者も判断を追えます。

  • POSTがControllerへ届き、BindingResultで対象項目のエラー件数を確認したか。
  • Form名と、入力欄・エラー条件・表示先の項目名を照合したか。
  • 入力エラー時は同じFormで画面を返し、正しい欄の文言と他の入力値をHTMLで確認したか。
  • 失敗時は保存に進まず、成功時だけ登録とリダイレクトが行われるか。
  • テストで確かめたHTMLと、ブラウザ表示やDBなど未確認の範囲を区別したか。

フォーム送信から入力エラーの表示までを通して作りたい場合に。この記事の不具合を直した後、画面・Controller・Formのつながりを練習できます。

書籍「作って学ぶ Spring Boot入門」の表紙
商品画像:Amazon.co.jp
PR Java新人・若手向け

作って学ぶ Spring Boot入門

多田 真敏 (著)

Spring Bootを、動くWebアプリを作りながら覚える。

Controller、画面、DB連携などを一つのアプリとして実装できます。Java基礎からSpringの現場へ進む橋渡しに向く一冊です。

  • Spring Bootを手を動かして学びたい
  • Webアプリ開発の流れをつかみたい

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

関連記事テストケースの作り方|仕様・境界値・期待結果をJUnitと対応させる実例 件名や権限の仕様から、入力・事前条件・操作・期待結果をJUnitのケースへ対応させます。 記事を読む

名前のエラーがあるなら、文言を読む先と応答を追う

最初にBindingResultの対象項目を確認します。エラーがなければ送信値・Form・検証を確認し、あるなら同じFormを返しているか、th:errorsがその項目を読んでいるかを確認します。修正の合否はエラー件数だけで決めず、正しい場所の文言、入力保持、保存されないことを同じケースで確かめます。

仕様の参照先:Spring Framework 6.2:@ModelAttribute引数とBindingResult、Thymeleaf 3.1:Field errors。

-実装・エラー解決
-, ,