Spring Boot のバリデーションを題材にしたテスト入門

はじめに

前回は、テストに必要なアノテーションやアサーションについて説明しました。

今回は境界値分析というテスト技法を使ったバリデーションのテストと、 コントローラー・アドバイスのテストを実施してみましょう。

Spring Initializr

これ

テスト対象のソースコード

ユーザー作成エンドポイントを想定した、空実装のコードと、バリデーションエラーをハンドリングする RestControllerAdvice を用意しました。

DTO

UserCreateRequest.java

リクエスト側には、アノテーションでバリデーションの制約を定義しています。

src/main/java/dev/mikoto2000/handson/springboot/test/firststep/user/dto/UserCreateRequest.java:

package dev.mikoto2000.handson.springboot.test.firststep.user.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@AllArgsConstructor
@Data
@NoArgsConstructor
public class UserCreateRequest {

    @NotBlank(message = "username は必須です")
    @Size(min = 3, max = 20, message = "username は 3〜20 文字で入力してください")
    private String username;

    @NotBlank(message = "email は必須です")
    @Email(message = "email の形式が不正です")
    private String email;

    @NotBlank(message = "password は必須です")
    @Size(min = 8, max = 100, message = "password は 8〜100 文字で入力してください")
    private String password;
}

UserCreateResponse.java

レスポンス側は特にバリデーションの制約は無いので、ただの DTO になっています。

src/main/java/dev/mikoto2000/handson/springboot/test/firststep/user/dto/UserCreateResponse.java:

package dev.mikoto2000.handson.springboot.test.firststep.user.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@AllArgsConstructor
@Data
@NoArgsConstructor
public class UserCreateResponse {

    private Long id;
    private String username;
    private String email;

}

Controller

今回は、バリデーションのテストの練習なので、サービス呼び出しなどはせず、適当にレスポンスを返すだけのコントローラーにしています。

src/main/java/dev/mikoto2000/handson/springboot/test/firststep/user/controller/UserController.java:

package dev.mikoto2000.handson.springboot.test.firststep.user.controller;

import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

import dev.mikoto2000.handson.springboot.test.firststep.user.dto.UserCreateRequest;
import dev.mikoto2000.handson.springboot.test.firststep.user.dto.UserCreateResponse;

@RestController
@RequestMapping("/users")
public class UserController {

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public UserCreateResponse create(@RequestBody @Valid UserCreateRequest request) {
        // 教材用なのでサービス呼び出しは省略、適当にレスポンスを組み立てて返却
        return new UserCreateResponse(1L, request.getUsername(), request.getEmail());
    }
}

RestControllerAdvice

全 RestController の例外をハンドリングする RestControllerAdvice クラスを用意しました。

DTO

エラーレスポンスの形式を定義する DTO クラスを用意しました。

src/main/java/dev/mikoto2000/handson/springboot/test/firststep/common/dto/ApiErrorResponse.java:

package dev.mikoto2000.handson.springboot.test.firststep.common.dto;

import java.util.List;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;


@AllArgsConstructor
@Data
@NoArgsConstructor
public class ApiErrorResponse {
    private String code;
    private String message;
    private List<ValidationErrorDetail> errors;
}

また、バリデーションエラーの詳細を表す DTO クラスも用意しました。

src/main/java/dev/mikoto2000/handson/springboot/test/firststep/common/dto/ValidationErrorDetail.java:

package dev.mikoto2000.handson.springboot.test.firststep.common.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@AllArgsConstructor
@Data
@NoArgsConstructor
public class ValidationErrorDetail {
    private String field;
    private String message;
}

Advice クラス

エラーを横断的にハンドリングする RestControllerAdvice クラスを用意しました。

src/main/java/dev/mikoto2000/handson/springboot/test/firststep/common/advice/ApiExceptionHandler.java:

package dev.mikoto2000.handson.springboot.test.firststep.common.advice;

import java.util.List;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import dev.mikoto2000.handson.springboot.test.firststep.common.dto.ApiErrorResponse;
import dev.mikoto2000.handson.springboot.test.firststep.common.dto.ValidationErrorDetail;

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiErrorResponse handleMethodArgumentNotValid(MethodArgumentNotValidException e) {
        List<ValidationErrorDetail> errors = e.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(e2 -> new ValidationErrorDetail(e2.getField(), e2.getDefaultMessage()))
                .toList();

        return new ApiErrorResponse(
                "VALIDATION_ERROR",
                "入力値が不正です",
                errors
        );
    }
}

動作確認

次のコマンドでアプリケーションを起動し、正常系と異常系のリクエストを送ってみましょう。

./mvnw spring-boot:run

正常系リクエスト:

curl -X POST \
    -H 'Content-Type: application/json' \
    http://localhost:8080/users \
    -d '{"username":"test", "email":"test@gmail.com", "password": "abcdefgh"}'

異常系リクエスト:

curl -X POST \
    -H 'Content-Type: application/json' \
    http://localhost:8080/users \
    -d '{"username":"", "email":"test@gmail.com", "password": "abcdefgh"}'

それぞれの HTTP ステータスコードとレスポンスの内容を確認してみましょう。

テストコード

DTO のバリデーションテスト

リクエストの DTO クラスのバリデーションを、Spring を起動せずに検証するテストコードを用意します。

バリデーターを直接呼び出すことで、DTO クラスのバリデーションの制約が期待通りに定義されているかを確認します。

src/test/java/dev/mikoto2000/handson/springboot/test/firststep/user/dto/UserCreateRequestTest.java:

package dev.mikoto2000.handson.springboot.test.firststep.user.dto;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.Set;
import java.util.stream.Stream;

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;

import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;

/**
 * DTO の Bean Validation を Spring を起動せずに検証するテスト。
 */
@DisplayName("UserCreateRequest の Bean Validation")
class UserCreateRequestTest {

  // バリデーションを実行するためのバリデーター
  private static Validator validator;

  // 全テストの前に一度だけ実行する処理
  @BeforeAll
  static void setUp() {
    // バリデーターを生成
    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    validator = factory.getValidator();
  }

  @Nested
  @DisplayName("正常系")
  class NormalCases {
    @Test
    void test妥当な値なら違反なし() {

      // Arrange
      UserCreateRequest request = new UserCreateRequest(
          "test",
          "test@example.com",
          "password123"
          );

      // Act
      Set<ConstraintViolation<UserCreateRequest>> violations = validator.validate(request);

      // Assert
      assertTrue(violations.isEmpty(), "違反は0件のはず");
    }
  }

  @Nested
  @DisplayName("異常系")
  class ErrorCases {

    // username の異常系は境界値でカバーできるので無し

    @Test
    void testEmailの形式が不正なら違反() {
      // Arrange:
      // email だけ不正にし、他の項目は妥当値に固定する
      UserCreateRequest request = new UserCreateRequest(
          "test",
          "abc",
          "password123"
          );

      // Act
      Set<ConstraintViolation<UserCreateRequest>> violations = validator.validate(request);

      // Assert 1:
      // 今回は email だけを不正にしているので、違反が返るはず
      assertFalse(violations.isEmpty(), "違反があるはず");

      // Assert 2:
      // email に対する違反であることを propertyPath で確認する
      boolean emailPathFound = violations.stream()
        .map(ConstraintViolation::getPropertyPath)
        .anyMatch(path -> path.toString().contains("email"));

      assertTrue(emailPathFound, "email の違反があるはず");
    }

    // password の異常系は境界値でカバーできるので無し
  }

  @Nested
  @DisplayName("境界値")
  class BoundaryCases {

    @ParameterizedTest(name = "[{index}] {0}: username長={1} → username違反={2}")
    @MethodSource("dev.mikoto2000.handson.springboot.test.firststep.user.dto.UserCreateRequestTest#usernameLengthBoundaryCases")
    void testUsernameの文字数境界(String caseName, int usernameLength, boolean expectedViolation) {
      // Arrange:
      // username だけを境界値として変化させる
      // email / password は常に妥当値に固定し、判定対象を username に絞る
      String username = "a".repeat(usernameLength);
      UserCreateRequest request = new UserCreateRequest(
          username,           // MethodSource により変化する
          "test@example.com", // 常に妥当
          "password123"       // 常に妥当
          );

      // Act: Bean Validation を実行
      Set<ConstraintViolation<UserCreateRequest>> violations = validator.validate(request);

      // Assert 1:
      // 今回は username だけを変化させているため、違反の有無をそのまま username の判定結果として扱える。
      boolean actualViolation = !violations.isEmpty();

      assertEquals(
          expectedViolation,
          actualViolation,
          "case=" + caseName + ", usernameLength=" + usernameLength + " の判定が期待と異なる"
          );

      // Assert 2:
      // propertyPath に username が含まれるかを確認する。
      boolean usernamePathFound = violations.stream()
        .map(ConstraintViolation::getPropertyPath)
        .anyMatch(path -> path.toString().contains("username"));

      assertEquals(
          expectedViolation,
          usernamePathFound,
          "case=" + caseName + ", usernameLength=" + usernameLength + " のパスが期待と異なる"
          );
    }
  }

  /**
   * username の境界値ケースを供給する。
   *
   * 引数の意味:
   *  1列目: ケース名(説明用)
   *  2列目: username の文字数
   *  3列目: username に違反が期待されるか(true=違反あり)
   *
   * 今回の制約:
   *  - username は 3〜20 文字
   */
  static Stream<Arguments> usernameLengthBoundaryCases() {
    return Stream.of(
        Arguments.of("最小値-1", 2, true),
        Arguments.of("最小値",   3, false),
        Arguments.of("最大値",   20, false),
        Arguments.of("最大値+1", 21, true)
        );
  }

  // TODO: 他の項目の境界値
}

Controller のテスト

コントローラーのテストコードを用意します。

MockMvc を使って、HTTP リクエストをシミュレートし、 コントローラーの入力バリデーションが期待通りに動作するかを確認します。

正常系のテストでは、妥当なリクエストを送って 201 が返ることと、レスポンスの内容が期待通りであることを確認します。

バリデーションエラー時の戻り値の形式などは、Advice のテストで確認することにし、 ここでは入力バリデーションエラー時に 400 が返ることを確認します。

src/test/java/dev/mikoto2000/handson/springboot/test/firststep/user/controller/UserControllerTest.java:

package dev.mikoto2000.handson.springboot.test.firststep.user.controller;

import static org.junit.jupiter.api.Assertions.*;


import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;

import dev.mikoto2000.handson.springboot.test.firststep.user.dto.UserCreateRequest;
import dev.mikoto2000.handson.springboot.test.firststep.user.dto.UserCreateResponse;
import tools.jackson.databind.json.JsonMapper;

/**
 * UserController の入力バリデーションを HTTP 層で検証するテスト。
 *
 * DTO 単体テストでは制約そのものを確認し、
 * ここでは @RequestBody + @Valid により想定した HTTP ステータスコードが返ることを確認する。
 * エラーレスポンスの形式は ApiExceptionHandlerTest で確認する。
 */
@WebMvcTest(UserController.class)
class UserControllerTest {

  @Autowired
  private MockMvc mockMvc;

  @Autowired
  private JsonMapper jsonMapper;

  @Nested
  @DisplayName("正常系")
  class NormalCase {
    @Test
    void test妥当なリクエストで201が返る() throws Exception {
      var request = new UserCreateRequest(
          "test",
          "test@example.com",
          "password123"
          );

      var body = jsonMapper.writeValueAsString(request);

      MvcResult result = mockMvc.perform(post("/users")
          .contentType(MediaType.APPLICATION_JSON)
          .content(body))
        // HTTP ステータスの確認
        .andExpect(status().isCreated())
        .andReturn();

      // 期待通りの型か、JsonMapper で変換することにより確認する
      UserCreateResponse response = assertDoesNotThrow(
          () -> jsonMapper.readValue(result.getResponse().getContentAsString(), UserCreateResponse.class),
          "UserCreateResponse に変換できるはず");

      // 型の内容が期待通りか確認する
      assertEquals(1L, response.getId());
      assertEquals("test", response.getUsername());
      assertEquals("test@example.com", response.getEmail());
    }
  }

  @Nested
  @DisplayName("異常系")
  class ErrorCase {
    @Test
    void test不正なリクエストで400が返る() throws Exception {
      var request = new UserCreateRequest(
          "",
          "test@example.com",
          "password123"
          );

      var body = jsonMapper.writeValueAsString(request);

      mockMvc.perform(post("/users")
          .contentType(MediaType.APPLICATION_JSON)
          .content(body))
        // HTTP ステータスの確認
        .andExpect(status().isBadRequest());
    }

    // レスポンスの型を確認するテストは ApiExceptionHandlerTest に任せる
  }
}

Advice のテスト

コントローラーアドバイスのテストコードを用意します。

ここでは、例外発生時に返却されるエラーレスポンスの形式を検証することに集中します。

また、スタンドアロンの MockMvc を使って、コントローラーとアドバイスを明示的に登録してテストします。

src/test/java/dev/mikoto2000/handson/springboot/test/firststep/common/advice/ApiExceptionHandlerTest.java:

package dev.mikoto2000.handson.springboot.test.firststep.common.advice;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import dev.mikoto2000.handson.springboot.test.firststep.common.advice.ApiExceptionHandler;
import dev.mikoto2000.handson.springboot.test.firststep.common.dto.ApiErrorResponse;
import dev.mikoto2000.handson.springboot.test.firststep.user.dto.UserCreateRequest;
import jakarta.validation.Valid;
import tools.jackson.databind.json.JsonMapper;

/**
 * ApiExceptionHandler を standalone MockMvc で直接検証するテスト。
 *
 * Controller 本体の実装には依存せず、
 * 例外 -> エラーレスポンス形式への変換ロジックを確認する。
 */
@DisplayName("ApiExceptionHandler のテスト")
class ApiExceptionHandlerTest {

  private MockMvc mockMvc;

  private JsonMapper jsonMapper;

  @BeforeEach
  void setUp() {
    // JsonMapper を生成(Spring の DI に依存しない)
    jsonMapper = new JsonMapper();

    // テスト用 Controller + Advice を明示登録して MockMvc を構築
    mockMvc = MockMvcBuilders.standaloneSetup(new TestController())
        .setControllerAdvice(new ApiExceptionHandler())
        .build();
  }

  @Test
  void testバリデーションエラー時にApiErrorResponseを返す() throws Exception {
    // Arrange:
    // username を空にして、@Valid によるバリデーションエラーを発生させる
    var request = new UserCreateRequest(
        "",
        "test@example.com",
        "password123"
        );

    String body = jsonMapper.writeValueAsString(request);

    // Act:
    // ダミー Controller を叩く(@Valid で例外発生 -> Advice が処理)
    MvcResult result = mockMvc.perform(post("/test/users")
            .contentType(APPLICATION_JSON)
            .content(body))
        .andExpect(status().isBadRequest())
        .andReturn();

    // Assert 1:
    // レスポンス JSON を ApiErrorResponse に変換できることを確認
    ApiErrorResponse response = assertDoesNotThrow(
        () -> jsonMapper.readValue(result.getResponse().getContentAsString(), ApiErrorResponse.class),
        "ApiErrorResponse に変換できるはず"
        );

    // Assert 2:
    // Advice が組み立てたエラー応答の固定項目を確認
    assertEquals("VALIDATION_ERROR", response.getCode());
    assertEquals("入力値が不正です", response.getMessage());

    // Assert 3:
    // username に NotBlank と Size の両方が付いているので 2 件
    assertEquals(2, response.getErrors().size(), "username のエラーは 2 件のはず");
  }

  /**
   * Advice テスト用のダミー Controller
   *
   * @Valid により MethodArgumentNotValidException を発生させるためだけに使う。
   */
  @RestController
  static class TestController {

    @PostMapping("/test/users")
    void create(@RequestBody @Valid UserCreateRequest request) {
      // バリデーションエラーを起こすことが目的なので、正常時の処理は不要
    }
  }
}

まとめ

ここまでで、バリデーションのテスト構成・テスト方法について説明しました。

  • DTO クラスのバリデーションの制約を、Spring を起動せずに直接検証するテスト
  • コントローラーの入力バリデーションが、HTTP リクエストに対して期待通りに動作することを確認するテスト
  • コントローラーアドバイスが、例外発生時に期待通りのエラーレスポンスを返すことを確認するテスト

1つのテストで制約・HTTP・エラーレスポンス形式をすべて確認しようとせず、責務ごとに分けて検証するのがポイントです。

これで、バリデーションに関するテストの基本的な構成と方法を理解できたと思います。

DTO, Controller, Advice それぞれのテストの責務と役割を理解し、適切にテストコードを書いていきましょう。