はじめに
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ボタンをクリックしてください。
プロジェクトのファイル生成が終わると、zipファイルがダウンロードされるので、
Zipファイルを解凍して、IntelliJからプロジェクトを開きましょう。
アプリケーションを起動すると src フォルダの main > java > com.demo フォルダ内に以下のファイルが作成されているはずです。
このファイルの main メソッドを実行するとアプリケーションが起動するので、正常に起動するか確認してください
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コンテナ用のファイルを作成してください。
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:
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へ初期データを作成していました。
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 を定義します。
@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で返します。
@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処理で利用します。
@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ステップで実装を進めます。
- Controller でリクエストに応じた処理を定義
- Service の定義
- Mapper の処理を定義
2-2.Read処理
今回は /memos
にGETリクエストをするとデータベースのメモデータをレスポンスとして返す処理をしています。
@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);
}
}
サービスの振る舞いをインターフェースとして定義します。
@Service
@RequiredArgsConstructor
public interface MemoService {
// findAllメソッドを定義
List<Memo> findAll();
// findByIdメソッドを定義
Memo findById(int id);
}
インターフェースを実装して、必要な業務処理を実装します。
@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);
}
}
@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処理を実装します。
@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処理と同様振る舞いを定義します。
@Service
@RequiredArgsConstructor
public interface MemoService {
~~~~~省略~~~~~
void createMemo(MemoForm form);
}
@Service
@RequiredArgsConstructor
public class MemoServiceImpl implements MemoService {
private final MemoMapper memoMapper;
~~~~~省略~~~~~
@Override
public void createMemo(MemoForm form) {
memoMapper.createMemo(form);
}
}
Serviceの定義を終えたら、Mapper側でSQLを定義しましょう。
@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 の処理実装の流れと同様なので、コード内のコメントは割愛します。
@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"));
}
}
@Service
@RequiredArgsConstructor
public interface MemoService {
~~~~~省略~~~~~
void deleteMemo(int id);
}
@Service
@RequiredArgsConstructor
public class MemoServiceImpl implements MemoService {
private final MemoMapper memoMapper;
~~~~~省略~~~~~
@Override
public void deleteMemo(int id) {
memoMapper.findById(id);
memoMapper.deleteMemo(id);
}
}
@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上のデータを更新するための情報が増えているので、適切にハンドリングしましょう。
@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"));
}
}
@Service
@RequiredArgsConstructor
public interface MemoService {
~~~~~省略~~~~~
void updateMemo(int id, MemoForm form);
}
@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);
}
}
@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.gradle
にimplementation 'org.springframework.boot:spring-boot-starter-validation'
を追記が必要です。
追加を終えたらバリデーション用のアノテーションを利用できるようになります。
メモアプリでバリデーションが必要なクラスはMemoForm
です。
@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
を作成して以下を記載してください。
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に紐づくデータが存在しない場合、空のレスポンスが返りますが、この修正を行います。
まずはリソースが見つからない時の例外クラスを用意します。
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 を付与したメソッドを追加すると例外ハンドリングの完了です。
@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.gradle
に testImplementation 'org.springframework.boot:spring-boot-starter-test'
を追加してテストを実装できるようにしましょう。
テスト用のディレクトリはデフォルトで作成されているはずなので、そちらにテストコードを実装しましょう。
アノテーション | 意味 | |
---|---|---|
Spring | @ExtendWith | テストクラスを何らかの拡張をする際に利用するアノテーション |
Mockito | @InjectMocks | @Mockのモックインスタンスを差し込むために利用するアノテーション |
Mockito | @Mock | モックを作成することを宣言するアノテーション |
Junit | @Test | Junitが特定のメソッドをテスト対象だと認識するために利用するアノテーション |
まずはサービス層に対する単体テストを実装します。
@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 | トランザクション管理を行うことを宣言するアノテーション。アノテーションが付与されたメソッドが呼び出されたタイミングでトランザクションが開始される |
その後、テストを実行するようのデータを準備します。
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);
DELETE FROM memos;
データの前準備ができたら、いよいよDBテストの実装です。
@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を有効にするために利用するアノテーション |
@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も実装してみるのもおすすめです。
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 開発の備忘録でした!