【備忘録】Spring Boot+Postgres+MyBatisでRESTfulなAPIを作成してCRUDする

公開:2021/01/03
更新:2021/01/14
62 min読了の目安(約56500字TECH技術記事

はじめに

概要

備忘録ということで,Spring Boot+Postgres+MyBatisを用いたRESTfulなWeb APIを作成して,テーブルに対するCRUD処理 (CREATE/READ/UPDATE/DELETE) を実装してみたいと思います.

なお今回は簡単のため,PostgreSQLはDockerで立ち上げます.また,タイトルにもある通り,ORマッパーとしてはMyBatisを利用します.

ゴール

データベースに接続するWebアプリケーション開発者が開発を始めるときに,環境設定+一通りの処理フローの実装+単体テストを迷わず実装できるようにすることが本記事執筆の目的となります.自分もよく細かい部分の書き方を忘れて調べたりすることが多いので,そのときに見てとりあえず処理の書き方がわかるようなものが書けていればいいなと思っています.

具体的には,下記ポイントに焦点を置き,体系的に整理します.

  • Spring Bootを用いたRESTfulなWeb APIな作り方
  • Spring→データベースへの繋ぎ方
  • ORマッパーMyBatisの書き方
  • データベース接続・Dependency Injectionの単体テストの書き方

そのため,テーブルとテーブルのJOINですとか,難しいSQLですとか,難解なビジネスロジックといった概念は今回出てきません.そのあたりは開発におけるOptionalな部分だと思いますので,もし興味のある方がいらっしゃる場合には,他に整理されている記事と本記事の内容を融合して開発頂ければと思います.

アプリケーション・アーキテクチャ

とても一般的だと思いますが,今回採用するアプリケーション・アーキテクチャは以下の通りとなります.

クライアントからのリクエストをControllerクラスで受け付けて,Serviceクラスで加工し,Mapper (Repository) クラスでデータベースへのCRUD処理を行います.MyBatisを利用するので,SQLはXMLに外出しして利用します.

なお,リスポンスやデータベース用のDTOクラスは別途作成します.

開発環境

開発環境は以下の通りです.

分類 ツール/バージョン
OS macOS Big Sur 11.1
Java (openjdk) 11.0.2
Spring Boot 2.4.1
MyBatis 2.1.4
Docker 20.10.0
docker-compose 1.27.4
Postgres 13-alpine
IDE IntelliJ IDEA
StyleGuide Google Java Style
REST Client Postman

ソースコード

この記事のソースコードは GitHub で公開していますので,興味のある方はそちらも確認頂ければと思います.

開発準備

前提

下記ツールがインストール済みであることを前提として開発準備を行います.

  • JDK
  • Docker
  • docker-compose
  • IDE (e.g. IntelliJ)
  • REST Client (e.g. Postman)

Spring Bootの雛形をダウンロードする

Spring Bootの雛形は spring initializr のサイトからダウンロードします.IDEで直接作成しても良いです.

各項目は以下のように設定しました.Dependenciesは ADD DEPENDENCIES... を押して検索をかけてあげると見つけやすいです.設定後はページ下部の GENERATE を押下してZipファイルをダウンロードしてください.

項目
Project Gradle Project
Language Java
Spring Boot 2.4.1
Project Metadata (Group) com.numacci
Project Metadata (Artifact) restapi-postgres-crud-tutorial
Project Metadata (Name) restapi-postgres-crud-tutorial
Project Metadata (Package Name) com.numacci.api
Project Metadata (Packaging) War
Project Metadata (Java) 11
Dependencies Validation
Dependencies Spring Web
Dependencies MyBatis Framework
Dependencies PostgreSQL Driver

IDE (IntelliJ) に雛形をインポートする

先程ダウンロードしたプロジェクトの雛形をIDEにインポートします.まずは下記コマンドを実行して,Zipファイルをワークスペースのディレクトリまで移動→解凍してください.筆者の場合,ワークスペースは $HOME/workspace-java ですので,適宜読み替えてください.

$ cd ~/workspace-java
$ mv ~/Downloads/restapi-postgres-crud-tutorial.zip .
$ unzip restapi-postgres-crud-tutorial.zip
$ rm -rf restapi-postgres-crud-tutorial.zip

次に,IntelliJ IDEAを起動し,File→Open...→Finderで ~/workspace-java/restapi-postgres-crud-tutorial を選択してOpenします.

Openすると自動でGradleのプロセスが走り,IntelliJ IDEAがSpringを実行するための設定をよしなにやってくれます.(ConfigurationのMain Classの設定とか)

以上でIntelliJ IDEAにプロジェクトの雛形を取り込むことができました.

起動設定変更

Webアプリの起動を確認する前に,起動の設定を少しいじっておきます.具体的には,アプリケーションの起動ポートとコンテキスト・パスを以下のようにカスタマイズしました.

項目 設定値 デフォルト値
ポート 8081 8080
コンテキスト・パス /api -

src/main/resources/application.yml に以下を追記すればOKです.なお,はじめは application.properties になっているかもしれないので,拡張子は yml に変更しましょう.(可読性向上のため)

application.yml
# Web
server:
  port: 8081
  servlet:
    context-path: /api

ポートを変更する理由ですが,開発に利用していた端末が別プロセスで8080ポートを使っていたので変えました.変える必要がない人は不要の設定です.コンテキスト・パスについてはAPIなので /api としました.デプロイ用のwarファイル名と揃えてあげると結構嬉しいことになりますが,ここでは割愛します.[1]

Postgresの起動・初期化データロード

ちょっと順番が前後してしまって申し訳ないですが,アプリの起動確認の前にデータベースの起動と初期設定を終わらせてしまいます.

開発用のデータベースとしては今回PostgreSQLを利用します.MySQLやMongoDB等別のDBを利用したい方は 開発用データベースのためのDocker Composeの設定方法 (MySQL/PostgreSQL/MongoDB編) 等を見て頂ければ docker-compose.yml の書き方がわかるかなと思います.今回利用するPostgreSQLの docker-compose.yml もこちらから引用させて頂きました.

まずプロジェクトルートで docker-compose.yml を新規作成し,以下入力してください.

docker-compose.yml
version: '3'

services:
  postgres:
    image: postgres:13.1-alpine
    restart: always
    environment:
      TZ: "Asia/Tokyo"
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: appuser
      POSTGRES_DB: app_db
    ports:
      - 5432:5432
    volumes:
      - postgres:/var/lib/postgresql/data
      - ./postgres/initdb:/docker-entrypoint-initdb.d

  pgadmin:
    image: dpage/pgadmin4
    restart: always
    ports:
      - 18080:80
    environment:
      PGADMIN_DEFAULT_EMAIL: admin@example.com
      PGADMIN_DEFAULT_PASSWORD: admin
    volumes:
      - pgadmin:/var/lib/pgadmin
    depends_on:
      - postgres

volumes:
  postgres:
  pgadmin:

続いて,プロジェクトルートで postgres/initdb ディレクトリを作成し,初期化用のSQLファイルを作成します.以下のコマンドをプロジェクトのルートディレクトリで実行します.

$ mkdir -p postgres/initdb
$ touch postgres/initdb/01_DDL_CREATE_TABLE.sql
$ touch postgres/initdb/02_DML_INSERT_INIT_DATA.sql

実行後のプロジェクト構成は以下のようになります.

SQLファイル名を連番にするのはPostgresの初期化スクリプトの実行順序を保証するためです.各種SQLファイルには以下のDDL/DMLを書いておきます.

01_DDL_CREATE_TABLE.sql
CREATE TABLE customer (
    id VARCHAR(10) PRIMARY KEY,
    username VARCHAR(50) NOT NULL,
    email VARCHAR(50) NOT NULL,
    phone_number VARCHAR(11) NOT NULL,
    post_code VARCHAR(7) NOT NULL
);
02_DML_INSERT_INIT_DATA.sql
INSERT INTO customer VALUES ('001', 'user001', 'test.user.001@example.com', '12345678901', '1234567');
INSERT INTO customer VALUES ('002', 'user002', 'test.user.002@example.com', '23456789012', '2345671');
INSERT INTO customer VALUES ('003', 'user003', 'test.user.003@example.com', '34567890123', '3456712');
INSERT INTO customer VALUES ('004', 'user004', 'test.user.004@example.com', '45678901234', '4567123');
INSERT INTO customer VALUES ('005', 'user005', 'test.user.005@example.com', '56789012345', '5671234');
INSERT INTO customer VALUES ('006', 'user006', 'test.user.006@example.com', '67890123456', '6712345');
INSERT INTO customer VALUES ('007', 'user007', 'test.user.007@example.com', '78901234567', '7123456');
INSERT INTO customer VALUES ('008', 'user008', 'test.user.008@example.com', '89012345678', '1234567');
INSERT INTO customer VALUES ('009', 'user009', 'test.user.009@example.com', '90123456789', '2345671');
INSERT INTO customer VALUES ('010', 'user010', 'test.user.010@example.com', '01234567890', '3456712');

以上でPostgresの起動とテーブル初期化の準備が整ったので,早速Dockerを起動してみます.以下のコマンドをプロジェクトのルートディレクトリで実行してください.

$ docker-compose up

Creating network "restapi-postgres-crud-tutorial_default" with the default driver
Creating volume "restapi-postgres-crud-tutorial_postgres" with default driver
Creating volume "restapi-postgres-crud-tutorial_pgadmin" with default driver
Pulling postgres (postgres:13.1-alpine)...
13.1-alpine: Pulling from library/postgres

# ...(中略)

Status: Downloaded newer image for dpage/pgadmin4:latest
Creating restapi-postgres-crud-tutorial_postgres_1 ... done
Creating restapi-postgres-crud-tutorial_pgadmin_1  ... done

最後に,初期化用のデータがデータベースに挿入されているか確認します.http://localhost:18080/ にアクセスし,pgadminコンソールにログインします.ログイン情報は docker-compose.yml で設定した下記値を利用します.

項目
Email Address / Username admin@example.com
Password admin

ログイン後,Add New Server をクリックすると,Create - Server モーダルが表示されます.以下の項目を設定し,Save を押下します.

タブ 項目
General Name appdb
Connection Host name/address postgres
Connection Port 5432
Connection Maintenance database postgres
Connection Username appuser
Connection Password appuser


これで接続設定ができました.ページ左部の Browser から [Servers > appdb > Databases > app_db] を選択します.各種メトリクスがリアルタイムで見れたりして面白いのですが,ここではデータが格納されているのかわかりません.ので,ページ上部の [Tools > Query Tool] をクリックしてクエリ入力用のパネルを表示させてください.

Query Editorに下記SQLを入力し,ページ上部の実行ボタン (▶) を押すと,customer テーブルに初期化用のデータが入っていることが確認できます.

はい.長かったですが以上でデータベースの起動・初期化データロード・起動確認作業が完了となります.続いて,アプリ→データベースへの接続設定をしていきます.

データソース/MyBatis設定

src/main/resources/application.ymlspring.datasource の設定を追記します.JDBCドライバのクラス名,PostgresのURL,ユーザ名,パスワードをそれぞれ以下のように設定します.

application.yml
# データソース
spring:
  datasource:
    driverClassName: org.postgresql.Driver
    url: jdbc:postgresql://localhost:5432/app_db
    username: appuser
    password: appuser

上記に加え,mybatis.configuration.map-underscore-to-camel-case の設定を追記します.このプロパティをTrueに設定すると,キャメルケースとスネークケースの命名の差分をMyBatis側で吸収し,適切にテーブル・カラム名とクラス・フィールド名をマッピングしてくれます.

application.yml
# MyBatis
mybatis:
  configuration:
    map-underscore-to-camel-case: true

application.yml の編集はここまでとなります.最終形は以下のようになります.

application.yml
# Web
server:
  port: 8081
  servlet:
    context-path: /api
# Datasource
spring:
  datasource:
    driverClassName: org.postgresql.Driver
    url: jdbc:postgresql://localhost:5432/app_db
    username: appuser
    password: appuser
# MyBatis
mybatis:
  configuration:
    map-underscore-to-camel-case: true

Webアプリ起動確認

IntelliJ IDEAへのインポートが成功していて,無事起動できるかを最後に確認しておきます.本来であればインポート直後に確認すべきなのですが,インポート直後はデータソース未定義のため以下のようなERRORが出て起動に失敗してしまいます.ので,起動確認はデータソースの設定後に行うか,テスト用にH2データベースの依存性を追加してから行ってください.

Error starting ApplicationContext. To display the conditions report re-run your application with 'debug' enabled.
2020-12-29 00:41:44.379 ERROR 62249 --- [           main] o.s.b.d.LoggingFailureAnalysisReporter   :

***************************
APPLICATION FAILED TO START
***************************

Description:

Failed to configure a DataSource: 'url' attribute is not specified and no embedded datasource could be configured.

Reason: Failed to determine a suitable driver class


Action:

Consider the following:
	If you want an embedded database (H2, HSQL or Derby), please put it on the classpath.
	If you have database settings to be loaded from a particular profile you may need to activate it (no profiles are currently active).

まず,src/main/java/com.numacci.api 配下に controller パッケージを作成し,CustomerController クラスを作成してください.階層は以下のようになります.

コントローラとしてリクエストを受け付けるための @RestController アノテーションをクラスに付与し,エンドポイント /hello にGETでアクセスできるよう,以下のようにコードを修正します.

CustomerController.java
package com.numacci.api.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class CustomerController {

  @GetMapping("/hello")
  public String hello() {
    return "Hello World.";
  }
}

保存した後,IntelliJ IDEAの右上の Run ボタンを押してアプリケーションを実行してみましょう.ショートカットで [Ctrl+R] でもよいです.以下のログが出れば起動できています.

2020-12-29 15:01:43.650  INFO 70198 --- [           main] a.RestapiPostgresCrudTutorialApplication : Started RestapiPostgresCrudTutorialApplication in 1.442 seconds (JVM running for 2.1)

それでは実際にRESTクライアントから叩いてみましょう.筆者はPostmanのUIが好きなのでPostmanを使います.

HTTPメソッドをGETにセットして,URLには http://localhost:8081/api/hello と書きます.テスト用APIのエンドポイントです.Send ボタンを押してあげると,ページ下部にリスポンスとして Hello World. のメッセージが返ってくることが確認できると思います.

以上で起動確認は終了です.

テスト用ライブラリの追加

最後に,テスト用のライブラリを追加していきます.明示的に追加するライブラリは dbunit のみで,データベース接続部分のテストで利用します.Dependency Injection (DI) 対象クラスのテストでは mockito のモックを利用しますが,デフォルトで依存性が追加されている spring-boot-starter-test によって暗黙的にライブラリに追加されています.

追加後の build.gradle は以下のようになります.

build.gradle
plugins {
    id 'org.springframework.boot' version '2.4.1'
    id 'io.spring.dependency-management' version '1.0.10.RELEASE'
    id 'java'
    id 'war'
}

group = 'com.numacci'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.1.4'
    runtimeOnly 'org.postgresql:postgresql'
    providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
    testImplementation 'org.dbunit:dbunit:2.5.3'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

test {
    useJUnitPlatform()
}

build.gradle に依存性を追加したので,一度Gradleの依存関係をリフレッシュさせます.IntelliJ上で [View > Tool Windows > Gradle] を選択し,Gradle用のタブを開きます.プロジェクト名を右クリックし,Refresh Gradle Dependencies を選択するとリフレッシュが始まり,ライブラリが追加されます.

以上で開発準備ができましたので,次章からは実際にPostgresに接続してCRUDをするアプリケーションを作っていきたいと思います.

実装/UT (CREATE)

まずはCREATEから作成していきます.呼び出し順を考慮してMapper→Service→Controllerの順で実装&テストしていきます.また,必要なDTOは適宜実装していきます.

Mapper/XML (実装)

早速Mapperクラスを実装します,といきたいところですが,返り値の定義をするためにデータベース用のDTOクラスを先に実装してしまいます.src/main/java/com.numacci.api.dto パッケージを作成し,Customer クラスを実装します.

Customer.java
package com.numacci.api.dto;

public class Customer {
  @NotNull
  private String id;

  @NotNull
  private String username;

  @NotNull
  private String email;

  @NotNull
  private String phoneNumber;

  @NotNull
  private String postCode;

  // Setter/Getterは省略
}

各フィールドの制約としては @NotNull のみ課しています.要件に応じて制約違反の場合のメッセージを追加したり,@Pattern で正規表現の制約を課したりしてください.

続いて,src/main/java/com.numacci.api.repository パッケージを作成し,CustomerMapper インタフェースを新規作成します.インタフェースに @Mapper アノテーションをつけることで,SpringがこのMapperの存在を認識してくれるようになり,Autowireが可能になります.

メソッドとしては,CREATEの処理に対応している insert を定義します.引数の customer は新規挿入したいオブジェクト,返り値の int は実際に挿入されたレコードの件数を表します.

CustomerMapper.java
package com.numacci.api.repository;

import com.numacci.api.dto.Customer;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface CustomerMapper {

  /**
   * Insert a new customer record to the database.
   *
   * @param customer customer object which we want to insert
   * @return the number of inserted records
   */
  int insert(Customer customer);
}

さて,この insert メソッドですが,インタフェースなのでメソッドの実装はしていません.かつ,このMapperを明示的にimplementsしたクラスも作成しません.

ではどうやって insert の処理を実装するかというと,XMLファイルに実行させたいSQL文を書くことによって,MyBatisがよしなに処理を実行してくれます.[2]

このXMLファイルは,application.ymlmybatis.mapper-locations プロパティの設定さえしていればどこに置いても問題ないのですが,今回はデフォルトのXML格納先 (src/main/resources 配下で Mapper インタフェースと同階層) にXMLファイルを配置することで,追加の設定は無しにします.具体的には,src/main/resources/com/numacci/api/repository 配下に CustomerMapper.xml ファイルを作成し,CustomerMapper.java で定義した insert メソッドの中身を実装していきます.

CustomerMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.numacci.api.repository.CustomerMapper">
  <insert id="insert" parameterType="com.numacci.api.dto.Customer">
    INSERT INTO customer VALUES (
      #{id, jdbcType=VARCHAR},
      #{username, jdbcType=VARCHAR},
      #{email, jdbcType=VARCHAR},
      #{phoneNumber, jdbcType=VARCHAR},
      #{postCode, jdbcType=VARCHAR}
    )
  </insert>
</mapper>

細かい説明は 公式ドキュメント に譲ってポイントだけ説明します.

mapper namespace ではどのMapperインタフェースの実装かを宣言しています.

<mapper></mapper> 内部がこのファイルのコアです.まず <insert> ですが,MyBatisでは何の種類のSQLを実行するのかをタグで宣言してあげます.今回の処理内容は insert into なので,ここでは <insert> タグを記載します.parameterType はこの insert ステートメントに渡されるパラメータ customer の完全修飾クラス名を表しています.

<insert></insert> 内部が実行するSQL文です.インタフェースのメソッドで引数として与えた customer のフィールドには #{fieldName} でアクセスすることができます.jdbcType はデータベース側での型を表しています.

Mapper/XML (テスト)

MapperとXMLが作成できたので,引き続きこれらのテストクラスを作成し,正常に動くかどうか確認していきます.データベース接続部分のテストには,DBUnitを利用していきます.

まず,src/test/java/com.numacci.api.config パッケージ配下に,DbConfig.java クラスを作成し,以下のコードを記入します.DBUnitが利用するデータソースのBeanを定義しています.

DbConfig.java
package com.numacci.api.config;

import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy;

public class DbConfig {

  @Value("${spring.datasource.username}")
  private String username;

  @Value("${spring.datasource.password}")
  private String password;

  @Value("${spring.datasource.url}")
  private String url;

  @Value("${spring.datasource.driverClassName}")
  private String jdbcDriver;

  @Bean
  public DataSource dataSource() {
    return new TransactionAwareDataSourceProxy(
        DataSourceBuilder.create()
            .username(this.username)
            .password(this.password)
            .url(this.url)
            .driverClassName(this.jdbcDriver)
            .build());
  }
}

続いて,src/test/java/com.numacci.api.repository パッケージ配下に CustomerMapperTest クラスを作成し,以下のようにテストを実装します.

CustomerMapperTest.java
package com.numacci.api.repository;

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

import com.numacci.api.config.DbConfig;
import com.numacci.api.dto.Customer;
import java.io.File;
import javax.sql.DataSource;
import org.dbunit.database.DatabaseConnection;
import org.dbunit.database.IDatabaseConnection;
import org.dbunit.dataset.IDataSet;
import org.dbunit.dataset.csv.CsvDataSet;
import org.dbunit.operation.DatabaseOperation;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.transaction.annotation.Transactional;

@SpringBootTest
@Transactional
@Import(DbConfig.class)
public class CustomerMapperTest {

  @Autowired
  private CustomerMapper mapper;

  @Autowired
  private DataSource ds;

  private IDatabaseConnection dbconn;

  private IDataSet inputCsvDataSet;

  /**
   * Clean and insert test data before each test method.
   *
   * @throws Exception SQLException thrown when connecting to database
   */
  @BeforeEach
  public void setup() throws Exception {
    this.dbconn = new DatabaseConnection(this.ds.getConnection());
    this.inputCsvDataSet =
        new CsvDataSet(new File("src/test/resources/com/numacci/api/repository"));
    DatabaseOperation.CLEAN_INSERT.execute(dbconn, inputCsvDataSet);
  }

  /**
   * Close database connection after each test method.
   *
   * @throws Exception SQLException thrown when closing the connection
   */
  @AfterEach
  public void teardown() throws Exception {
    this.dbconn.close();
  }

  @DisplayName("INSERT TEST: Check if the data is inserted as expected.")
  @Test
  public void testInsert() {
    Customer customer = new Customer();
    customer.setId("100");
    customer.setUsername("user100");
    customer.setEmail("test.user.100@example.com");
    customer.setPhoneNumber("01234567890");
    customer.setPostCode("1234567");

    assertEquals(1, mapper.insert(customer));
  }
}

まずアノテーションについて,MapperクラスをDIしてあげたいので @SpringBootTest でテストを起動してあげます.また,テストの前後でテーブルの中身が変更しないようにするため,@Transactional をつけます.

クラス変数としては,テスト対象である CustomerMapper,データソース設定情報を保持しているBean DataSource をDIし,またデータベースとのコネクションである IDatabaseConnection とデータセットを持たせる IDataSet を定義しておきます.

@BeforeEach アノテーションがついた setup メソッド内では,各テスト前にデータベースの初期化処理を行っています.具体的には,データソースからデータベース接続を取得し,src/test/resources/com/numacci/api/repository 配下に存在するCSVファイルをデータベースに CLEAN & INSERT (=DeleteAll+Insert) しています.CSVファイルはまだ作成していないのでこのままだとテストは通りませんが,後ほど作成します.

@AfterEach がついた teardown メソッドでは,各テスト後にデータベース接続をクローズしています.

最後に,testInsert でテストを実装しています.Insertしたいオブジェクトを作成し,mapper.insert メソッドをコールしています.メソッドをコールした返り値が1である=新規挿入件数が1件であるかどうかを最後に確認していて,1ならばテスト成功,それ以外ならばテスト失敗 (=Insertされなかった,等) となります.

以上がテストクラスです.ここで,先程述べたCSVファイルを追加で作成し,実際にテストを動かしてみます.src/test/resources/com/numacci/api/respository ディレクトリを作成し,Insertするテーブルの順番を定義する table-ordering.txt とテストデータを定義する customer.csv を新規作成します.ファイルの中身は以下の通りとします.

table-ordering.txt
customer
customer.csv
"id","username","email","phone_number","post_code"
"001","user001","test.user.001@example.com","12345678901","1234567"
"002","user002","test.user.002@example.com","23456789012","2345671"
"003","user003","test.user.003@example.com","34567890123","3456712"
"004","user004","test.user.004@example.com","45678901234","4567123"
"005","user005","test.user.005@example.com","56789012345","5671234"
"006","user006","test.user.006@example.com","67890123456","6712345"
"007","user007","test.user.007@example.com","78901234567","7123456"
"008","user008","test.user.008@example.com","89012345678","1234567"
"009","user009","test.user.009@example.com","90123456789","2345671"
"010","user010","test.user.010@example.com","01234567890","3456712"

これでテスト実行の準備ができたので,実際にテストを走らせてみます.テストクラスをエディタで開いた状態で右クリックし,Run 'CustomerMapperTest' を実行しても良いですし,[Run > Run...(Ctrl+Option+R) > CustomerMapperTest] を選んで実行してもいいですし,ショートカットで実行してもいいです.実行結果で [ Tests passed: 1 ] となっていればOKです.

テストがFailしている場合にはコンソール上にエラーログが出ているはずですので,そこから頑張ってググりましょう.どんなエラーメッセージが出るのか気になる方は assertEquals の第一引数を1から0に変更してみると expected: <0> but was: <1> みたいなメッセージを見ることができるのでやってみてください.あとはDockerで起動しているPostgreSQLを落としてテストを実行してみても面白いと思います.

以上でCREATEに関するMapper/XMLの実装とテストは終了です.

Service (実装)

続いて,上記で実装したMapperクラスを実際に呼び出して処理を行うServiceクラスを作っていきます.Serviceクラスでは,Controllerクラスから受け取ったオブジェクトに何かしらの中間処理 (フォーマット整形や別サービスの呼び出し等) を行った後,データベースにデータを登録します.

まず src/main/java/com.numacci.api.service パッケージ配下に CustomerService インタフェースを作成します.

CustomerService.java
package com.numacci.api.service;

import com.numacci.api.dto.Customer;

public interface CustomerService {

  /**
   * Register a new customer information.
   *
   * @param customer customer object which we want to register
   * @return customer object registered
   */
  Customer register(Customer customer);
}

src/main/java/com.numacci.api.service.impl パッケージ配下に,CustomerService インタフェースを実装した CustomerServiceImpl クラスを作成します.せっかくなので中間処理としてメールアドレスの整形処理 (ドメイン部分を小文字にする) を入れています.

CustomerServiceImpl.java
package com.numacci.api.service.impl;

import com.numacci.api.dto.Customer;
import com.numacci.api.repository.CustomerMapper;
import com.numacci.api.service.CustomerService;
import org.springframework.stereotype.Service;

@Service
public class CustomerServiceImpl implements CustomerService {

  private CustomerMapper mapper;

  public CustomerServiceImpl(CustomerMapper mapper) {
    this.mapper = mapper;
  }

  @Override
  public Customer register(Customer customer) {
    String formattedEmail = formatEmail(customer.getEmail());
    customer.setEmail(formattedEmail);

    mapper.insert(customer);
    return customer;
  }

  private String formatEmail(String email) {
    String[] separatedEmail = email.split("@");
    return separatedEmail[0] + "@" + separatedEmail[1].toLowerCase();
  }
}

ControllerにDIさせるために,クラスには @Service アノテーションをつけます.また,先程作成したMapperをConstructor InjectionでDIします.

register メソッドは実際にControllerから呼び出される処理で,CustmerService インタフェースで定義したメソッドを実装した部分となります.内容としては,formatEmail メソッドを利用して新規登録するオブジェクトのメールアドレスフィールドを整形し,Mapperクラスの insert メソッドに渡しています.また,呼び出し元には格納された customer オブジェクトを返却します.

なお formatEmail について,ここでは処理内容 (メールアドレスの整形) がとても簡素なものであり,かつServiceクラスが CustomerServiceImpl しか無いのでプライベートメソッドとして定義しています.プライベートメソッドの数が増えてきたり,他のServiceクラス等から呼び出したくなったりした場合には適宜別のサービスクラスとして切り出す→CustomerService からはDIして利用する形で実装してあげましょう.単一責任の原則が壊れたり,コードが重複することを防げます.

Service (テスト)

src/test/java/com.numacci.api.service パッケージ配下に CustomerServiceTest クラスを作成し,以下を実装します.

CustomerServiceTest.java
package com.numacci.api.service;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;

import com.numacci.api.dto.Customer;
import com.numacci.api.repository.CustomerMapper;
import com.numacci.api.service.impl.CustomerServiceImpl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;

public class CustomerServiceTest {

  @Mock
  private CustomerMapper mapper;

  @InjectMocks
  private CustomerServiceImpl service;

  @BeforeEach
  public void setup() {
    MockitoAnnotations.openMocks(this);
  }

  @DisplayName("CREATE TEST: Check if registeration succeeded.")
  @Test
  public void testRegister() {
    Customer customer = new Customer();
    customer.setId("100");
    customer.setUsername("user100");
    customer.setEmail("test.user.100@EXAMPLE.com");
    customer.setPhoneNumber("01234567890");
    customer.setPostCode("1234567");

    when(mapper.insert(Mockito.any(Customer.class))).thenReturn(1);
    Customer actual = service.register(customer);
    assertEquals(customer.getId(), actual.getId());
    assertEquals(customer.getUsername(), actual.getUsername());
    assertEquals("test.user.100@example.com", actual.getEmail());
    assertEquals(customer.getPhoneNumber(), actual.getPhoneNumber());
    assertEquals(customer.getPostCode(), actual.getPostCode());
    Mockito.verify(mapper, Mockito.times(1)).insert(customer);
  }
}

テスト対象の CustomerServiceImpl クラスにDIされる CustomerMapper クラスはクラス変数として定義し,@Mock アノテーションをつけることで,Mockとしての振る舞いをテストコード内で定義できます.テスト対象である CustomerServiceImpl クラスには @InjectMocks アノテーションをつけることで,Mockitoのライブラリがよしなにインスタンス化してくれます.

これらのアノテーションを効かせるためには @BeforeEach がついたメソッド内で MockitoAnnotations を初期化する必要があります.staticなメソッドである openMocks メソッドにテスト対象クラスである this を渡してあげてください.[3]

testRegister メソッドではテストを実装しています.when は Mock の振る舞いを定義するための static なメソッドで,ここでは mapper.insert のメソッドがコールされたら1を返却するように指定しています.あとはテスト対象である service.register メソッドをコールしてあげて,返却された Customer オブジェクトが期待通りの値を持っているか確認してあげます.最後に,when で定義した Mock である mapper.insert が1回だけコールされたことを verify し,テスト完了です.

Mapper と同様にテストを実行すると,[ Tests passed: 1 ] が表示されます.以上で Service クラスのテスト実装は完了です.

Controller (実装)

最後に src/main/java/com.numacci.api.controller.CustomerController を実装していきます.動作確認で実装した hello メソッドを削除し,新しく post メソッドを実装します.

CustomerController.java
package com.numacci.api.controller;

import com.numacci.api.dto.Customer;
import com.numacci.api.service.CustomerService;
import org.springframework.validation.Errors;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/customers")
public class CustomerController {

  private CustomerService customerService;

  public CustomerController(CustomerService customerService) {
    this.customerService = customerService;
  }

  @PostMapping
  public Customer post(@Validated @RequestBody Customer customer, Errors errors) {
    // If the fields of requested customer object are invalid,
    // throw Runtime Exception with validation errors.
    // NOTE: You can set HTTP status code and return it instead of throwing error.
    if (errors.hasErrors()) {
      throw new RuntimeException((Throwable) errors);
    }
    // NOTE: You can also validate whether insertion succeeded or not here.
    return customerService.register(customer);
  }
}

@RequestMapping("/customers") をクラスレベルで記載しているので,このクラスで定義したエンドポイント全ては /customers スタートとなります.

メソッドの処理としては,まず引数で @Validated アノテーションを使い,Bean Validation を行っています.これは,HTTPリクエストのBodyで連携された customer オブジェクトが,Customer クラスで定義した各フィールドの制約に違反していないかをチェックするものです.今回のケースだとNullチェックを全フィールドに課していますので,customer のいずれかのフィールドがNullである場合にはエラーが発生し,第二引数の errors に格納されます.

この errors にエラーが格納されている場合には,エラーメッセージを元にインスタンス化した RuntimeException をスローさせます.ここでは簡単のために例外投げちゃってますが,これ結構雑なので,実際には Bad Request とかにマップして適切なステータス・コードとメッセージを返却してあげてください.

バリデーションが通った customer に関しては Service クラスに連携し,処理を進めます.これもちょっと雑ですが,HTTP リスポンス用のDTOクラスは今回定義せず,Service クラスから返却されたオブジェクトをそのままリクエスト元に返却します.

以上でCREATEのソースコードの実装は完了です.

Controller (テスト)

Controller のテストを実装していきます.MockMvc を使ったやり方等もありますが,今回は Service クラスと同様 Mockito でテストコードを実装していきます.

src/test/java/com.numacci.api.controller パッケージ配下に CustomerControllerTest クラスを作成し,以下のコードを実装します.

CustomerControllerTest.java
package com.numacci.api.controller;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import com.numacci.api.dto.Customer;
import com.numacci.api.service.CustomerService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.validation.Errors;

public class CustomerControllerTest {

  @Mock
  private CustomerService customerService;

  @InjectMocks
  private CustomerController controller;

  @BeforeEach
  public void setup() {
    MockitoAnnotations.openMocks(this);
  }

  @DisplayName("CREATE TEST: Check if a new customer is registered and returned.")
  @Test
  public void testPost() {
    Customer customer = new Customer();
    customer.setId("100");
    customer.setUsername("user100");
    customer.setEmail("test.user.100@example.com");
    customer.setPhoneNumber("01234567890");
    customer.setPostCode("1234567");

    when(customerService.register(customer)).thenReturn(customer);

    Errors errors = mock(Errors.class);
    Customer response = controller.post(customer, errors);

    assertEquals(customer, response);
  }

  @DisplayName("CREATE TEST: Check if controller throws exception when some fields are invalid.")
  @Test
  public void testPostAbnormal() {
    Customer customer = new Customer();
    customer.setId("100");
    customer.setPhoneNumber("01234567890");
    customer.setPostCode("1234567");

    // Call post method with empty username and email.
    Errors errors = mock(Errors.class);
    when(errors.hasErrors()).thenReturn(true);
    assertThrows(RuntimeException.class, () -> controller.post(customer, errors));
  }
}

testPost では正常系のテストを,testPostAbnormal では異常系のテストをしています.Mockito の書き方は Service クラスのテストと同じなので説明は割愛します.なお異常系のテストでは,errors にエラーが格納されている場合に例外が投げられるか否かを assertThrows メソッドで確認しています.

動作確認

CREATEの開発の仕上げとして動作確認をしていきます.開発準備と同様にしてSpringを起動した後 (IntelliJでは右上の実行ボタン押下),Postmanを立ち上げ,POSTメソッドで http://localhost:8081/api/customers にリクエストを投げます.Postman上だとURL入力欄の下の Body > raw > JSON を選択して,以下のJSONを入力します.

{
    "id": "011",
    "username": "user011",
    "email": "test.user.011@EXAMPLE.com",
    "phoneNumber": "12345678901",
    "postCode": "4567123"
}

Send ボタンを押下すると,以下のリスポンスが返却されるのが確認できると思います.

{
    "id": "011",
    "username": "user011",
    "email": "test.user.011@example.com",
    "phoneNumber": "12345678901",
    "postCode": "4567123"
}

Postman全体のスクリーンショットは以下のようになります.

以上でCREATEの実装と動作確認が完了したので,同様にREAD/UPDATE/DELETEも実装していきます.

実装/UT (READ/UPDATE/DELETE)

Mapper/XML (実装)

まずMapperとXMLから実装していきます.といってもコアな部分は既に CREATE のセクションで説明済みなので,比較的簡単に実装できます.

src/main/java/com.numacci.api.repository.CustomerMapper インタフェースに READ/UPDATE/DELETE 用のメソッドを以下のように追記します.

CustomerMapper.java
package com.numacci.api.repository;

import com.numacci.api.dto.Customer;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface CustomerMapper {

  /**
   * Insert a new customer record to the database.
   *
   * @param customer customer object which we want to insert
   * @return the number of inserted records
   */
  int insert(Customer customer);

  /**
   * Select all records from database.
   *
   * @return all records stored in database
   */
  List<Customer> selectAll();

  /**
   * Retrieve a record with the same id as provided.
   *
   * @param id identity of customer record
   * @return customer record which has the same id as provided
   */
  Customer select(String id);

  /**
   * Update an existing record.
   *
   * @param customer customer object having the updating contents
   * @return the number of updated records
   */
  int update(Customer customer);

  /**
   * Delete a record with the same id as provided.
   *
   * @param id identity of customer record
   * @return the number of deleted records
   */
  int delete(String id);
}

これに対応するSQLを実装します.src/main/resources/com/numacci/api/repository/CustomerMapper.xml を以下のように編集します.

CustomerMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.numacci.api.repository.CustomerMapper">
  <insert id="insert" parameterType="com.numacci.api.dto.Customer">
    INSERT INTO customer VALUES (
      #{id, jdbcType=VARCHAR},
      #{username, jdbcType=VARCHAR},
      #{email, jdbcType=VARCHAR},
      #{phoneNumber, jdbcType=VARCHAR},
      #{postCode, jdbcType=VARCHAR}
    )
  </insert>
  <select id="selectAll" resultType="com.numacci.api.dto.Customer">
    SELECT * FROM customer
  </select>
  <select id="select" resultType="com.numacci.api.dto.Customer">
    SELECT * FROM customer
    WHERE id = #{id, jdbcType=VARCHAR}
  </select>
  <update id="update" parameterType="com.numacci.api.dto.Customer">
    UPDATE customer
    SET username = #{username, jdbcType=VARCHAR},
        email = #{email, jdbcType=VARCHAR},
        phone_number = #{phoneNumber, jdbcType=VARCHAR},
        post_code = #{postCode, jdbcType=VARCHAR}
    WHERE id = #{id, jdbcType=VARCHAR}
  </update>
  <delete id="delete">
    DELETE FROM customer
    WHERE id = #{id, jdbcType=VARCHAR}
  </delete>
</mapper>

基本的には CREATE と同様にして実装できたかと思います.差分としては,select の実装で resultType 属性を使っていますが,これは select によって返却される Java の型を指定します.

Mapper/XML (テスト)

上記で実装した READ/UPDATE/DELETE のテストコードは以下のようになります.

CustomerMapperTest.java
package com.numacci.api.repository;

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

import com.numacci.api.dto.Customer;
import java.io.File;
import java.util.List;
import javax.sql.DataSource;
import org.dbunit.database.DatabaseConnection;
import org.dbunit.database.IDatabaseConnection;
import org.dbunit.dataset.IDataSet;
import org.dbunit.dataset.csv.CsvDataSet;
import org.dbunit.operation.DatabaseOperation;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.transaction.annotation.Transactional;

@SpringBootTest
@Transactional
@Import(DbConfig.class)
public class CustomerMapperTest {

  @Autowired
  private CustomerMapper mapper;

  @Autowired
  private DataSource ds;

  private IDatabaseConnection dbconn;

  private IDataSet inputCsvDataSet;

  /**
   * Clean and insert test data before each test method.
   *
   * @throws Exception SQLException thrown when connecting to database
   */
  @BeforeEach
  public void setup() throws Exception {
    this.dbconn = new DatabaseConnection(this.ds.getConnection());
    this.inputCsvDataSet =
        new CsvDataSet(new File("src/test/resources/com/numacci/api/repository"));
    DatabaseOperation.CLEAN_INSERT.execute(dbconn, inputCsvDataSet);
  }

  /**
   * Close database connection after each test method.
   *
   * @throws Exception SQLException thrown when closing the connection
   */
  @AfterEach
  public void teardown() throws Exception {
    this.dbconn.close();
  }

  @DisplayName("INSERT TEST: Check if the data is inserted as expected.")
  @Test
  public void testInsert() {
    Customer customer = new Customer();
    customer.setId("100");
    customer.setUsername("user100");
    customer.setEmail("test.user.100@example.com");
    customer.setPhoneNumber("01234567890");
    customer.setPostCode("1234567");

    assertEquals(1, mapper.insert(customer));
  }

  @DisplayName("SELECT TEST: Check if all records retrieved.")
  @Test
  public void testSelectAll() {
    List<Customer> actuals = mapper.selectAll();
    assertEquals(10, actuals.size());
  }

  @DisplayName("SELECT TEST: Check if a record with the same id as provided retrieved.")
  @Test
  public void testSelect() {
    Customer actual = mapper.select("002");
    assertEquals("002", actual.getId());
    assertEquals("user002", actual.getUsername());
    assertEquals("test.user.002@example.com", actual.getEmail());
    assertEquals("23456789012", actual.getPhoneNumber());
    assertEquals("2345671", actual.getPostCode());
  }

  @DisplayName("UPDATE TEST: Check if the data is updated as expected.")
  @Test
  public void testUpdate() {
    Customer customer = new Customer();
    customer.setId("002");
    customer.setUsername("user002");
    customer.setEmail("test.user.002@example.com");
    customer.setPhoneNumber("23456789012");
    customer.setPostCode("2345671");

    assertEquals(1, mapper.update(customer));
  }

  @DisplayName("DELETE TEST: Check if the data is deleted as expected.")
  @Test
  public void testDelete() {
    assertEquals(1, mapper.delete("002"));
  }
}

こちらも説明済みである DBUnit 以外は基本的な JUnit の書き方と変わらないので,さほど問題なく実装できると思います.以上で Mapper/XML の実装とテストが完了しました.

Service (実装)

Service クラスの READ/UPDATE/DELETE もサクッと実装してしまいます.src/main/java/com.numacci.api.service.CustomerService インタフェースを以下のように修正してください.

CustomerService.java
package com.numacci.api.service;

import com.numacci.api.dto.Customer;
import java.util.List;

public interface CustomerService {

  /**
   * Register a new customer information.
   *
   * @param customer customer object which we want to register
   * @return customer object registered
   */
  Customer register(Customer customer);

  /**
   * Retrieve all customer information.
   *
   * @return all customer information
   */
  List<Customer> retrieve();

  /**
   * Retrieve the customer information having the same id as provided.
   *
   * @param id identity of customer information
   * @return customer information having the same id as provided
   */
  Customer retrieve(String id);

  /**
   * Update customer information.
   *
   * @param customer customer object which we want to update
   * @return customer object updated
   */
  Customer update(Customer customer);

  /**
   * Delete customer information.
   *
   * @param id identity of customer information
   * @return identity of deleted customer
   */
  String delete(String id);
}

各種 CRUD 用のメソッドを追加しました.CustomerServiceImpl クラスでこの追加されたメソッドを実装します.

CustomerServiceImpl.java
package com.numacci.api.service.impl;

import com.numacci.api.dto.Customer;
import com.numacci.api.repository.CustomerMapper;
import com.numacci.api.service.CustomerService;
import java.util.List;
import org.springframework.stereotype.Service;

@Service
public class CustomerServiceImpl implements CustomerService {

  private CustomerMapper mapper;

  public CustomerServiceImpl(CustomerMapper mapper) {
    this.mapper = mapper;
  }

  @Override
  public Customer register(Customer customer) {
    String formattedEmail = formatEmail(customer.getEmail());
    customer.setEmail(formattedEmail);

    mapper.insert(customer);
    return customer;
  }

  @Override
  public List<Customer> retrieve() {
    return mapper.selectAll();
  }

  @Override
  public Customer retrieve(String id) {
    return mapper.select(id);
  }

  @Override
  public Customer update(Customer customer) {
    String formattedEmail = formatEmail(customer.getEmail());
    customer.setEmail(formattedEmail);

    mapper.update(customer);
    return customer;
  }

  @Override
  public String delete(String id) {
    mapper.delete(id);
    return id;
  }

  private String formatEmail(String email) {
    String[] separatedEmail = email.split("@");
    return separatedEmail[0] + "@" + separatedEmail[1].toLowerCase();
  }
}

retrieve はオーバーロードを用いて実装しています.また,update では register と同様にメールアドレスの整形処理を入れています.

Service (テスト)

Service クラスのテストクラスは以下のようになります.基本的には各メソッドの返り値が正しいかを確認→Mock 化した mapper の呼び出し回数のチェックを行っています.

CustomerServiceTest.java
package com.numacci.api.service;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.numacci.api.dto.Customer;
import com.numacci.api.repository.CustomerMapper;
import com.numacci.api.service.impl.CustomerServiceImpl;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

public class CustomerServiceTest {

  @Mock
  private CustomerMapper mapper;

  @InjectMocks
  private CustomerServiceImpl service;

  @BeforeEach
  public void setup() {
    MockitoAnnotations.openMocks(this);
  }

  @DisplayName("CREATE TEST: Check if registeration succeeded.")
  @Test
  public void testRegister() {
    Customer customer = new Customer();
    customer.setId("100");
    customer.setUsername("user100");
    customer.setEmail("test.user.100@EXAMPLE.com");
    customer.setPhoneNumber("01234567890");
    customer.setPostCode("1234567");

    when(mapper.insert(any(Customer.class))).thenReturn(1);
    Customer actual = service.register(customer);
    assertEquals(customer.getId(), actual.getId());
    assertEquals(customer.getUsername(), actual.getUsername());
    assertEquals("test.user.100@example.com", actual.getEmail());
    assertEquals(customer.getPhoneNumber(), actual.getPhoneNumber());
    assertEquals(customer.getPostCode(), actual.getPostCode());
    verify(mapper, times(1)).insert(customer);
  }

  @DisplayName("READ TEST: Check if all customer information retrieved.")
  @Test
  public void testRetrieveAll() {
    Customer customer2 = new Customer();
    customer2.setId("002");
    customer2.setUsername("user002");
    customer2.setEmail("test.user.002@example.com");
    customer2.setPhoneNumber("23456789012");
    customer2.setPostCode("2345671");
    Customer customer3 = new Customer();
    customer3.setId("003");
    customer3.setUsername("user003");
    customer3.setEmail("test.user.003@example.com");
    customer3.setPhoneNumber("34567890123");
    customer3.setPostCode("3456712");
    Customer customer4 = new Customer();
    customer4.setId("004");
    customer4.setUsername("user004");
    customer4.setEmail("test.user.004@example.com");
    customer4.setPhoneNumber("45678901234");
    customer4.setPostCode("4567123");

    List<Customer> customers = new ArrayList<>();
    customers.add(customer2);
    customers.add(customer3);
    customers.add(customer4);
    when(mapper.selectAll()).thenReturn(customers);

    List<Customer> actuals = service.retrieve();
    assertEquals(3, actuals.size());
    verify(mapper, times(1)).selectAll();
  }

  @DisplayName("READ TEST: Check if expected customer information retrieved.")
  @Test
  public void testRetrieve() {
    Customer customer = new Customer();
    customer.setId("002");
    customer.setUsername("user002");
    customer.setEmail("test.user.002@example.com");
    customer.setPhoneNumber("23456789012");
    customer.setPostCode("2345671");

    when(mapper.select(customer.getId())).thenReturn(customer);
    Customer actual = service.retrieve("002");
    assertEquals(customer, actual);
    verify(mapper, times(1)).select(customer.getId());
  }

  @DisplayName("UPDATE TEST: Check if update succeeded.")
  @Test
  public void testUpdate() {
    Customer customer = new Customer();
    customer.setId("002");
    customer.setUsername("user002");
    customer.setEmail("modified.test.user.002@example.com");
    customer.setPhoneNumber("23456789012");
    customer.setPostCode("2345671");

    when(mapper.update(any(Customer.class))).thenReturn(1);
    Customer actual = service.update(customer);
    assertEquals(customer, actual);
    verify(mapper, times(1)).update(customer);
  }

  @DisplayName("DELETE TEST: Check if deletion succeeded.")
  @Test
  public void testDelete() {
    String id = "002";
    when(mapper.delete(id)).thenReturn(1);

    String actual = service.delete(id);
    assertEquals(id, actual);
    verify(mapper, times(1)).delete(id);
  }
}

以上で Service クラスの実装とテストは終了です.

Controller (実装)

最後に Controller クラスを実装していきます.src/main/java/com.numacci.api.controller.CustomerController クラスを以下のように修正し,CRUD オペレーション可能な状態にします.

CustomerController.java
package com.numacci.api.controller;

import com.numacci.api.dto.Customer;
import com.numacci.api.service.CustomerService;
import java.util.List;
import org.springframework.validation.Errors;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/customers")
public class CustomerController {

  private CustomerService customerService;

  public CustomerController(CustomerService customerService) {
    this.customerService = customerService;
  }

  @PostMapping
  public Customer post(@Validated @RequestBody Customer customer, Errors errors) {
    // If the fields of requested customer object are invalid,
    // throw Runtime Exception with validation errors.
    // NOTE: You can set HTTP status code and return it instead of throwing error.
    if (errors.hasErrors()) {
      throw new RuntimeException((Throwable) errors);
    }
    // NOTE: You can also validate whether insertion succeeded or not here.
    return customerService.register(customer);
  }

  @GetMapping
  public List<Customer> get() {
    return customerService.retrieve();
  }

  @GetMapping("/{id}")
  public Customer get(@PathVariable String id) {
    return customerService.retrieve(id);
  }

  @PatchMapping
  public Customer patch(@Validated @RequestBody Customer customer, Errors errors) {
    if (errors.hasErrors()) {
      throw new RuntimeException((Throwable) errors);
    }
    return customerService.update(customer);
  }

  @DeleteMapping("/{id}")
  public String delete(@PathVariable String id) {
    return customerService.delete(id);
  }
}

getdelete では @PathVariable アノテーションを使ってパス・パラメータを取得する構成としています.また,update に対応する HTTP メソッドとしては PUT ではなく PATCH を採用しています.

Controller (テスト)

上記実装に対するテストコードは以下のようになります.

CustomerControllerTest.java
package com.numacci.api.controller;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.numacci.api.dto.Customer;
import com.numacci.api.service.CustomerService;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.validation.Errors;

public class CustomerControllerTest {

  @Mock
  private CustomerService customerService;

  @InjectMocks
  private CustomerController controller;

  @BeforeEach
  public void setup() {
    MockitoAnnotations.openMocks(this);
  }

  @DisplayName("CREATE TEST: Check if a new customer is registered and returned.")
  @Test
  public void testPost() {
    Customer customer = new Customer();
    customer.setId("100");
    customer.setUsername("user100");
    customer.setEmail("test.user.100@example.com");
    customer.setPhoneNumber("01234567890");
    customer.setPostCode("1234567");

    when(customerService.register(customer)).thenReturn(customer);

    Errors errors = mock(Errors.class);
    Customer response = controller.post(customer, errors);

    assertEquals(customer, response);
  }

  @DisplayName("READ TEST: Check if all customer information retrieved.")
  @Test
  public void testGetAll() {
    Customer customer2 = new Customer();
    customer2.setId("002");
    customer2.setUsername("user002");
    customer2.setEmail("test.user.002@example.com");
    customer2.setPhoneNumber("23456789012");
    customer2.setPostCode("2345671");
    Customer customer3 = new Customer();
    customer3.setId("003");
    customer3.setUsername("user003");
    customer3.setEmail("test.user.003@example.com");
    customer3.setPhoneNumber("34567890123");
    customer3.setPostCode("3456712");
    Customer customer4 = new Customer();
    customer4.setId("004");
    customer4.setUsername("user004");
    customer4.setEmail("test.user.004@example.com");
    customer4.setPhoneNumber("45678901234");
    customer4.setPostCode("4567123");

    List<Customer> customers = new ArrayList<>();
    customers.add(customer2);
    customers.add(customer3);
    customers.add(customer4);
    when(customerService.retrieve()).thenReturn(customers);

    List<Customer> actuals = controller.get();
    assertEquals(3, actuals.size());
    verify(customerService, times(1)).retrieve();
  }

  @DisplayName("READ TEST: Check if expected customer information retrieved.")
  @Test
  public void testGet() {
    Customer customer = new Customer();
    customer.setId("002");
    customer.setUsername("user002");
    customer.setEmail("test.user.002@example.com");
    customer.setPhoneNumber("23456789012");
    customer.setPostCode("2345671");

    when(customerService.retrieve(customer.getId())).thenReturn(customer);
    Customer actual = controller.get("002");
    assertEquals(customer, actual);
    verify(customerService, times(1)).retrieve(customer.getId());
  }

  @DisplayName("UPDATE TEST: Check if update succeeded.")
  @Test
  public void testPatch() {
    Customer customer = new Customer();
    customer.setId("002");
    customer.setUsername("user002");
    customer.setEmail("test.user.002@example.com");
    customer.setPhoneNumber("23456789012");
    customer.setPostCode("2345671");

    when(customerService.update(customer)).thenReturn(customer);

    Errors errors = mock(Errors.class);
    Customer response = controller.patch(customer, errors);

    assertEquals(customer, response);
  }

  @DisplayName("DELETE TEST: Check if deletion succeeded.")
  @Test
  public void testDelete() {
    String id = "002";
    when(customerService.delete(id)).thenReturn(id);
    String actual = controller.delete(id);
    assertEquals(id, actual);
    verify(customerService, times(1)).delete(id);
  }

  @DisplayName("CREATE TEST: Check if controller throws exception when some fields are invalid.")
  @Test
  public void testPostAbnormal() {
    Customer customer = new Customer();
    customer.setId("100");
    customer.setPhoneNumber("01234567890");
    customer.setPostCode("1234567");

    // Call post method with empty username and email.
    Errors errors = mock(Errors.class);
    when(errors.hasErrors()).thenReturn(true);
    assertThrows(RuntimeException.class, () -> controller.post(customer, errors));
  }

  @DisplayName("UPDATE TEST: Check if controller throws exception when some fields are invalid.")
  @Test
  public void testPatchAbnormal() {
    Customer customer = new Customer();
    customer.setId("002");
    customer.setPhoneNumber("23456789012");
    customer.setPostCode("2345671");

    // Call patch method with empty username and email.
    Errors errors = mock(Errors.class);
    when(errors.hasErrors()).thenReturn(true);
    assertThrows(RuntimeException.class, () -> controller.patch(customer, errors));
  }
}

以上で全実装とテストが完了となります.お疲れ様でした.

動作確認

それでは最後に動作確認をしていきます.CREATE については確認済みとなりますので,それ以外の処理を見ていきます.Spring Boot アプリケーションを起動して,Postman を立ち上げてください.

GET の確認をします.Postman 上で HTTP メソッドを GET に変更して,URL に http://localhost:8081/api/customers をセットし,Send を押下します.以下のように全件のJSONが返却されればOKです.

[
    {
        "id": "001",
        "username": "user001",
        "email": "test.user.001@example.com",
        "phoneNumber": "12345678901",
        "postCode": "1234567"
    },
    {
        "id": "002",
        "username": "user002",
        "email": "test.user.002@example.com",
        "phoneNumber": "23456789012",
        "postCode": "2345671"
    },
    // 中略
]

GET で id を指定した場合の振る舞いを見てみます.HTTP メソッドはそのままに,URL を http://localhost:8081/api/customers/002 に変更してリクエストを送ると,以下のように特定の id を持つJSONが返ってきます.

{
    "id": "002",
    "username": "user002",
    "email": "test.user.002@example.com",
    "phoneNumber": "23456789012",
    "postCode": "2345671"
}

UPDATE の確認をします.HTTP メソッドを PATCH に変更して,URL に http://localhost:8081/api/customers をセットします.リクエストボディに以下のJSONを貼り付け,Send を押下します.

{
    "id": "002",
    "username": "user002",
    "email": "modified.test.user.002@EXAMPLE.com",
    "phoneNumber": "99999999999",
    "postCode": "9999999"
}

リスポンスとしては以下のJSONが返ってくると思います.

{
    "id": "002",
    "username": "user002",
    "email": "modified.test.user.002@example.com",
    "phoneNumber": "99999999999",
    "postCode": "9999999"
}

pgadmin からも実際にデータが更新されていることが確認できます.

最後に DELETE の確認です.HTTP メソッドを DELETE に変更して,http://localhost:8081/api/customers/002 にリクエストを送ると,リスポンスとして 002 が返却されます.pgadmin でレコードを確認してみると,id が 002 のレコードは存在しないことがわかります.

まとめ

想定していたよりもだいぶ長くなってしましましたが,これで Spring Boot+Postgres+MyBatisでRESTfulなAPIを作成してCRUDする のタイトル通り,各ツールを使って一通りの開発ができるようになったかなと思います.

後半の READ/UPDATE/DELETE あたりで何となく感じたかもですが,基本的には HTTP メソッドや SQL を変えるだけで各種 CRUD は実装できてしまいます.ので,CRUD のうちどれか1つでも実装したことがある or 実装のやり方を知っているという状態になれば,後の細かい部分は Google 先生に聞いたりして実装できると思います.

また,今回はちゃんとテストコードも書きながら実装しました.どれだけ小さいコードやツールであっても,テストコードがしっかり書かれていないとメンテナンスや機能追加ができなくなってしまうので,基本的にはメソッド単位で,それが難しければ少なくともクラス単位やモジュール単位でテストを実施しましょう.テストの心構えや重要性をチームメンバと共有したい場合にはこの辺の書籍とかおすすめです.俗に言うTDD本で,開発手法について述べている本ですが,何故そのような開発手法が生まれたのかを知ることができて割と面白いです.

テスト駆動開発

最後まで読んで頂きありがとうございました.今後も定期的に備忘録的なものや作ったものを記事として残していきたいと思いますので,よろしくお願いします.

脚注
  1. コンテキスト・パスとアプリケーションのwarファイル名を揃えておくと,どこかのAPサーバにデプロイしたときでもコンテキスト・パスが変わらず混乱が少なくなります.具体的には,コンテキスト・パスを /api にセットし,かつこのアプリケーションのwar名を api.war に変更すれば,ローカル開発環境とデプロイ先でエンドポイントの差分はホスト名だけとなります.何もセットしないとローカルの開発では /api がつかないのでパスがずれてしまい,バグを生む可能性があるのでご注意ください. ↩︎

  2. こちらの記事 で説明されているように,@Insert アノテーション+SQL文で実装することも可能です.簡単なSQLはアノテーションで実装して,複雑なSQLを扱うようになってきたらXMLファイルとして外出しして管理,といった手法が良いかもしれないですね. ↩︎

  3. ちょっと前までは MockitoAnnotations.initMocks メソッドで初期化していたのですが,現在は Deprecated になってしまっているため openMocks メソッドを利用しています. ↩︎