なんとなくvalidationをどこで実行するかでバリデーションの方針を決め、formでのvalidation方法 (GET版)でコードを書いてみた。もう少しバリデーションすべきことってあるよな、例えば指定項目を送って来なかったときとかもあるよなと思ったのとそもそもテスト書いてないなとも思ったのでこの2つを追加で対応する。 プロダクトコードとテストをあわせて書けば表層的なルールの精度も上がるだろう目論見。

validationをどこで実行するかで表層的なルールはControllerで実施し、クライアントからの値はDomainへ直接マッピングせずDTOを介し変換する方針とした。DTOは基本Stringで受けるんでしょと思っていたが、ゆうパケット登録API(コード全体はValidationとRailwayプログラミングとか, ValidationかDomainからへん)を作っていた際、数値を受け取る必要があり「お?」と思ったので整理してみる。

題材

ゆうパケット登録APIをテスト対象にする。単に3辺の長さと重さを受け取りゆうパケットの規定に収まり集荷できると判断した場合は、識別番号を払い出し返却してくれるAPIである。具体的には下記。

リクエスト
% curl -X POST localhost:8080/yu-paket -H "Content-Type: application/json" -d @- <<EOF | jq .
{
   "width":10,
   "height":2,
   "weight":1,
   "depth":30
}
EOF
レスポンス
{
  "id": "1"
}

バリデーション

ゆうパケットの規定はドメイン層が持っているためコントローラ層では、3辺の情報を全部送ってないとか、数値じゃなくて文字列送ってるとかをバリデーションする。弾きたいリクエストは下記。

数値では無い
文字列
% curl -X POST localhost:8080/yu-paket -H "Content-Type: application/json" -d @- <<EOF | jq .
{
   "width":10,
   "height":20,
   "weight":"a",
   "depth":40
}
EOF
値指定忘れ
% curl -X POST localhost:8080/yu-paket -H "Content-Type: application/json" -d @- <<EOF | jq .
{
   "width":10,
   "height":20,
   "weight":,
   "depth":40
}
EOF
null
% curl -X POST localhost:8080/yu-paket -H "Content-Type: application/json" -d @- <<EOF | jq .
{
   "width":10,
   "height":20,
   "weight":null,
   "depth":40
}
EOF
そもそも項目を送って無い
% curl -X POST localhost:8080/yu-paket -H "Content-Type: application/json" -d @- <<EOF | jq .
{
   "width":10,
   "height":2,
   "depth":30
}
EOF

問題

  • 数値として受け取るか、文字列として受け取るか
    • 受け取るデータは長さや重さであるため数値しか入らないが、"width":"10"として値を受け取る方針もあり
  • 数値で受ける場合、プリミティブ型(int)で受け取るか、Integerで受け取るか
    • Domainはプリミティブ型であるintで値を保持する(想定)ため、intで受けると変換ステップが減るため楽かもしれない。

DTOの構造

どれを選択するかは時と場合によるためトレードオフを考えてみる。トレードオフはどこをまでをフレームワークの責務とするかによるため、DTOへのマッピングの動きを整理しておく。

  1. Jsonテキストを受け取る
  2. フレームワーク責務: DTOオブジェクトへ変換
    • 値が無い場合はnullで埋める
    • マッピング先がプリミティブ型(int)の場合は0で埋める
  3. 実装者責務: BeanValidationで検証
    • @Size, @NotNullとか
  4. コントローラーメソッド実行

案① 全てStringで受け取る

DTOオブジェクトのマッピングはフレームワークの責務、マッピング後のBeanValidation及びStringからintへの変換が実装者側の責務。クライアントにエラーの詳細や意図を返せるため、広く外部に公開するAPIとかであればコレもありなのかもしれない。数値を文字列で送れと言われると心象は良くない気がするが。。。

Good : 最もバリデーション可能な範囲が広いのがコレと思われる。型変換前の生データ(nullや空文字列でも)を一旦は受け取れるため、受け取った後どんな制御をするか、どんなエラーメッセージを返すかを自前のコードで制御可能である。

Bad : 反面自前で実装する範囲が増えるため影響範囲も大きくなり、(例えばStringからドメインの持つIntの変換は自前で実装する必要がある。なんら変換できなかったエラー制御も自前で対応する必要もある)実装コストが大きい。

案② 文字列はString、数値はIntegerで受け取る

DTOオブジェクトのマッピングはフレームワークの責務、マッピング後のBeanValidationが実装者側の責務。フレームワークと実装者側の責務境界が一番キレイ。基本コレな気がする。

Good : DTOへのマッピングはライブラリに任せつつ、StringやIntegerで受け取った後のバリデーションを自前で管理したい場合はコレ。マッピングはライブラリの責務なので細かく制御できないが、マッピング後のバリデーションは自前で管理できる。

Bad : DTOへのマッピングはライブラリ任せなのでエラーメッセージは細かく制御できない。フレームワーク側の実装に依存する。フレームワーク側のexceptionを拾ってエラーメッセージを変えることも可能だが、結局はフレームワーク側の実装により依存することになる。

案③ 文字列はString、数値はIntで受け取る

DTOオブジェクトのマッピングはフレームワークの責務、マッピング後のBeanValidationが実装者側の責務。ではあるが、フレームワークがほぼドメインまでの変換を担っている。また、フレームワークが値が無い場合0埋めするため、受け取る項目が全て必須であれば選択肢になるかも(?)ぐらいで基本選ばない気がする。

Good : ライブラリのマッピングでDomainの変換一歩手前まで実施してしまいたい場合はコレ。Domainがプリミティブ型であればほぼDTOからDomainへの変換処理が無いため、実装コストが低い。表層的なバリデーションがあまりない場合は良いかも。

Bad : プリミティブ型であるIntはnullを受け取れないため、項目を送らなかった場合は勝手にライブラリ側が0で埋めてくれるらしい。項目としてwidthを送らなかった場合勝手に0で送ったことにしてくれ、widthに空文字列入れていた場合は変換エラーとなる。そのためクライアント側が0で送ったのか、それともライブラリが0で埋めたのか判断できないためバリデーションを細かく管理することは難しい

DTOの構造とバリデーションの対応方針

境界がきれいな案② 文字列はString、数値はIntegerで受け取るで実装することにする。BeanValidationのExceptionはそのままでは全て400 bad requestになるが、それは味気ないため不正な値が来たことを伝えるようエラーメッセージだけ変える方針で実装する。

ライブラリの責務

  • 数値では無い
    • 文字列 "weight":"a"
    • 値指定忘れ "weight":

実装者側

  • 数値では無い
    • null: "weight":null
  • そもそも項目を送って無い

コード

▶ build.gradle
plugins {
    id 'java'
    id 'groovy'
    id 'org.springframework.boot' version '3.5.4'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

// テストコードでGroovyとJavaの両方を使用可能にする
sourceSets {
    test {
        groovy {
            srcDirs = ['src/test/groovy', 'src/test/java']
        }
    }
}

dependencies {
    // Spring関連
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'

    // その他ライブラリ
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    implementation 'io.vavr:vavr:0.9.0'

    // テスト関連
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    testImplementation 'org.spockframework:spock-core:2.4-M5-groovy-4.0'
    testImplementation 'org.spockframework:spock-spring:2.4-M5-groovy-4.0'

    // フォーマッター
    implementation 'com.google.googlejavaformat:google-java-format:1.28.0'
}

tasks.named('test') {
    useJUnitPlatform()
}
▶ ディレクトリ構成
src/main/java/com/example/demo
├── DemoApplication.java
├── controller
│   └── yu_packet
│       ├── form
│       │   ├── FieldForm.java
│       │   └── YuPacketTrackingNumberForm.java
│       ├── refer
│       │   └── YuPaketReferController.java
│       └── registration
│           ├── YuPaketRegistrationController.java
│           ├── request
│           │   └── YuPacketRequest.java
│           └── response
│               ├── YuPacketResponse.java
│               └── YuPacketResponseImpl.java
├── domain
│   └── yu_packet
│       ├── Depth.java
│       ├── Height.java
│       ├── InvalidatedReason.java
│       ├── Weight.java
│       ├── Witdth.java
│       ├── YuPacketRegistrationCommand.java
│       ├── YuPacketTrackingNumber.java
│       ├── YuPacketValidator.java
│       ├── YuPaket.java
│       └── YuPaketCreateCommand.java
├── exception
│   └── GlobalRestControllerExceptionHandler.java
└── service
    └── YuPaketRegistrationService.java

バリデーション

YuPaketRegistrationController

受け取った値をバリデーションしたいためYuPacketRequestに@Validatedを付けとくぐらい。

@RestController
@RequiredArgsConstructor
public class YuPaketRegistrationController {
  private final YuPaketRegistrationService service;
  private final YuPacketResponse response;

  @RequestMapping(value = "/yu-paket", method = RequestMethod.POST)
  public ResponseEntity<?> invoke(@Validated @RequestBody YuPacketRequest yuPacketRequest) {
    YuPacketRegistrationCommand command = yuPacketRequest.toCommand();
    
    return service.register(command)
            .mapEither(response::success, response::validatedFailure);
  }
}

YuPacketRequest

クライアントがnullを渡したとき及び値が無くフレームワークがnullを設定した場合バリデーションするため、@NotNullアノテーションを付与する。やることはこれだけ。ついでにWeight以外は整数0以上でなければならないため@Rangeも付与した。もちろんドメイン側のルールに実装しているが、まあコントローラ側でも付けていいかなと思ったため付与した。

Weightは0以上である必要があるためisWeightPositiveバリデーションも追加した。Weightに対してisWeightPositive@NotNull2つバリデーション処理が書かれているがどちらが先に実行されるか明示的に決まっていないため、isWeightPositiveにはnullの場合はパスさせるようにした。

@Data
@AllArgsConstructor
public class YuPacketRequest {
  @NotNull(message = "width is required")
  @Range(min = 1, message = "width must be greater than 1")
  private Integer width;

  @NotNull(message = "height is required")
  @Range(min = 1, message = "height must be greater than 1")
  private Integer height;

  @NotNull(message = "depth is required")
  @Range(min = 1, message = "depth must be greater than 1")
  private Integer depth;

  @NotNull(message = "weight is required")
  private Float weight;

  @AssertTrue(message = "weight must be greater than 0")
  boolean isWeightPositive() {
    return weight == null || weight > 0;
  }

  public YuPacketRegistrationCommand toCommand() {
    return new YuPacketRegistrationCommand(width, height, depth, weight);
  }
}

参考 DomainのDepth

0以上はドメイン側のルールとしてももちろん実装してある。

@Data
public class Depth {
  private final int value;

  public static Option<Depth> of(int depth) {
    return validate(depth) ? Option.of(new Depth(depth)) : Option.none();
  }

  public static Boolean validate(int depth) {
    return 0 < depth && depth <= 34;
  }
}

Exceptionのハンドリング

実装者側

付与したアノテーションによるバリデーションや、メソッドが投げるExceptionのハンドリング。例えばクライアントがnullを指定、又は値を指定し忘れてライブラリ側がnullで埋めた場合、@NotNullMethodArgumentNotValidExceptionを投げる。何もしない場合はフレームワーク(Spring?)のデフォルトで処理される。これではクライアント側が何が駄目だったか判断できないため、アノテーションに設定したmesageをエラーに含めて上げるように修正する。やることはformでのvalidation方法 (GET版)で作成したGlobalRestControllerExceptionHandlerにMethodArgumentNotValidExceptionの処理を追加するだけ。これと同様に、エラーが複数ある場合はまとめて返すようにする。

デフォルトの味気ないレスポンス

{
  "timestamp": "2026-01-01T00:00:00.000+00:00",
  "status": 400,
  "error": "Bad Request",
  "path": "/yu-paket"
}

Exception ログ

WARN 91809 --- [demo] [nio-8080-exec-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public org.springframework.http.ResponseEntity<?> com.example.demo.controller.yu_packet.registration.YuPaketRegistrationController.invoke(com.example.demo.controller.yu_packet.registration.request.YuPacketRequest): [Field error in object 'yuPacketRequest' on field 'weight': rejected value [null]; codes [NotNull.yuPacketRequest.weight,NotNull.weight,NotNull.java.lang.Float,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [yuPacketRequest.weight,weight]; arguments []; default message [weight]]; default message [weight is required]] ]

GlobalRestControllerExceptionHandler

@ControllerAdvice(annotations = RestController.class)
public class GlobalRestControllerExceptionHandler {

  // GetリクエストにてURL Pathの値を変数へマッピングする際、メソッドパラメータに直接付与したアノテーション
  // RequestParam、PathVariableのExceptionを処理する
  @ExceptionHandler(ConstraintViolationException.class)
  public ResponseEntity<?> handleConstraintViolationException(ConstraintViolationException e) {
    // status
    HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

    // header
    MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
    headers.add("Content-Type", "application/json; charset=UTF-8");
    HttpHeaders httpHeaders = new HttpHeaders(headers);

    // body
    List<String> errors = e.getConstraintViolations().stream().map(v -> v.getMessage()).toList();
    Map<String, List<String>> body = new HashMap<>();
    body.put("validation errors", errors);

    return ResponseEntity
            .status(httpStatus)
            .headers(httpHeaders)
            .body(body);
  }

  // ControllerのDTO変換時、アノテーションによるバリデーションをパスせずthrowされたExceptionを処理する
  @ExceptionHandler(MethodArgumentNotValidException.class)
  public ResponseEntity<?> handleMethodArgumentNotValidException(
      MethodArgumentNotValidException e) {
    // status
    HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

    // header
    MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
    headers.add("Content-Type", "application/json; charset=UTF-8");
    HttpHeaders httpHeaders = new HttpHeaders(headers);

    // body
    List<String> errors =
        e.getBindingResult().getFieldErrors().stream()
            .map(v -> v.getField() + ": " + v.getDefaultMessage())
            .toList();
    Map<String, List<String>> body = new HashMap<>();
    body.put("validationErrors", errors);

    return ResponseEntity
            .status(httpStatus)
            .headers(httpHeaders)
            .body(body);
  }
}

修正後のレスポンス

{
  "validationErrors": [
    "weightPositive: weight must be greater than 0",
    "height: height is required"
  ]
}

フレームワーク側

DTOへマッピングできない場合フレームワークがHttpMessageNotReadableExceptionを投げる。こちらも同様に何もしなければデフォルトで処理されるため、いい感じにレスポンスを返す。ただしフレームワーク側の処理であるため、細かくエラーメッセージを出し分けることはできるが難しい。例えばweightに文字列aが指定されてるとかはエラーメッセージには含まれているためパースすればできるが、フレームワークが出すエラーメッセージに強く依存するためやらない方針とする。リクエストのフォーマットがおかしいとだけ返すようにハンドリングする。

デフォルトの味気ないレスポンス

{
  "timestamp": "2026-01-01T00:00:00.000+00:00",
  "status": 400,
  "error": "Bad Request",
  "path": "/yu-paket"
}

Exception ログ

文字列 "weight":"a"

WARN 69056 --- [demo] [nio-8080-exec-1] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.lang.Float` from String "a": not a valid `Float` value]

値指定忘れ "weight":

WARN 69056 --- [demo] [nio-8080-exec-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Unexpected character (',' (code 44)): expected a value]

GlobalRestControllerExceptionHandler

@ControllerAdvice(annotations = RestController.class)
public class GlobalRestControllerExceptionHandler {

  // GetリクエストにてURL Pathの値を変数へマッピングする際、メソッドパラメータに直接付与したアノテーション
  // RequestParam、PathVariableのExceptionを処理する
  @ExceptionHandler(ConstraintViolationException.class)
  public ResponseEntity<?> handleConstraintViolationException(ConstraintViolationException e) {
    // status
    HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

    // header
    MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
    headers.add("Content-Type", "application/json; charset=UTF-8");
    HttpHeaders httpHeaders = new HttpHeaders(headers);

    // body
    List<String> errors = e.getConstraintViolations().stream().map(v -> v.getMessage()).toList();
    Map<String, List<String>> body = new HashMap<>();
    body.put("validationErrors", errors);

    return ResponseEntity
            .status(httpStatus)
            .headers(httpHeaders)
            .body(body);
  }

  // ControllerのDTO変換時、アノテーションによるバリデーションをパスせずthrowされたExceptionを処理する
  @ExceptionHandler(MethodArgumentNotValidException.class)
  public ResponseEntity<?> handleMethodArgumentNotValidException(
      MethodArgumentNotValidException e) {
    // status
    HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

    // header
    MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
    headers.add("Content-Type", "application/json; charset=UTF-8");
    HttpHeaders httpHeaders = new HttpHeaders(headers);

    // body
    List<String> errors =
        e.getBindingResult().getFieldErrors().stream()
            .map(v -> v.getField() + ": " + v.getDefaultMessage())
            .toList();
    Map<String, List<String>> body = new HashMap<>();
    body.put("validationErrors", errors);

    return ResponseEntity
            .status(httpStatus)
            .headers(httpHeaders)
            .body(body);
  }

  // ControllerのDTO変換時、フレームワークがJsonのパースに失敗しthrowされたExceptionを処理する
  @ExceptionHandler(HttpMessageNotReadableException.class)
  public ResponseEntity<?> handleHttpMessageNotReadableException(
      HttpMessageNotReadableException e) {
    // status
    HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

    // header
    MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
    headers.add("Content-Type", "application/json; charset=UTF-8");
    HttpHeaders httpHeaders = new HttpHeaders(headers);

    // body
    Map<String, String> body = new HashMap<>();
    body.put("validationError", "Invalid request format");

    return ResponseEntity
            .status(httpStatus)
            .headers(httpHeaders)
            .body(body);
  }
}

修正後のレスポンス

{
  "validationError": "Invalid request format"
}

テスト

大雑把に下記正常系・異常系をテストする。組み合わせるとテスト数が爆発するため、各パターンごとにテストする。DTOでのバリデーションエラーテストも同様に、フィールド値の組み合わせを実施するとテストパターンが爆発するためフィールド値ごとにテストする。

  • 正常系
  • 異常系
    • Service層でのエラー
    • Controller層 DTOでのバリデーションエラー

Controller層 DTOでのバリデーションエラー

バリデーションエラーをどの粒度でテストするか決める。 バリデーションは実装者側、FrameWork側でメッセージが異なるためそれぞれ分けてテストする。メッセージまで見ない場合はまとめてしまっても良いが、個人的にGlobalRestControllerExceptionHandlerが正しく動作しているか不安であるため分けてテストする判断とした。また、実装者側のバリデーションはエラーが複数ある場合、エラーメッセージをまとめて返すように修正したためこれも1パターンテストする。

YuPaketRegistrationControllerSpec

@SpringBootTest
@AutoConfigureMockMvc
class YuPaketRegistrationControllerSpec extends Specification {
    @Autowired
    private MockMvc mockMvc
    @MockitoBean
    private YuPaketRegistrationService yuPaketRegistrationServiceMock
    @Autowired
    private ObjectMapper mapper

    def "200 ok"() {
        setup:
        Mockito.when(yuPaketRegistrationServiceMock.register(Mockito.any())).thenReturn(FixtureYuPaket.validYuPaket())

        when:
        def result = mockMvc.perform(
                post("/yu-paket")
                        .content(mapper.writeValueAsString(FixtureYuPacketRequest.getValidRequest()))
                        .contentType("application/json"))

        then:
        result.andExpect(status().isOk())
        result.andExpect(jsonPath('$.id').value(FixtureYuPacketTrackingNumber.DEFAULT_TRACKING_NUMBER))
    }

    def "400 bad request in service validation"() {
        setup:
        Mockito.when(yuPaketRegistrationServiceMock.register(Mockito.any())).thenReturn(FixtureYuPaket.invalidYuPaket())

        when:
        def result = mockMvc.perform(
                post("/yu-paket")
                        .content(mapper.writeValueAsString(FixtureYuPacketRequest.getValidRequest()))
                        .contentType("application/json"))

        then:
        result.andExpect(status().isBadRequest())
        result.andExpect(jsonPath(('$.errors')).exists())
    }

    def "400 bad request in controller validation : #useCase"() {

        when:
        def result = mockMvc.perform(
                post("/yu-paket")
                        .content(mapper.writeValueAsString(invalidRequest))
                        .contentType("application/json"))

        then:
        result.andExpect(status().isBadRequest())
        result.andExpect(jsonPath('$.validationErrors').exists())

        where:
        useCase           | invalidRequest
        "width negative"  | FixtureYuPacketRequest.getInvalidRequestWidthOfNegativeValue()
        "width null"      | FixtureYuPacketRequest.getInvalidWidthOfNullRequest()
        "height negative" | FixtureYuPacketRequest.getInvalidRequestHeightOfNegativeValue()
        "height null"     | FixtureYuPacketRequest.getInvalidHeightOfNullRequest()
        "depth negative"  | FixtureYuPacketRequest.getInvalidRequestDepthOfNegativeValue()
        "depth null"      | FixtureYuPacketRequest.getInvalidDepthOfNullRequest()
        "weight negative" | FixtureYuPacketRequest.getInvalidRequestWeightOfNegativeValue()
        "weight null"     | FixtureYuPacketRequest.getInvalidWeightOfNullRequest()
    }

    def "400 bad request in controller validation : contain all error messages"() {
        when:
        def result = mockMvc.perform(
                post("/yu-paket")
                        .content(mapper.writeValueAsString(FixtureYuPacketRequest.getInvalidWithAllNegativeValuesRequest()))
                        .contentType("application/json"))

        then:
        result.andExpect(status().isBadRequest())
        result.andExpect(jsonPath('$.validationErrors').isArray())
        result.andExpect(jsonPath('$.validationErrors.length()').value(4))
    }

    def "400 bad request in framework exception : #useCase"() {
        when:
        def result = mockMvc.perform(
                post("/yu-paket")
                        .content(mapper.writeValueAsString(invalidRequest))
                        .contentType("application/json"))

        then:
        result.andExpect(status().isBadRequest())
        result.andExpect(jsonPath('$.validationError').value(containsString("Invalid request format")))

        where:
        useCase               | invalidRequest
        "width not a number"  | FixtureYuPacketRequest.getInvalidWidthOfNotANumberRequest()
        "width no value"      | FixtureYuPacketRequest.getInvalidWidthOfNoValueRequest()
        "width no key"        | FixtureYuPacketRequest.getInvalidWidthOfNoKeyValueRequest()
        "height not a number" | FixtureYuPacketRequest.getInvalidHeightOfNotANumberRequest()
        "height no value"     | FixtureYuPacketRequest.getInvalidHeightOfNoValueRequest()
        "height no key"       | FixtureYuPacketRequest.getInvalidHeightOfNoKeyValueRequest()
        "depth not a number"  | FixtureYuPacketRequest.getInvalidDepthOfNotANumberRequest()
        "depth no value"      | FixtureYuPacketRequest.getInvalidDepthOfNoValueRequest()
        "depth no key"        | FixtureYuPacketRequest.getInvalidDepthOfNoKeyValueRequest()
        "weight not a number" | FixtureYuPacketRequest.getInvalidWeightOfNotANumberRequest()
        "weight no value"     | FixtureYuPacketRequest.getInvalidWeightOfNoValueRequest()
        "weight no key"       | FixtureYuPacketRequest.getInvalidWeightOfNoKeyValueRequest()
    }
}

正常系

とりあえず200 okが返ることは確認する。メッセージをどこまで確認するかは迷ったが、TrackingNumberはわりかしドメインの重要なポジションな気がしたため完全一致で値が同じであることを確認することにした。

def "200 ok"() {
   setup:
   Mockito.when(yuPaketRegistrationServiceMock.register(Mockito.any())).thenReturn(FixtureYuPaket.validYuPaket())

   when:
   def result = mockMvc.perform(
            post("/yu-paket")
                  .content(mapper.writeValueAsString(FixtureYuPacketRequest.getValidRequest()))
                  .contentType("application/json"))

   then:
   result.andExpect(status().isOk())
   result.andExpect(jsonPath('$.id').value(FixtureYuPacketTrackingNumber.DEFAULT_TRACKING_NUMBER))
}

異常系 : Service層でのエラー

とりあえず400 bad requestが返ることは確認する。エラーメッセージの内容はエラーによりけりで文言の変更コストが軽いため、テストではメッセージ内容までは確認せずキー名としてerrors項目があるかだけ確認する判断とした。

def "400 bad request in service validation"() {
   setup:
   Mockito.when(yuPaketRegistrationServiceMock.register(Mockito.any())).thenReturn(FixtureYuPaket.invalidYuPaket())

   when:
   def result = mockMvc.perform(
            post("/yu-paket")
                  .content(mapper.writeValueAsString(FixtureYuPacketRequest.getValidRequest()))
                  .contentType("application/json"))

   then:
   result.andExpect(status().isBadRequest())
   result.andExpect(jsonPath(('$.errors')).exists())
}

異常系 : 実装者側バリデーション

方針は異常系 : Service層でのエラーと合わせた

def "400 bad request in controller validation : #useCase"() {

   when:
   def result = mockMvc.perform(
            post("/yu-paket")
                  .content(mapper.writeValueAsString(invalidRequest))
                  .contentType("application/json"))

   then:
   result.andExpect(status().isBadRequest())
   result.andExpect(jsonPath('$.validationErrors').exists())

   where:
   useCase           | invalidRequest
   "width negative"  | FixtureYuPacketRequest.getInvalidRequestWidthOfNegativeValue()
   "width null"      | FixtureYuPacketRequest.getInvalidWidthOfNullRequest()
   "height negative" | FixtureYuPacketRequest.getInvalidRequestHeightOfNegativeValue()
   "height null"     | FixtureYuPacketRequest.getInvalidHeightOfNullRequest()
   "depth negative"  | FixtureYuPacketRequest.getInvalidRequestDepthOfNegativeValue()
   "depth null"      | FixtureYuPacketRequest.getInvalidDepthOfNullRequest()
   "weight negative" | FixtureYuPacketRequest.getInvalidRequestWeightOfNegativeValue()
   "weight null"     | FixtureYuPacketRequest.getInvalidWeightOfNullRequest()
}

異常系 : エラーメッセージをまとめて返すか

同様に方針は異常系 : Service層でのエラーと合わせ、メッセージ内容は確認せず構造的にとりあえずエラーの個数が狙った数あるかだけを確認する方針とした。

    def "400 bad request in controller validation : contain all error messages"() {
        when:
        def result = mockMvc.perform(
                post("/yu-paket")
                        .content(mapper.writeValueAsString(FixtureYuPacketRequest.getInvalidWithAllNegativeValuesRequest()))
                        .contentType("application/json"))

        then:
        result.andExpect(status().isBadRequest())
        result.andExpect(jsonPath('$.validationErrors').isArray())
        result.andExpect(jsonPath('$.validationErrors.length()').value(4))
    }

異常系 : FrameWork側バリデーション

Framework側のバリデーションエラーメッセージは基本変えない気がしたため、メッセージまでテスト対象とした。

    def "400 bad request in framework exception : #useCase"() {
        when:
        def result = mockMvc.perform(
                post("/yu-paket")
                        .content(mapper.writeValueAsString(invalidRequest))
                        .contentType("application/json"))

        then:
        result.andExpect(status().isBadRequest())
        result.andExpect(jsonPath('$.validationError').value(containsString("Invalid request format")))

        where:
        useCase               | invalidRequest
        "width not a number"  | FixtureYuPacketRequest.getInvalidWidthOfNotANumberRequest()
        "width no value"      | FixtureYuPacketRequest.getInvalidWidthOfNoValueRequest()
        "width no key"        | FixtureYuPacketRequest.getInvalidWidthOfNoKeyValueRequest()
        "height not a number" | FixtureYuPacketRequest.getInvalidHeightOfNotANumberRequest()
        "height no value"     | FixtureYuPacketRequest.getInvalidHeightOfNoValueRequest()
        "height no key"       | FixtureYuPacketRequest.getInvalidHeightOfNoKeyValueRequest()
        "depth not a number"  | FixtureYuPacketRequest.getInvalidDepthOfNotANumberRequest()
        "depth no value"      | FixtureYuPacketRequest.getInvalidDepthOfNoValueRequest()
        "depth no key"        | FixtureYuPacketRequest.getInvalidDepthOfNoKeyValueRequest()
        "weight not a number" | FixtureYuPacketRequest.getInvalidWeightOfNotANumberRequest()
        "weight no value"     | FixtureYuPacketRequest.getInvalidWeightOfNoValueRequest()
        "weight no key"       | FixtureYuPacketRequest.getInvalidWeightOfNoKeyValueRequest()
    }

Fixture

テスト入力値はJsonかFixtureか

Jsonの方が実際のデータに近いが、テストデータの意図を読み取るのが難しい。Fixture(からJsonへ変換して渡す)であればテストデータの意図がわかりやすいが、正しく実データのJson構造を表現できているか整合が取りづらい。今回のAPIはそこまで多くのパラメータを要求していないため、Fixtureで対応する方針とした。もっと大量のJsonデータをやり取りする際は生Jsonの方がイイかも?

FixtureYuPacketRequest

package com.example.demo.controller.yu_packet.registration.request

class FixtureYuPacketRequest {
    static Integer DEFAULT_WIDTH_VALUE = 10
    static Integer DEFAULT_HEIGHT_VALUE = 20
    static Integer DEFAULT_DEPTH_VALUE = 30
    static Float DEFAULT_WEIGHT_VALUE = 1.0

    static YuPacketRequest getValidRequest() {
        return new YuPacketRequest(
                DEFAULT_WIDTH_VALUE,
                DEFAULT_HEIGHT_VALUE,
                DEFAULT_DEPTH_VALUE,
                DEFAULT_WEIGHT_VALUE
        )
    }

    static YuPacketRequest getInvalidRequestWidthOfNegativeValue() {
        return new YuPacketRequest(
                -1,
                DEFAULT_HEIGHT_VALUE,
                DEFAULT_DEPTH_VALUE,
                DEFAULT_WEIGHT_VALUE
        )
    }

    static YuPacketRequest getInvalidWidthOfNullRequest() {
        return new YuPacketRequest(
                null,
                DEFAULT_HEIGHT_VALUE,
                DEFAULT_DEPTH_VALUE,
                DEFAULT_WEIGHT_VALUE
        )
    }

    static Map getInvalidWidthOfNotANumberRequest() {
        return Map.of(
                "width", "invalid",
                "height", DEFAULT_HEIGHT_VALUE,
                "depth", DEFAULT_DEPTH_VALUE,
                "weight", DEFAULT_WEIGHT_VALUE
        )
    }

    static String getInvalidWidthOfNoValueRequest() {
        return """
        {
            "width":,
            "height": ${DEFAULT_HEIGHT_VALUE},
            "depth": ${DEFAULT_DEPTH_VALUE},
            "weight": ${DEFAULT_WEIGHT_VALUE}
        }
        """
    }

    static String getInvalidWidthOfNoKeyValueRequest() {
        return """
        {
            "height": ${DEFAULT_HEIGHT_VALUE},
            "depth": ${DEFAULT_DEPTH_VALUE},
            "weight": ${DEFAULT_WEIGHT_VALUE}
        }
        """
    }

    static YuPacketRequest getInvalidRequestHeightOfNegativeValue() {
        return new YuPacketRequest(
                DEFAULT_WIDTH_VALUE,
                -1,
                DEFAULT_DEPTH_VALUE,
                DEFAULT_WEIGHT_VALUE
        )
    }

    static YuPacketRequest getInvalidHeightOfNullRequest() {
        return new YuPacketRequest(
                DEFAULT_WIDTH_VALUE,
                null,
                DEFAULT_DEPTH_VALUE,
                DEFAULT_WEIGHT_VALUE
        )
    }

    static Map getInvalidHeightOfNotANumberRequest() {
        return Map.of(
                "width", DEFAULT_WIDTH_VALUE,
                "height", "invalid",
                "depth", DEFAULT_DEPTH_VALUE,
                "weight", DEFAULT_WEIGHT_VALUE
        )
    }

    static String getInvalidHeightOfNoValueRequest() {
        return """
        {
            "width": ${DEFAULT_WIDTH_VALUE},
            "height":,
            "depth": ${DEFAULT_DEPTH_VALUE},
            "weight": ${DEFAULT_WEIGHT_VALUE}
        }
        """
    }

    static String getInvalidHeightOfNoKeyValueRequest() {
        return """
        {
            "width": ${DEFAULT_WIDTH_VALUE},
            "depth": ${DEFAULT_DEPTH_VALUE},
            "weight": ${DEFAULT_WEIGHT_VALUE}
        }
        """
    }

    static YuPacketRequest getInvalidRequestDepthOfNegativeValue() {
        return new YuPacketRequest(
                DEFAULT_WIDTH_VALUE,
                DEFAULT_HEIGHT_VALUE,
                -1,
                DEFAULT_WEIGHT_VALUE
        )
    }

    static YuPacketRequest getInvalidDepthOfNullRequest() {
        return new YuPacketRequest(
                DEFAULT_WIDTH_VALUE,
                DEFAULT_HEIGHT_VALUE,
                null,
                DEFAULT_WEIGHT_VALUE
        )
    }

    static Map getInvalidDepthOfNotANumberRequest() {
        return Map.of(
                "width", DEFAULT_WIDTH_VALUE,
                "height", DEFAULT_HEIGHT_VALUE,
                "depth", "invalid",
                "weight", DEFAULT_WEIGHT_VALUE
        )
    }

    static String getInvalidDepthOfNoValueRequest() {
        return """
        {
            "width": ${DEFAULT_WIDTH_VALUE},
            "height": ${DEFAULT_HEIGHT_VALUE},
            "depth":,
            "weight": ${DEFAULT_WEIGHT_VALUE}
        }
        """
    }

    static String getInvalidDepthOfNoKeyValueRequest() {
        return """
        {
            "width": ${DEFAULT_WIDTH_VALUE},
            "height": ${DEFAULT_HEIGHT_VALUE},
            "weight": ${DEFAULT_WEIGHT_VALUE}
        }
        """
    }

    static YuPacketRequest getInvalidRequestWeightOfNegativeValue() {
        return new YuPacketRequest(
                DEFAULT_WIDTH_VALUE,
                DEFAULT_HEIGHT_VALUE,
                DEFAULT_DEPTH_VALUE,
                -1.0
        )
    }

    static YuPacketRequest getInvalidWeightOfNullRequest() {
        return new YuPacketRequest(
                DEFAULT_WIDTH_VALUE,
                DEFAULT_HEIGHT_VALUE,
                DEFAULT_DEPTH_VALUE,
                null
        )
    }

    static Map getInvalidWeightOfNotANumberRequest() {
        return Map.of(
                "width", DEFAULT_WIDTH_VALUE,
                "height", DEFAULT_HEIGHT_VALUE,
                "depth", DEFAULT_DEPTH_VALUE,
                "weight", "invalid"
        )
    }

    static String getInvalidWeightOfNoValueRequest() {
        return """
        {
            "width": ${DEFAULT_WIDTH_VALUE},
            "height": ${DEFAULT_HEIGHT_VALUE},
            "depth": ${DEFAULT_DEPTH_VALUE},
            "weight":
        }
        """
    }

    static String getInvalidWeightOfNoKeyValueRequest() {
        return """
        {
            "width": ${DEFAULT_WIDTH_VALUE},
            "height": ${DEFAULT_HEIGHT_VALUE},
            "depth": ${DEFAULT_DEPTH_VALUE}
        }
        """
    }

    static YuPacketRequest getInvalidWithAllNegativeValuesRequest() {
        return new YuPacketRequest(
                -1,
                -1,
                -1,
                -1.0
        )
    }
}