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


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ステータス | 200 | 200 |
| BindingResult | nameのエラー1件 | nameのエラー1件 |
| 返されたHTML | name-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の説明でも確認できます。
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 | エラー | エラー | エラー |
アノテーションの選び方が違うと、表示以前にエラーが作られません。「必須」の定義を画面仕様と照合してください。
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で引き継ぐ方法もありますが、まずは単純な構成を優先します。
表示されない時の確認順
- BindingResultにフィールドエラーが入っているか確認する
- @Validの対象直後にBindingResultがあるか確認する
- 制約が入力仕様に合っているか確認する
- Controllerのmodel属性名とth:objectを合わせる
- th:fieldとth:errorsの項目名を合わせる
- フィールドエラーかグローバルエラーかを確認する
- 入力エラー時にredirectしていないか確認する
- メッセージキー、ファイル配置、文字コードを確認する
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/new | 200。初期表示の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入門
Spring Bootを、動くWebアプリを作りながら覚える。
Controller、画面、DB連携などを一つのアプリとして実装できます。Java基礎からSpringの現場へ進む橋渡しに向く一冊です。
- Spring Bootを手を動かして学びたい
- Webアプリ開発の流れをつかみたい
当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。
この記事とあわせて読みたい
まとめ
バリデーションメッセージが表示されないときは、最初にBindingResultへエラーが入っているか確認します。エラーがなければ制約とValidation、エラーがあればThymeleafと属性名を中心に調べます。
Form名、項目名、th:object、th:errors、メッセージキーを揃え、入力エラー時はredirectせず同じ画面を返します。確認順を固定すれば、アノテーションを増やすだけの場当たり的な修正を避けられます。





