7
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Spring Boot で REST API を作成とテストコードの実装まで 〜 備忘録 〜

Posted at

はじめに

Spring Boot を用いて REST API を作成しました。
Spring Boot の アプリケーション作成〜テスト実装までがまとまった記事が意外と見つからなかったのでまとめてみました。

Java × Spring Boot に触るのは今回が初めてなので、Java初心者の方が対象です。

環境

私は以下の環境でアプリを作成しました。

  • IntelliJ : 2022.3.1(Community Edition)
  • Java : 17.0.5
  • Spring Boot : 3.0.1
  • Gradle JVM : zulu-17
  • Docker : 20.10.21
  • MySQL : 8.0

1. Spring Boot プロジェクトのセットアップ

ここからは、Java と IntelliJ を PCへインストールされていることが前提条件になります。

1-1. Spring Initializr

Spring Initializr からSpring Bootの起動に必要なファイルをダウンロードします。

画像のように プロジェクトのバージョンを指定してください。
Project:Gradle - Groovy
Language:Java
Spring Boot:3.0.2

Project Metadataに関しては任意の名前をつけて問題ありません。

Dependencies では Spring Web を追加してから、GENERATEボタンをクリックしてください。

SpringInitializr.png

プロジェクトのファイル生成が終わると、zipファイルがダウンロードされるので、
Zipファイルを解凍して、IntelliJからプロジェクトを開きましょう。

アプリケーションを起動すると src フォルダの main > java > com.demo フォルダ内に以下のファイルが作成されているはずです。
このファイルの main メソッドを実行するとアプリケーションが起動するので、正常に起動するか確認してください

demoApplicaction.java
package com.Demo;

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

@SpringBootApplication
public class DemoAppApplication {

	public static void main(String[] args) {
		SpringApplication.run(DemoApplication.class, args);
	}
}

問題なく起動すると以下のようにASCII文字でSpringと表示されます。

> Task :DemoMemoAppApplication.main()

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v3.0.2)

1-2. MySQL on Dockerを用意

こちらの手順はPCへDockerがインストールされていることが前提となります。

今回のプロジェクトでは Dockerコンテナ上にDBを用意して開発を進めます。
コンテナ上のDBを利用することで、ローカルのDBとの競合やバージョンの違いなどのアプリ開発におけるよくあるトラブルをある程度回避することができます。

アプリケーションのルートディレクトリにDockerコンテナ用のファイルを作成してください。

docker-compose.yml
version: '3.8'
services:
  db:
    build: .
    container_name: コンテナの名前
    platform: linux/x86_64
    command: --default-authentication-plugin=mysql_native_password
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: DBのルートユーザーパスワード
      MYSQL_DATABASE: DB名
      MYSQL_USER: DBのユーザー名
      MYSQL_PASSWORD: DBのパスワード
    ports:
      - "3307:3306"
    volumes:
      - ./sql:/docker-entrypoint-initdb.d
      - my-vol:/var/lib/mysql
volumes:
  my-vol:
Dockerfile
FROM mysql:8.0-debian
RUN apt-get update
RUN apt-get -y install locales-all
ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP:ja
ENV LC_ALL ja_JP.UTF-8
COPY ./conf/mysql/my.cnf /etc/my.cnf

Spring Bootアプリケーションで使用するデータベースへ初期データを追加したいので、
アプリケーションのディレクトリ直下にsqlディレクトリを作成します。

私はメモアプリのREST APIを作成したので、以下のように定義してDockerコンテナのDBへ初期データを作成していました。

sql/create-table-and-load-data.sql
DROP TABLE IF EXISTS memos;

CREATE TABLE memos (
  id int unsigned AUTO_INCREMENT,
  title VARCHAR(100) NOT NULL,
  category VARCHAR(100),
  description VARCHAR(100),
  date VARCHAR(100),
  mark_div int,
  PRIMARY KEY(id)
);

INSERT INTO memos (title, category, description, date, mark_div) VALUES ("今日の仕事", "Java", "課題に取り組みました","2022/12/31",1);
INSERT INTO memos (title, category, description, date, mark_div) VALUES ("明日の仕事", "Java", "課題が進みません","2023/01/01",1);
INSERT INTO memos (title, category, description, date, mark_div) VALUES ("明後日の仕事", "Java", "課題を終えたい","2023/01/02",0);
INSERT INTO memos (title, category, description, date, mark_div) VALUES ("来週の仕事", "Java", "課題が終わるはずです","2023/01/09",0);

上記をセットした状態で docker compose up -d を実行すると、コンテナが起動します。
コンテナを停止させる場合はdocker compose downというコマンドで停止できます。

作成したコンテナが稼働中?と気になったらdocker psで稼働しているコンテナを確認すると良いでしょう。

2. CRUD処理を実装

Spring Bootプロジェクトとデータベースの準備を終えたところでいよいよCRUD処理を実装します。
早速CRUD処理の開発.....へ行く前にディレクトリの構成を説明しておきます。

ディレクトリ構成

私が作成したアプリケーションでは最終的に以下のようなディレクトリ構成になっています。
application / domain / infrastructure の3つの層に分けてプログラムを配置しています。

パッケージ名は「rtjavamemoapp」にしています。

com.rtjavamemoapp
├── RtJavaMemoAppApplication.java
├── application
│   ├──── controller
│   │   ├──  MemoController.java
│   │   └──  MemoControllerAdvice.java
│   └──── resources
│       ├──  MemoForm.java
│       └──  MemoResponse.java
├── domain
│   ├── exception
│   │   └── ResourceNotFountException.java
│   ├── model
│   │   └── Memo.java
│   ├── object
│   │   └── MemoDTO.java
│   └── service
│       ├── MemoService.java
│       └── MemoServiceImpl.java
├── infrastructure
│   └── mapper 
│       └── MemoMapper.java

APIとしてレスポンスを返すだけのアプリケーションですが、CRUD処理を作成するにしても考える事項が非常に多いです。
ディレクトリ構成を考える上で参考にしたのはこちらです。

かなり難解ですが、コードを書いているうちに段々理解できるようになりました。

基本的にはユーザーとのやり取りをapplication層、domain層で業務処理を行う、
infrastructure層ではDBとのやり取りを行うとざっくり理解して取り掛かりました。

それでは、実装を進めていきます。

2-1.オブジェクトの準備とアノテーションの紹介

今回はバックエンドのみのAPIを作成するので、リクエスト/レスポンスで返すためのオブジェクトを準備しておきます。

DBのデータを受け取るためのオブジェクトを作成しておきます。

まずはDBとやり取りして取得するデータを格納するオブジェクト Memo を定義します。

domain/model/Memo.java
@Data
public class Memo {

    private int id;
    private String title;
    private String category;
    private String description;
    private String date;
    private int markDiv;

    public Memo(int id, String title, String category, String description, String date,
        int markDiv) {
        this.id = id;
        this.title = title;
        this.category = category;
        this.description = description;
        this.date = date;
        this.markDiv = markDiv;
    }
    
}

続いて、MemoResponse は データの全件表示(Read)の際に使用するオブジェクトです。
後ほど実装しますが、データを1件ずつ MemoResponse へ格納して、Listで返します。

application/resources/MemoResponse
@Data
public class MemoResponse {

    private int id;
    private String title;
    private String description;
    private String category;
    private String date;
    private int markDiv;
    
    // Memo のデータをMemoResponseへ定義します。
    public MemoResponse(Memo memo) {
        this.id = memo.getId();
        this.title = memo.getTitle();
        this.description = memo.getDescription();
        this.category = memo.getCategory();
        this.date = memo.getDate();
        this.markDiv = memo.getMarkDiv();
    }
    
}

最後にMemoFormオブジェクトを用意します。FormオブジェクトはCreateとUpdate処理で利用します。

application/resources/MemoForm
@Data
public class MemoForm {

    private int id;
    private String title;
    private String description;
    private String category;
    private String date;
    private int markDiv;
}

ここまで作成できれば、CRUD処理作成前の準備は完了です。

これからCRUD処理を実装する上で使用するアノテーションを紹介します。

アノテーション 意味
Spring @Override メソッドがスーパークラスのメソッドを上書きしていることを示すアノテーション
Spring @RestController JSONやXMLなど Web API 用のコントローラであることを示すアノテーション (戻り値はレスポンスのコンテンツ)
Spring @RequestBody クライアントから送信されるリクエストを受け取るためのアノテーション
Spring @PathVariable URLのパスに基づき変数に格納してくれるアノテーション
Spring @GetMapping GETリクエストをURLと特定のコントローラのクラス/メソッドに紐づけるアノテーション
Spring @PostMapping POSTリクエストをURLと特定のコントローラのクラス/メソッドに紐づけるアノテーション
Spring @DeleteMapping DELETEリクエストをURLと特定のコントローラのクラス/メソッドに紐づけるアノテーション
Spring @PatchMapping PATCHリクエストをURLと特定のコントローラのクラス/メソッドに紐づけるアノテーション
Spring @Service 特定のクラスがサービスであることを宣言するアノテーション
MyBatis @Mapper 特定のクラスがマッパーであることを宣言するアノテーション SpringのRepositoryインターフェースに対して付与する
MyBatis @Select SQLのSelectの役割。MyBatisによるマッピングの際に使用するアノテーション
MyBatis @Insert SQLのInsertの役割。MyBatisによるマッピングの際に使用するアノテーション
MyBatis @Options マッピングするデータ処理に追加情報を加えるためのアノテーション
MyBatis @Update SQLのUpdateの役割。MyBatisによるマッピングの際に使用するアノテーション
MyBatis @Delete SQLのDeleteの役割。MyBatisによるマッピングの際に使用するアノテーション
Lombok @Data クラス内のインスタンス変数に対してgetter/setterでアクセスできるようになる。この他にも機能あり
Lombok @RequiredArgsConstructor finalのメンバへ値をセットするための引数つきコンストラクタを自動生成することができる
Spring @Validated バリデーションを行うためのアノテーション(Java標準の@Validの拡張)

CRUD処理の実装の流れについては基本的にして、以下の3ステップで実装を進めます。

  1. Controller でリクエストに応じた処理を定義
  2. Service の定義
  3. Mapper の処理を定義

2-2.Read処理

今回は /memos にGETリクエストをするとデータベースのメモデータをレスポンスとして返す処理をしています。

application/controller/MemoController.java
@RestController
@RequiredArgsConstructor
public class MemoController {

    // Controllerで使用するサービスを呼び出します
    private final MemoService memoService;

    // `/memos`へGETリクエストを送った際に実行
    @GetMapping("/memos")
    public List<MemoResponse> getMemos() {
        // memoServiceのfindAllメソッドを実行し、MemoResponseをリスト形式で返します
        return memoService.findAll().stream().map(MemoResponse::new).toList();
    }

    // `/memos/{id}`へGETリクエストを送った際に実行
    @GetMapping("/memos/{id}")
    public Memo findById(@PathVariable int id){
        // memoServiceのfindById(id)を実行した結果を返します
        return memoService.findById(id);
    }
}

サービスの振る舞いをインターフェースとして定義します。

domain/service/MemoService.java
@Service
@RequiredArgsConstructor
public interface MemoService {
    // findAllメソッドを定義
    List<Memo> findAll();
    // findByIdメソッドを定義
    Memo findById(int id);

}

インターフェースを実装して、必要な業務処理を実装します。

domain/service/MemoServiceImpl.java
@Service
@RequiredArgsConstructor
public class MemoServiceImpl implements MemoService {

    private final MemoMapper memoMapper;
    
    // インターフェースで定義した振る舞いをOverrideします。ここではmapperのメソッドを実行
    @Override
    public List<Memo> findAll() {
        return memoMapper.findAll();
    }

    @Override
    public Memo findById(int id) {
        return memoMapper.findById(id);
    }
}
infrastructure/mapper/MemoMapper.java
@Mapper
public interface MemoMapper {
    
    // MapperではDBとのやり取りを行うためにSQLを記載します。
    @Select("SELECT * FROM memos")
    List<Memo> findAll();

    @Select("SELECT * FROM memos WHERE id=#{id}")
    Optional<Memo> findById(int id);

}

ここまで、実装ができたらアプリケーションを起動して、期待したレスポンスが返ってくるか検証してみましょう。

# GET /memos 全件検索
curl http://localhost:8080/memos

# GET /memos/{id}  id に紐づくデータ検索
curl http://localhost:8080/memos/{id}

2-3.Create処理

続いてCreate処理を実装します。

application/controller/MemoController.java
@RestController
@RequiredArgsConstructor
public class MemoController {

    private final MemoService memoService;

    ~~~~~省略~~~~~
    // `/memos`へPOSTリクエストを送った際に実行
    @PostMapping("/memos")
    public ResponseEntity<Map<String, String>> createMemo(@RequestBody @Validated MemoForm form) {
        memoService.createMemo(form);
        URI url = UriComponentsBuilder.fromUriString("").path("/memos/id").build().toUri();

        return ResponseEntity.created(url)
            .body(Map.of("message", "memo has been successfully created"));
    }
}

memoService.createMemo(form); の実行のみではユーザーへのレスポンスを返しません。
そのため、ResponseEntityクラスを利用して、ユーザーにもわかるようレスポンスを定義しています。

ServiceはRead処理と同様振る舞いを定義します。

domain/service/MemoService.java
@Service
@RequiredArgsConstructor
public interface MemoService {

    ~~~~~省略~~~~~

    void createMemo(MemoForm form);
}
domain/service/MemoServiceImpl.java
@Service
@RequiredArgsConstructor
public class MemoServiceImpl implements MemoService {
    
    private final MemoMapper memoMapper;
    
    ~~~~~省略~~~~~

    @Override
    public void createMemo(MemoForm form) {
        memoMapper.createMemo(form);
    }

}

Serviceの定義を終えたら、Mapper側でSQLを定義しましょう。

infrastructure/mapper/MemoMapper.java
@Mapper
public interface MemoMapper {

    ~~~~~省略~~~~~

    @Insert("INSERT INTO memos(title, category, description, date, mark_div) VALUES(#{title}, #{category}, #{description}, #{date}, #{markDiv})")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    void createMemo(MemoForm form);

}

ここまで、実装ができたらアプリケーションを起動して、期待したレスポンスが返ってくるか検証してみましょう。

# POST /memos 新規作成
curl -X POST -H "Content-Type: application/json" -d '{"title": "新規作成", "category": "CREATE", "description": "メモを作成しました", "date": "2023/02/08", "markDiv": 1}' localhost:8080/memos

# GET /memos/{id}  作成したデータ確認
curl http://localhost:8080/memos/{id}

2-4.Delete処理

Read と Create の処理実装の流れと同様なので、コード内のコメントは割愛します。

application/controller/MemoController.java
@RestController
@RequiredArgsConstructor
public class MemoController {

    private final MemoService memoService;

    ~~~~~省略~~~~~

    @DeleteMapping("/memos/{id}")
    public ResponseEntity<Map<String, String>> deleteMemo(@PathVariable int id) {
        memoService.deleteMemo(id);
        return ResponseEntity.ok(Map.of("message", "memo has been successfully deleted"));
    }
}
domain/service/MemoService.java
@Service
@RequiredArgsConstructor
public interface MemoService {

    ~~~~~省略~~~~~

    void deleteMemo(int id);

}
domain/service/MemoServiceImpl.java
@Service
@RequiredArgsConstructor
public class MemoServiceImpl implements MemoService {
    
    private final MemoMapper memoMapper;
    
    ~~~~~省略~~~~~
    @Override
    public void deleteMemo(int id) {
        memoMapper.findById(id);
        memoMapper.deleteMemo(id);
    }
}
infrastructure/mapper/MemoMapper.java
@Mapper
public interface MemoMapper {

    ~~~~~省略~~~~~
    @Delete("DELETE from memos WHERE id=#{id}")
    void deleteMemo(int id);
}

ここまで、実装ができたらアプリケーションを起動して、期待したレスポンスが返ってくるか検証してみましょう。

# DELETE /memos/{id} id に紐づくデータを削除
curl -X DELETE localhost:8080/memos/{id}

# GET /memos/{id}  作成したデータ確認
curl http://localhost:8080/memos/{id}

2-5.Update処理

Update処理はControllerの実装でつまずいたため、詳細を説明します。

@PathVariableはクエリパラメータを受け取るためのアノテーションでidを受け取る役割を果たします。
@RequestBody は リクエストパラメータ(title,category...など)を受け取ります。

Updateではidを指定して、更新するデータを絞り込んでから、@RequestBodyで受け取ったパラメータへ更新を行います。

DB上のデータを更新するための情報が増えているので、適切にハンドリングしましょう。

application/controller/MemoController.java
@RestController
@RequiredArgsConstructor
public class MemoController {

    private final MemoService memoService;

    ~~~~~省略~~~~~

    @PatchMapping("/memos/{id}")
    public ResponseEntity<Map<String, String>> updateMemo(@PathVariable int id,
        @RequestBody @Validated MemoForm form) {
        memoService.updateMemo(id, form);
        return ResponseEntity.ok(Map.of("message", "memo has been successfully updated"));
    }
}
domain/service/MemoService.java
@Service
@RequiredArgsConstructor
public interface MemoService {

    ~~~~~省略~~~~~

    void updateMemo(int id, MemoForm form);
}
domain/service/MemoServiceImpl.java
@Service
@RequiredArgsConstructor
public class MemoServiceImpl implements MemoService {
    
    private final MemoMapper memoMapper;
    
    ~~~~~省略~~~~~

    @Override
    public void updateMemo(int id, MemoForm form) {
        // 更新の対象となるデータへ絞り込み
        memoMapper.findById(id);
        // 対象データを更新するメソッドを呼び出し
        memoMapper.updateMemo(id, form);
    }
}
infrastructure/mapper/MemoMapper.java
@Mapper
public interface MemoMapper {

    ~~~~~省略~~~~~

    @Update("UPDATE memos SET title=#{form.title}, category=#{form.category}, description=#{form.description}, date=#{form.date},mark_div=#{form.markDiv} WHERE id=#{id}")
    void updateMemo(int id, MemoForm form);
}

ここまで、実装ができたらアプリケーションを起動して、期待したレスポンスが返ってくるか検証してみましょう。

# PATCH /memos/{id} 
curl -X PATCH -H "Content-Type: application/json" -d '{"title": "新規作成", "category": "CREATE", "description": "メモを作成しました", "date": "2023/02/08", "markDiv": 1}' localhost:8080/memos/{id}

# GET /memos/{id}  id に紐づくデータを確認
curl http://localhost:8080/memos/{id}

2-6.まとめ

2 ではSpring Boot を用いて簡単なREST APIを作成しました。
最低限 REST API の CRUD処理としては機能します。

初めはディレクトリの構成や配置先に迷ったり、なぜかID拾えないなと言うエラーにあったりとしましたが、
最終的に完成させるとよく理解することができました。

3. 例外処理とバリデーションの実装

例外処理とバリデーションを実装していきます。

アノテーション 意味
Java @NotBlank アノテーション付きの要素がNullではなく、空白文字以外が少なくとも1つ含めることをチェック
Java @Size アノテーション付きの要素数が指定されたサイズの間にあることをチェック
Java @Range アノテーション付きの要素が指定された最小値と最大値の間にあることをチェック
Java @Pattern アノテーション付きの要素が指定された正規表現と一致していることをチェック

3-1.バリデーションの実装

今回、メモを登録する際の制限としては以下を定義しています。

  • title は入力必須で1文字以上20文字以下で登録すること
  • date はYYYY/MM/DDの形式で登録すること
  • markDiv は 1 か 0 のみ登録可能であること

Spring Boot でバリデーションを実装するにはbuild.gradleimplementation 'org.springframework.boot:spring-boot-starter-validation'を追記が必要です。

追加を終えたらバリデーション用のアノテーションを利用できるようになります。
メモアプリでバリデーションが必要なクラスはMemoFormです。

application/resources/MemoForm.java
@Data
public class MemoForm {

    private int id;
    @NotBlank(message = "{validation.title-required}")
    @Size(min = 1, max = 20, message = "{validation.title-required}")
    private String title;
    private String description;
    private String category;
    @NotBlank(message = "{validation.date-required}")
    @Pattern(regexp = "^[0-9]{4}\\/(0[1-9]|1[0-2])\\/(0[1-9]|[12][0-9]|3[01])$", message = "{validation.date-pattern}")
    private String date;
    @Range(min = 0, max = 1, message = "{validation.markDiv}")
    private int markDiv;

}

バリデーション発生時のメッセージについては別のファイルへ定義しています。
これはバリデーション対象のオブジェクトが増えた場合に、ハードコードを増やさないための対策です。

javaディレクトリ直下のresoucesディレクトリへmessages.propertiesを作成して以下を記載してください。

main/java/resources/messages.properties
validation.title-required=titleの指定は必須です
validation.date-required=dateに空白文字を指定してはいけません
validation.date-pattern=dateはYYYY/MM/DDの形式で入力してください
validation.markDiv=mark_divは0か1のどちらかのみ指定可能です

これでバリデーションエラー発生時に表示されるメッセージの設定が完了です。

3-2.例外処理の実装

続いて、例外処理を実装します。現時点では例外が発生してもSpring Bootで対応するExceptionクラスが表示されるか
レスポンスがないため、ユーザーからすると何が起きているのかがわかりません。

例えば、現状は指定したIDに紐づくデータが存在しない場合、空のレスポンスが返りますが、この修正を行います。

まずはリソースが見つからない時の例外クラスを用意します。

domain/exception/ResourceNotFound.java
public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}

次にService側にメモを取得しているServiceで検索結果が存在しない時にThrowする処理を追記します。
現状 findByIdの実行時に例外をThrowする可能性があるため定義しておきます。

findById(id).orElseThrow(~~~)でfindByIdメソッドで例外が発生した際にorElseThrow(~~~)の例外が呼び出されます。


@Service
@RequiredArgsConstructor
public class MemoServiceImpl implements MemoService {

    ~~~~~省略~~~~~

    @Override
    public Memo findById(int id) {
        return memoMapper.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("指定したIDに紐づくメモは存在しません。"));
    }

    ~~~~~省略~~~~~

    @Override
    public void deleteMemo(int id) {
        memoMapper.findById(id).orElseThrow(() -> new ResourceNotFoundException("指定したIDに紐づくメモは存在しません。"));
        memoMapper.deleteMemo(id);
    }

    @Override
    public void updateMemo(int id, MemoForm form) {
        memoMapper.findById(id).orElseThrow(() -> new ResourceNotFoundException("指定したIDに紐づくメモは存在しません。"));
        memoMapper.updateMemo(id, form);
    }

}

最後に Controller側に @ExceptionHandler を付与したメソッドを追加すると例外ハンドリングの完了です。

application/controller/MemoControllerAdvice.java
@RestControllerAdvice
public class MemoControllerAdvice {
    // 以下の定数定義については例外が増えた場合もハードコードが増えないようにするための対策です
    private static final String TIMESTAMP = "timestamp";
    private static final String STATUS = "status";
    private static final String ERROR = "error";
    private static final String MESSAGE = "message";
    private static final String PATH = "path";
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd - HH:mm:ss Z");

    @ExceptionHandler(value = ResourceNotFoundException.class)
    public ResponseEntity<Map<String, String>> handleNoResourceFound(ResourceNotFoundException e, HttpServletRequest request) {
        var body = Map.of(
                TIMESTAMP, ZonedDateTime.now().format(formatter),
                STATUS, String.valueOf(HttpStatus.NOT_FOUND.value()),
                ERROR, HttpStatus.NOT_FOUND.getReasonPhrase(),
                MESSAGE, e.getMessage(),
                PATH, request.getRequestURI()
        );
        return new ResponseEntity<>(body, HttpStatus.NOT_FOUND);
    }

    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, List<String>>> handleValidationErrors(MethodArgumentNotValidException ex) {
        List<String> errors = ex.getBindingResult().getFieldErrors().stream()
                .map(FieldError::getDefaultMessage)
                .collect(Collectors.toList());
        return new ResponseEntity<>(getErrorsMap(errors), new HttpHeaders(), HttpStatus.BAD_REQUEST);
    }

    // バリデーション発生時にエラーメッセージが表示されるよう定義しています。
    private Map<String, List<String>> getErrorsMap(List<String> errors) {
        Map<String, List<String>> errorResponse = new HashMap<>();
        errorResponse.put("入力値エラー", errors);
        return errorResponse;
    }
}

これで例外ハンドリングは完了です。わざと例外orバリデーションエラーが発生するようにリクエストを送信して試してみてください。

4. 単体テストの実装

Javaでテストを行ううえで最も使われているのはJunitです。

build.gradletestImplementation 'org.springframework.boot:spring-boot-starter-test'を追加してテストを実装できるようにしましょう。

テスト用のディレクトリはデフォルトで作成されているはずなので、そちらにテストコードを実装しましょう。

アノテーション 意味
Spring @ExtendWith テストクラスを何らかの拡張をする際に利用するアノテーション
Mockito @InjectMocks @Mockのモックインスタンスを差し込むために利用するアノテーション
Mockito @Mock モックを作成することを宣言するアノテーション
Junit @Test Junitが特定のメソッドをテスト対象だと認識するために利用するアノテーション

まずはサービス層に対する単体テストを実装します。

src/test/java/com.rtjavamemoapp/domain/service/MemoServiceImplTest.java
@ExtendWith(MockitoExtension.class)
class MemoServiceImplTest {

    @InjectMocks
    MemoServiceImpl memoServiceImpl;

    @Mock
    MemoMapper memoMapper;

    List memoList = List.of(
            new Memo(1,"第1回課題", "Java", "Hello World","2022/12/31",1),
            new Memo(2,"第2回課題", "Java", "オリジナルクラスの実装","2023/01/01",1),
            new Memo(3,"第3回課題", "Java", "ListとMapの練習","2023/01/02",1),
            new Memo(4, "第4回課題", "Java", "Streamをお試し","2023/01/09",0));
    Memo memo = new Memo(1,"第1回課題", "Java", "Hello World","2022/12/31",1);

    @Test
    void 全てのメモを取得できること() {
        doReturn(memoList).when(memoMapper).findAll();

        List<Memo> actual = memoServiceImpl.findAll();
        assertThat(actual).isEqualTo(memoList);
    }

    @Test
    void 存在するメモのIDを指定した時に正常にメモが返されること() throws Exception {
        doReturn(Optional.of(memo)).when(memoMapper).findById(1);

        Memo actual = memoServiceImpl.findById(1);
        assertThat(actual).isEqualTo(
            new Memo(1, "第1回課題", "Java", "Hello World", "2022/12/31", 1));
    }

    @Test
    void メモを作成できること() {
        doNothing().when(memoMapper).createMemo(any(MemoForm.class));

        MemoForm form = new MemoForm();
        form.setTitle("第5回課題");
        form.setCategory("Java");
        form.setDescription("プルリクエストの作成");
        form.setDate("2023/01/16");
        form.setMarkDiv(0);

        memoServiceImpl.createMemo(form);
        verify(memoMapper).createMemo(any(MemoForm.class));
    }

    @Test
    void 指定したIDのメモを削除できること() {
        doReturn(Optional.of(memo)).when(memoMapper).findById(1);

        memoServiceImpl.deleteMemo(1);
        verify(memoMapper).deleteMemo(1);
    }

    @Test
    void 指定したIDのメモを更新できること() {
        doReturn(Optional.of(memo)).when(memoMapper).findById(1);

        MemoForm form = new MemoForm();
        form.setTitle("第1回課題");
        form.setCategory("Java");
        form.setDescription("Hello World");
        form.setDate("2022/12/31");
        form.setMarkDiv(1);

        memoServiceImpl.updateMemo(1, form);
        verify(memoMapper).updateMemo(eq(1), any(MemoForm.class));
    }

    @Test
    void 存在しないメモのIDを指定した時にResourceNotFoundExceptionが発生すること() {
        doReturn(Optional.empty()).when(memoMapper).findById(1);

        assertThatThrownBy(() -> memoServiceImpl.findById(1))
            .isInstanceOf(ResourceNotFoundException.class);

        assertThatThrownBy(() -> memoServiceImpl.findById(1)).hasMessage(
            "指定したIDに紐づくメモは存在しません。");
    }

    @Test
    void 存在しないメモのIDを指定して更新した時にResourceNotFoundExceptionが発生すること() {
        doReturn(Optional.empty()).when(memoMapper).findById(1);

        MemoForm form = new MemoForm();
        form.setTitle("第1回課題");
        form.setCategory("Java");
        form.setDescription("Hello World");
        form.setDate("2022/12/31");
        form.setMarkDiv(1);

        assertThatThrownBy(() -> memoServiceImpl.updateMemo(1, form))
            .isInstanceOf(ResourceNotFoundException.class);

        assertThatThrownBy(() -> memoServiceImpl.findById(1)).hasMessage(
            "指定したIDに紐づくメモは存在しません。");
    }

    @Test
    void 存在しないメモのIDを指定して削除した時にResourceNotFoundExceptionが発生すること() {
        doReturn(Optional.empty()).when(memoMapper).findById(1);

        assertThatThrownBy(() -> memoServiceImpl.deleteMemo(1))
            .isInstanceOf(ResourceNotFoundException.class);
        assertThatThrownBy(() -> memoServiceImpl.findById(1)).hasMessage(
            "指定したIDに紐づくメモは存在しません。");
    }
}

5. DBテスト

次にDBテストを実装します。DBテストではデータが意図した通りに追加/更新/削除されているかの確認が必要になります。

まずはDBテストで使用するアノテーションを紹介します。

アノテーション 意味
MyBatis @MyBatisTest MyBatisコンポーネントをテストする際に使用するためのアノテーション
Spring @AutoConfigureTestDatabase テストデータベースを構成するために使用するためのアノテーション
Spring @Autowired フィールドの変数をDIコンテナに登録し使用するためのアノテーション
Spring @Sql テストクラス/メソッドに対して付与し、指定されたSQL文を実行するために使用するアノテーション
Spring @Transactional トランザクション管理を行うことを宣言するアノテーション。アノテーションが付与されたメソッドが呼び出されたタイミングでトランザクションが開始される

その後、テストを実行するようのデータを準備します。

src/test/resources/sqlannotation/insert-memos.sql
INSERT INTO memos (id, title, category, description, date, mark_div) VALUES (1, "第1回課題", "Java", "Hello World", "2022/12/31",1);
INSERT INTO memos (id, title, category, description, date, mark_div) VALUES (2, "第2回課題", "Java", "オリジナルクラスの実装", "2023/01/01",1);
INSERT INTO memos (id, title, category, description, date, mark_div) VALUES (3, "第3回課題", "Java", "ListとMapの練習", "2023/01/02",1);
INSERT INTO memos (id, title, category, description, date, mark_div) VALUES (4, "第4回課題", "Java", "Streamをお試し", "2023/01/09",0);

src/test/resources/sqlannotation/delete-memos.sql
DELETE FROM memos;

データの前準備ができたら、いよいよDBテストの実装です。

src/test/java/com.rtjavamemoapp/infrastructure/mapper/MemoMapperTest.java
@MyBatisTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class MemoMapperTest {

  @Autowired
  MemoMapper memoMapper;

  @Test
  @Sql(
      scripts = {"classpath:/sqlannotation/delete-memos.sql",
          "classpath:/sqlannotation/insert-memos.sql"},
      executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD
  )
  @Transactional
  void 全てのメモを取得できること() {
    List<Memo> memos = memoMapper.findAll();
    assertThat(memos)
        .hasSize(4)
        .contains(
            new Memo(1, "第1回課題", "Java", "Hello World", "2022/12/31", 1),
            new Memo(2, "第2回課題", "Java", "オリジナルクラスの実装", "2023/01/01", 1),
            new Memo(3, "第3回課題", "Java", "ListとMapの練習", "2023/01/02", 1),
            new Memo(4, "第4回課題", "Java", "Streamをお試し", "2023/01/09", 0)
        );
  }

  @Test
  @Sql(
      scripts = {"classpath:/sqlannotation/delete-memos.sql",
          "classpath:/sqlannotation/insert-memos.sql"},
      executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD
  )
  @Transactional
  void 指定したIDのメモを取得できること() {
    Optional<Memo> memo = memoMapper.findById(1);
    assertThat(memo).isEqualTo(
        Optional.of(new Memo(1, "第1回課題", "Java", "Hello World", "2022/12/31", 1)));
  }

  @Test
  @Sql(
      scripts = {"classpath:/sqlannotation/delete-memos.sql",
          "classpath:/sqlannotation/insert-memos.sql"},
      executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD
  )
  @Transactional
  void メモを作成できること() {
    MemoForm form = new MemoForm();
    form.setTitle("第5回課題");
    form.setCategory("Java");
    form.setDescription("プルリクエストの作成");
    form.setDate("2023/01/16");
    form.setMarkDiv(0);

    memoMapper.createMemo(form);
    List<Memo> memos = memoMapper.findAll();
    assertThat(memos).hasSize(5);

    int newMemoId = memos.get(memos.size() - 1).getId();
    assertThat(memoMapper.findById(newMemoId)
        .map(Memo::getTitle)
        .orElseThrow()).isEqualTo("第5回課題");
    assertThat(memoMapper.findById(newMemoId)
        .map(Memo::getDescription)
        .orElseThrow()).isEqualTo("プルリクエストの作成");
    assertThat(memoMapper.findById(newMemoId)
        .map(Memo::getDate)
        .orElseThrow()).isEqualTo("2023/01/16");
  }

  @Test
  @Sql(
      scripts = {"classpath:/sqlannotation/delete-memos.sql",
          "classpath:/sqlannotation/insert-memos.sql"},
      executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD
  )
  @Transactional
  void 指定したIDのメモを削除できること() {
    List<Memo> memos = memoMapper.findAll();
    assertThat(memos).hasSize(4);
    memoMapper.deleteMemo(1);
    memos = memoMapper.findAll();
    assertThat(memos).hasSize(3);
  }

  @Test
  @Sql(
      scripts = {"classpath:/sqlannotation/delete-memos.sql",
          "classpath:/sqlannotation/insert-memos.sql"},
      executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD
  )
  @Transactional
  void 指定したIDのメモを更新できること() {
    MemoForm form = new MemoForm();
    form.setTitle("第5回課題");
    form.setCategory("Java");
    form.setDescription("プルリクエストの作成");
    form.setDate("2023/01/16");
    form.setMarkDiv(0);

    memoMapper.updateMemo(1, form);
    assertThat(memoMapper.findById(1).map(Memo::getTitle).orElseThrow()).isEqualTo("第5回課題");
  }
}

6. 結合テスト

単体テスト / DBテストの実装を終えたところで最後に結合テストを実装します。

結合テストで使用するアノテーションは以下です。

アノテーション 意味
Spring @SpringBootTest Spring Boot でテストを行う時に利用するアノテーション
Spring @AutoConfigureMockMvc MockMVCを有効にするために利用するアノテーション
src/test/java/com.rtjavamemoapp/integrationtest/MemoRestApiIntegrationTest.java
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Sql(
    scripts = {"classpath:/sqlannotation/delete-memos.sql",
        "classpath:/sqlannotation/insert-memos.sql"},
    executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD
)
@Transactional
public class MemoRestApiIntegrationTest {

  @Autowired
  MockMvc mockMvc;

  ObjectMapper objectMapper = new ObjectMapper();

  @Test
  void メモを全件取得した時のレスポンス内容が正しいこと() throws Exception {
    String response = mockMvc.perform(MockMvcRequestBuilders.get("/memos"))
        .andExpect(MockMvcResultMatchers.status().isOk())
        .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);

    JSONAssert.assertEquals("""
            [
              {
                "id": 1,
                "title": "第1回課題",
                "category": "Java",
                "description": "Hello World",
                "date": "2022/12/31",
                "markDiv": 1
              },
              {
                "id": 2,
                "title": "第2回課題",
                "category": "Java",
                "description": "オリジナルクラスの実装",
                "date": "2023/01/01",
                "markDiv": 1
              },
              {
                "id": 3,
                "title": "第3回課題",
                "category": "Java",
                "description": "ListとMapの練習",
                "date": "2023/01/02",
                "markDiv": 1
              },
              {
                "id": 4,
                "title": "第4回課題",
                "category": "Java",
                "description": "Streamをお試し",
                "date": "2023/01/09",
                "markDiv": 0
              }
            ]
            """
        , response, JSONCompareMode.STRICT);

    List<MemoDTO> listMemo = objectMapper
        .readValue(response, new TypeReference<List<MemoDTO>>() {
        });

    assertThat(listMemo.size()).isEqualTo(4);
  }

  @Test
  void 指定したIDに紐づくメモを取得した時のレスポンス内容が正しいこと() throws Exception {
    String response = mockMvc.perform(MockMvcRequestBuilders.get("/memos/1")).
        andExpect(MockMvcResultMatchers.status().isOk())
        .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);

    JSONAssert.assertEquals(
        """
            {
              "id": 1,
              "title": "第1回課題",
              "category": "Java",
              "description": "Hello World",
              "date": "2022/12/31",
              "markDiv": 1
            }
            """, response, JSONCompareMode.STRICT);

    MemoDTO memo = objectMapper.readValue(response, MemoDTO.class);
    assertThat(memo.getId()).isEqualTo(1);
  }

  @Test
  void 存在しないID指定しメモを取得する時のレスポンス内容が正しいこと() throws Exception {
    mockMvc.perform(MockMvcRequestBuilders.get("/memos/100"))
        .andExpect(MockMvcResultMatchers.status().isNotFound());
  }

  @Test
  void メモを新規作成した時のレスポンス内容が正しいこと() throws Exception {

    String countDbBeforeCreate = mockMvc.perform(MockMvcRequestBuilders.get("/memos"))
        .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);

    List<MemoDTO> beforeCreateMemo = objectMapper
        .readValue(countDbBeforeCreate, new TypeReference<List<MemoDTO>>() {
        });

    mockMvc.perform(MockMvcRequestBuilders.post("/memos")
        .contentType(MediaType.APPLICATION_JSON)
        .content("""
            {
                "title": "第100回課題",
                "category": "Java",
                "description": "100回目の課題",
                "date": "2025/01/30",
                "markDiv": 1
            }
            """)
    ).andExpect(MockMvcResultMatchers.status().isCreated());

    String countDbAfterCreate = mockMvc.perform(MockMvcRequestBuilders.get("/memos"))
        .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);

    List<MemoDTO> afterCreateMemo = objectMapper
        .readValue(countDbAfterCreate, new TypeReference<List<MemoDTO>>() {
        });
    assertThat(beforeCreateMemo.size()).isLessThan(afterCreateMemo.size());
  }

  @Test
  void メモを更新した時のレスポンス内容が正しいこと() throws Exception {
    mockMvc.perform(MockMvcRequestBuilders.patch("/memos/1")
        .contentType(MediaType.APPLICATION_JSON)
        .content("""
            {
                "title": "第100回課題",
                "category": "Java",
                "description": "100回目の課題",
                "date": "2025/01/30",
                "markDiv": 1
            }
            """)
    ).andExpect(MockMvcResultMatchers.status().isOk());

    String response = mockMvc.perform(MockMvcRequestBuilders.get("/memos/1"))
        .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);

    MemoDTO memo = objectMapper.readValue(response, MemoDTO.class);
    assertThat(memo.getTitle()).isEqualTo("第100回課題");
    assertThat(memo.getCategory()).isEqualTo("Java");
    assertThat(memo.getDescription()).isEqualTo("100回目の課題");
    assertThat(memo.getDate()).isEqualTo("2025/01/30");
    assertThat(memo.getMarkDiv()).isEqualTo(1);
  }

  @Test
  void 存在しないIDを指定してメモを更新する時のレスポンス内容が正しいこと() throws Exception {
    mockMvc.perform(MockMvcRequestBuilders.patch("/memos/100")
            .contentType(MediaType.APPLICATION_JSON)
            .content("""
                {
                    "title": "第100回課題",
                    "category": "Java",
                    "description": "100回目の課題",
                    "date": "2025/01/30",
                    "markDiv": 1
                }
                """)
        ).andExpect(MockMvcResultMatchers.status().isNotFound());
  }

  @Test
  void メモを削除した時のレスポンス内容が正しいこと() throws Exception {

    String countDbBeforeDelete = mockMvc.perform(MockMvcRequestBuilders.get("/memos"))
        .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);

    List<MemoDTO> beforeDeleteMemo = objectMapper
        .readValue(countDbBeforeDelete, new TypeReference<List<MemoDTO>>() {
        });

    mockMvc.perform(MockMvcRequestBuilders.delete("/memos/1"))
        .andExpect(MockMvcResultMatchers.status().isOk());

    String countDbAfterDelete = mockMvc.perform(MockMvcRequestBuilders.get("/memos"))
        .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);

    List<MemoDTO> afterDeleteMemo = objectMapper
        .readValue(countDbAfterDelete, new TypeReference<List<MemoDTO>>() {
        });

    assertThat(beforeDeleteMemo.size()).isGreaterThan(afterDeleteMemo.size());
  }

  @Test
  void 存在しないIDを指定して削除する時のレスポンス内容が正しいこと() throws Exception {
    mockMvc.perform(MockMvcRequestBuilders.delete("/memos/100"))
        .andExpect(MockMvcResultMatchers.status().isNotFound());
  }
}

7. (おまけ) GitHub ActionsでCIの実装

作成したアプリケーションにはCIを実装しており、プルリクエストの作成時に自動でテスト実行されるようになっています。

これにより変更を加えた時にも安全に開発を進めることができるので、GitHub Actionsも実装してみるのもおすすめです。

github-actions.yml
name: Unit Testing by Github Actions

on:
  pull_request:
    branches:
      - main
      - 'feature/*'
  push:
    branches: [ "main" ]

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      SPRING_DATASOURCE_URL: ${{secrets.SPRING_DATASOURCE_URL}}
      SPRING_DATASOURCE_USER_NAME: ${{secrets.SPRING_DATASOURCE_USER_NAME}}
      SPRING_DATASOURCE_USER_PASS: ${{secrets.SPRING_DATASOURCE_USER_PASS}}

    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Set up JDK 17
        uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'zulu'

      - name: start docker
        run: docker compose up -d

      - name: Add exec Permission
        run: chmod +x gradlew

      - name: Test with Gradle
        id: test
        run: ./gradlew test
        if: always()

GitHub Actions の実装については記事の本筋ではないので、詳細は割愛します。

8. おわりに

現状はバックエンドのアプリケーションのみですが、以下のような新たな課題も見つかったので
引き続き学習していきます!

  • React × Next.js でフロントを用意し、本アプリケーションと連携して動作するよう実装したい
  • テストの重要性を体感したため、適切なテスト設計ができるようにスピードを上げたい
  • Spring Boot プロジェクトのデプロイできてない。。。

上記の課題を解消しつつ、オリジナルのアプリ作成時にSpring Bootで開発してみます。

以上、Spring Boot 開発の備忘録でした!

7
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?