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

実装・エラー解決

Spring Bootでバリデーションメッセージが表示されない|Thymeleafの原因と修正

AIによる要約

Spring Bootでバリデーションメッセージが表示されないときは、まずBindingResultのエラーを確認します。nameにエラーがあっても、Thymeleafのth:errorsが正常なemailを参照すれば、名前の文言は表示されません。この記事では同じ空欄入力で故障版と修正版を比べ、項目名を直すだけで日本語表示が戻ることをMockMvcで確認します。

新人SE
新人SE
空欄で登録すると同じ画面へ戻るのですが、「名前を入力してください」が表示されません。@NotBlankは付いています。
ポンコツSE
ポンコツSE
入力チェック自体が動いていないのか、エラーはあるがThymeleafが表示できていないのかを先に分けましょう。

Spring BootとThymeleafの入力画面では、Formに@NotBlankを付けただけではメッセージは自動表示されません。Controllerで検証し、BindingResultを保持したまま画面へ戻し、HTML側で対象エラーを表示する必要があります。

名前を空欄、メールを正常値にして登録すると、入力画面には戻るのに名前のエラー文だけ出ない。この症状を、Controllerや制約を変えずテンプレートの1行だけで再現します。その後に確認順をたどれば、「検証」「受け渡し」「表示」「文言解決」のどこで止まったかを絞れます。

この記事のポイント

  • 最初にBindingResult#hasErrors()を確認する
  • @Valid Formの直後にBindingResultを置く
  • model属性名、th:object、th:field、th:errorsを一致させる
  • redirectするとBindingResultが通常のリクエストをまたげない
  • メッセージキーとpropertiesの配置・文字コードを確認する

まずBindingResultにエラーがあるか確認する

画面表示を直す前に、Controllerで入力チェック結果を確認します。

@PostMapping("/users")
public String create(
        @Valid @ModelAttribute("userForm") UserForm form,
        BindingResult bindingResult) {

    if (bindingResult.hasErrors()) {
        return "users/input";
    }

    userService.create(form);
    return "redirect:/users";
}

デバッガーでbindingResult.getFieldErrors()を確認し、nameなどのエラーが入っているならValidationは動いています。その場合、原因はテンプレートや属性名の可能性が高くなります。

エラーが0件なら、@Validが付いているか、対象Formに制約が付いているか、依存関係にValidationが含まれているか、送信値が本当に空かを確認します。

同じ入力で再現:nameにエラーがあるのに表示されない

この比較では、後半に示すUserFormとPOST /usersのControllerを使います。送る値はname=""とemail="test@example.com"で固定します。名前には@NotBlankのエラーが1件、メールにはエラーがない状態です。

Before:名前の場所でemailのエラーを読んでいる

故障版はsrc/main/resources/comparison/broken/users/input.htmlへ保存します。th:ifはnameのエラーを見ていますが、th:errorsだけがemailを参照しています。emailは実在する正常な項目なので、ここから名前の文言は取得できません。

<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>入力</title></head>
<body>
<form th:action="@{/users}" th:object="${userForm}" method="post">
  <input type="text" th:field="*{name}">
  <p id="name-error" th:if="${#fields.hasErrors('name')}" th:errors="*{email}"></p>
  <input type="email" th:field="*{email}">
  <button type="submit">登録</button>
</form>
</body>
</html>

After:th:errorsをnameへ揃える

修正版はsrc/main/resources/comparison/fixed/users/input.htmlへ保存します。違いはth:errors="*{email}"をth:errors="*{name}"へ直す1箇所だけです。Form、Controller、メッセージファイル、送信値は変えません。

<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>入力</title></head>
<body>
<form th:action="@{/users}" th:object="${userForm}" method="post">
  <input type="text" th:field="*{name}">
  <p id="name-error" th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></p>
  <input type="email" th:field="*{email}">
  <button type="submit">登録</button>
</form>
</body>
</html>
同じPOSTの確認点故障版修正版
HTTPステータス200200
BindingResultnameのエラー1件nameのエラー1件
返されたHTMLname-error要素も日本語文言もないname-error要素に「名前を入力してください。」
メール入力値test@example.comを保持test@example.comを保持

この例では、エラー作成と入力値の保持は最初から動いています。変えるべきなのは名前のエラーを読む場所です。model().attributeHasFieldErrors(...)だけなら故障版でも成功するため、HTMLの文言まで確認して初めて表示の退行を検出できます。

項目ごとのエラーを調べる#fields.hasErrorsと、指定した項目の文言を表示するth:errorsの役割は、Thymeleaf公式のField errorsでも説明されています。実在しない項目名の誤記は別のエラーになる場合があるため、ここでは実在するemailへの参照違いだけを比較しています。

BindingResultは@Validの対象直後に置く

// OK
public String create(
        @Valid @ModelAttribute("userForm") UserForm form,
        BindingResult bindingResult,
        Model model) {
    // ...
}

// NG: 別の引数を間へ挟まない
public String create(
        @Valid @ModelAttribute("userForm") UserForm form,
        Model model,
        BindingResult bindingResult) {
    // ...
}

BindingResultは、直前の検証対象に対応します。FormとBindingResultの間へModelなどを挟むと、意図した検証結果を受け取れず、例外や想定外の動作につながります。

引数を隣に置いてController内で結果を扱う形は、Spring Framework 6.2の@ModelAttributeの説明でも確認できます。

関連記事BindingResultの使い方と注意点|@Validの直後に置く理由 検証結果の受け取り方で迷うときに。@Validの直後へ置く理由と、入力画面へエラーを戻す流れを確認できます。 記事を読む

Formクラスと制約を確認する

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;

    // getter / setter
}

空文字を拒否したいStringには、通常@NotNullだけでは足りません。@NotNullはnullだけを拒否し、空文字は許可します。前後空白だけの入力も拒否したいなら@NotBlankを使います。

制約null空文字空白だけ
@NotNullエラー許可許可
@NotEmptyエラーエラー許可
@NotBlankエラーエラーエラー

アノテーションの選び方が違うと、表示以前にエラーが作られません。「必須」の定義を画面仕様と照合してください。

関連記事Spring Bootの@Validと@Validatedの違い|必須チェックが効かない原因と使い分け BindingResultにエラーがない場合に。@Validと@Validatedの使い分け、グループ指定で共通の必須チェックが外れる原因を確認できます。 記事を読む

th:objectとmodel属性名を一致させる

ControllerがuserFormという名前でFormを渡すなら、Thymeleafも同じ名前を参照します。

<form th:action="@{/users}"
      th:object="${userForm}"
      method="post">
  <input type="text" th:field="*{name}">
  <p id="name-error" th:if="${#fields.hasErrors('name')}"
     th:errors="*{name}"></p>
  <button type="submit">登録</button>
</form>

th:object="${userForm}"を指定した内側では、*{name}がuserForm.nameを表します。Controller側が"form"、HTML側が"userForm"では一致しません。

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

初期表示でも同じ属性名を用意します。POSTエラー時だけ表示でき、GET初期表示でテンプレートエラーになる場合は、GET側のForm追加漏れを疑います。

th:errorsの項目名を確認する

<!-- フィールド単位 -->
<p th:if="${#fields.hasErrors('name')}"
   th:errors="*{name}"></p>

<!-- Form全体に付けたグローバルエラー -->
<div th:if="${#fields.hasGlobalErrors()}">
  <p th:each="error : ${#fields.globalErrors()}"
     th:text="${error}"></p>
</div>

<!-- すべてのエラーをまとめて確認 -->
<ul th:if="${#fields.hasAnyErrors()}">
  <li th:each="error : ${#fields.allErrors()}"
      th:text="${error}"></li>
</ul>

nameのエラーをemailの場所へ出そうとしても表示されません。また、パスワード確認のような複数項目チェックをグローバルエラーとして登録した場合、th:errors="*{name}"では拾えません。

messages.propertiesのキーを確認する

先ほどのFormでmessage = "{user.name.required}"と書いた場合、メッセージファイルへ対応するキーを用意します。

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

キーが見つからない場合、{user.name.required}のような文字がそのまま出たり、既定メッセージへフォールバックしたりします。ファイル名、配置場所、キーの綴りを確認します。

messages.propertiesとValidationMessages.propertiesを混同しない

Springの画面文言は通常MessageSourceのmessages.propertiesを使います。Jakarta Validation単体の既定バンドルはValidationMessages.propertiesです。Spring Boot側の設定やバージョンで解決経路が変わるため、既存プロジェクトがどのファイルへメッセージを集約しているかに揃えてください。

日本語が文字化けする場合は、IDE、ビルド、実行環境でpropertiesの文字コード設定を確認します。Spring Bootの現行構成ではUTF-8で扱えることが多いですが、古い案件や独自MessageSource設定ではISO-8859-1前提が残っていることがあります。

メッセージをアノテーションへ直書きするかキー化するか

// 小規模・一時的には分かりやすい
@NotBlank(message = "名前を入力してください。")
private String name;

// 再利用・文言統一・多言語化を考えるならキー化
@NotBlank(message = "{user.name.required}")
private String name;

直書きなら原因を追いやすい一方、同じ文言が複数Formへ散ります。業務システムでは文言レビューや表記統一があるため、プロジェクトがキー管理しているならそれに合わせます。キー化自体が目的になり、意味の分からない連番キーを増やすのは避けます。

redirectするとエラー情報が消える

@PostMapping("/users")
public String create(
        @Valid @ModelAttribute("userForm") UserForm form,
        BindingResult bindingResult) {

    if (bindingResult.hasErrors()) {
        return "redirect:/users/new"; // エラー情報を通常は引き継げない
    }

    return "users/input"; // 入力エラー時は同じリクエストで画面を返す
}

上のコメントは意図を示すための比較で、実際にはif内をreturn "users/input";にします。リダイレクトは新しいリクエストになるため、BindingResultと入力値をそのまま引き継げません。

登録成功後は二重送信を防ぐためredirect、入力エラー時は同じ画面名をreturnする、という使い分けが基本です。Flash Attributeで引き継ぐ方法もありますが、まずは単純な構成を優先します。

表示されない時の確認順

  1. BindingResultにフィールドエラーが入っているか確認する
  2. @Validの対象直後にBindingResultがあるか確認する
  3. 制約が入力仕様に合っているか確認する
  4. Controllerのmodel属性名とth:objectを合わせる
  5. th:fieldとth:errorsの項目名を合わせる
  6. フィールドエラーかグローバルエラーかを確認する
  7. 入力エラー時にredirectしていないか確認する
  8. メッセージキー、ファイル配置、文字コードを確認する

BindingResultにエラーがあるかどうかで、調査対象をJava側とHTML側へ大きく分けられます。

MockMvcでThymeleafのHTMLにメッセージが出るまで確認する

model().attributeHasFieldErrors(...)が成功しても、th:errorsの間違いやHTMLへの表示までは保証できません。この記事の症状を再発させないため、モデルのエラーと、実際にレンダリングされたHTMLの両方を確認します。

検証環境はJava 17.0.8、Spring Boot 3.5.16、Thymeleaf 3.1.5.RELEASE、Hibernate Validator 8.0.1.Finalです。spring-boot-starter-web、Thymeleaf、Validation実装、spring-boot-starter-testを含む構成で確認しました。Spring Securityを導入していない構成です。導入済みなら、テストの認証とCSRFの設定もプロジェクトへ合わせてください。

HTMLはsrc/main/resources/templates/users/input.htmlに保存し、前のフォーム例のid="name-error"を使います。messages.propertiesも読み込まれるよう、Spring Bootのアプリケーション設定を起動するテストにします。standaloneSetupでモデルだけを確認するテストとは条件が異なります。

import java.util.Locale;
import org.junit.jupiter.api.Test;
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.test.web.servlet.MockMvc;

import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureMockMvc
class UserFormHtmlTest {
    @Autowired
    MockMvc mockMvc;

    @Test
    void 空の名前ならHTMLにエラーメッセージを表示する() throws Exception {
        mockMvc.perform(post("/users")
                        .locale(Locale.JAPANESE)
                        .param("name", "")
                        .param("email", "test@example.com"))
                .andExpect(status().isOk())
                .andExpect(view().name("users/input"))
                .andExpect(model().attributeHasFieldErrors("userForm", "name"))
                .andExpect(content().string(containsString(
                        "<p id=\"name-error\">名前を入力してください。</p>")));
    }
}
確認した操作実行結果
GET /users/new200。初期表示のHTMLにname-error要素はない
POST /users:名前が空、メールは正常200。nameのフィールドエラーと、日本語のエラー要素をHTMLで確認。メール入力値も保持
POST /users:名前・メールが正常/usersへリダイレクト

このテストは「メッセージが表示されない」を検出する目的なので、重要な1文を確認しています。すべての文言を固定する必要はありません。文言を変更するときは、その画面の表示仕様とテストも一緒に更新します。HTMLを生成できても、CSSで非表示になっている場合は別なので、最後にブラウザでエラーが読めることも確認してください。

故障版と修正版を同じPOSTで比べる

上の2つのHTMLを保存したら、次のテストを同じSpring Bootプロジェクトへ追加します。ここでは、実行確認済みの基礎サンプル01-validation-basicsと同じinfo.syachiku.blog.VerificationApplicationを使います。自分のプロジェクトへ移す場合は、パッケージとアプリケーションクラス名を合わせてください。

テスト内だけでテンプレートの参照先を切り替え、キャッシュをクリアして同じPOST /usersを2回実行します。最後に元の参照先へ戻します。HTTPステータスとモデルの確認は両方で成功し、日本語の表示だけが変わることを確認できます。

package info.syachiku.blog;

import java.nio.charset.StandardCharsets;
import java.util.Locale;
import org.junit.jupiter.api.Test;
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.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.thymeleaf.spring6.templateresolver.SpringResourceTemplateResolver;

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

@SpringBootTest(classes = VerificationApplication.class)
@AutoConfigureMockMvc
class TemplateErrorComparisonTest {
    @Autowired MockMvc mockMvc;
    @Autowired SpringTemplateEngine templateEngine;
    @Autowired @Qualifier("defaultTemplateResolver")
    SpringResourceTemplateResolver templateResolver;

    @Test
    void 同じ入力でも参照する項目が違えばメッセージは表示されない() throws Exception {
        String originalPrefix = templateResolver.getPrefix();
        try {
            MvcResult broken = submitWithTemplate("classpath:/comparison/broken/");
            MvcResult fixed = submitWithTemplate("classpath:/comparison/fixed/");

            String before = broken.getResponse().getContentAsString(StandardCharsets.UTF_8);
            String after = fixed.getResponse().getContentAsString(StandardCharsets.UTF_8);
            assertThat(before).doesNotContain("名前を入力してください。");
            assertThat(after).contains("<p id=\"name-error\">名前を入力してください。</p>");
            assertThat(before).contains("name=\"email\" value=\"test@example.com\"");
            assertThat(after).contains("name=\"email\" value=\"test@example.com\"");
        } finally {
            templateResolver.setPrefix(originalPrefix);
            templateEngine.clearTemplateCache();
        }
    }

    private MvcResult submitWithTemplate(String prefix) throws Exception {
        templateResolver.setPrefix(prefix);
        templateEngine.clearTemplateCache();
        return mockMvc.perform(post("/users")
                        .locale(Locale.JAPANESE)
                        .param("name", "")
                        .param("email", "test@example.com"))
                .andExpect(status().isOk())
                .andExpect(view().name("users/input"))
                .andExpect(model().attributeHasFieldErrors("userForm", "name"))
                .andExpect(model().errorCount(1))
                .andReturn();
    }
}

Java 17.0.8と前述のSpring Boot 3.5.16の構成で、故障版の不表示、修正版の日本語表示、前後両方のメール入力値の保持を確認しました。既存の初期表示・空欄POST・正常POSTの3ケースも成功しています。HTMLが返るところまで通す範囲は、Spring公式のMockMvc概要を参照できます。CSSで読めるかはブラウザで別に確認します。

現場レビューでよくある指摘

// レビューコメント例
BindingResultにnameのエラーはありますが、テンプレートが*{userName}を参照しています。
Formのプロパティ名とth:errorsを揃えてください。

// レビューコメント例
入力エラー時にredirectしているため、BindingResultと入力値が失われます。
同じ入力画面をreturnする構成にしてください。

// レビューコメント例
必須項目へ@NotNullだけを付けていますが、空文字を拒否できません。
画面仕様が「空白不可」なら@NotBlankが適切か確認してください。

提出前のセルフチェック

  • BindingResultに期待するエラーが入ることを確認したか
  • FormとBindingResultを隣に置いたか
  • GETとPOSTで同じmodel属性名を使っているか
  • th:object、th:field、th:errorsの名前が一致しているか
  • フィールドエラーとグローバルエラーを表示できるか
  • 入力エラー時にredirectしていないか
  • メッセージキーとpropertiesの配置を確認したか
  • 日本語の文字化けがないか実画面で確認したか

Spring Bootの画面入力を体系的に学ぶ参考書

今回の表示エラーを直した後、Formへの入力からController、画面、DBまでを一つのアプリでつなげて学びたい人には、『作って学ぶ Spring Boot入門』が候補です。Javaの基本文法、HTML/CSS、基本SQLが前提になります。Javaを初めて学ぶ段階なら、先にその基礎を確かめてください。本を購入する前に、この記事の確認順でBindingResultと画面の項目名を見直せます。

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

作って学ぶ Spring Boot入門

多田 真敏 (著)

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

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

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

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

この記事とあわせて読みたい

関連記事Thymeleafで入力エラー後に値が消える|Formとselectを保持する方法 メッセージが出るようになった後に。入力値やselectの選択肢を保持して再表示し、実際のHTMLまで確認する方法を解説します。 記事を読む 関連記事Spring BootのMockMvcでPOSTが403・400になる原因|CSRF・Form・JSONの確認順 同じテストが403・400で止まる場合に。CSRF・認証・権限とController到達を分け、画面表示以前の失敗を切り分けます。 記事を読む 関連記事Spring Bootのフォーム入力・バリデーション実践ガイド|実装からテストまで 入力値の保持や業務エラーの返し方も確認したい人へ。フォーム改修の読む順と基礎サンプルをまとめています。今回の比較は、本文のHTML2枚とテストを基礎サンプルへ追加して試せます。 記事を読む

まとめ

バリデーションメッセージが表示されないときは、最初にBindingResultへエラーが入っているか確認します。エラーがなければ制約とValidation、エラーがあればThymeleafと属性名を中心に調べます。

Form名、項目名、th:object、th:errors、メッセージキーを揃え、入力エラー時はredirectせず同じ画面を返します。確認順を固定すれば、アノテーションを増やすだけの場当たり的な修正を避けられます。

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