ぺんぎんらぼ

お笑いとマンガ好きなしょぼしょぼWeb系エンジニアの日記です。たまに絵を描きます。

お笑いとマンガ好きなしょぼしょぼWeb系エンジニアの日記です

GitLabでCI/CD - Javaプロジェクトのビルドとテスト

みなさん、GitLabを使ってますか?

GitLabは、クラウド、オンプレ、Dockerと様々な環境で利用できるGitのリポジトリマネージャーです。
オンプレで利用できることから、社内の開発プロジェクトで利用するケースも多いです。Dockerイメージを使って環境を構築できるのも、お手軽でいいです。

そんな多様性の高いGitLabですが、ただのGitリポジトリだけとして、使ってないですか?
実はGitLabはリポジトリ以外の機能も多く、便利なものが多いです。今回はGitLabの便利機能の中から、CI/CDを紹介します。

GitLabのCI/CDは超簡単!

CI/CDというと、真っ先に思いつくのはJenkinsだと思います。
よくあるJenkinsのユースケースといえば、プロジェクトデプロイ時にビルドとJUnitテストを実行したり、定期的にWebアプリケーションをビルドしてWebサーバにデプロイ、なんてことをすることが多いのではないでしょうか。

GitLabにもCI/CDがあり、Jenkinsと連携せずにGitLab単体でも、Jenkinsと同様のことを実現できます。
今回はGitLab単体で、WebアプリケーションのビルドをJUnitのテストを自動化する方法を説明します。

GitLabでCI/CDを実行する場合、GitLabサーバのほかにGitLabRunnerというものを立ち上げる必要があります。
今回は、GitLabでCD/CDを実行する環境の構築方法については解説しません。

CIの設定 (定義)

CIの動作の定義はGitLab上ではなく、CIを動かすプロジェクト上に定義ファイルを配置して、そのファイルに定義します。
プロジェクトの直下に「.gitlab-ci.yml」というファイル名でファイルを作成して、以下のような内容でCIの動作を定義します。

ファイル「.gitlab-ci.yml」はファイル名が「.」(ドット)で始まるので、eclipseでは隠しファイルと認識され、パッケージエクスプローラーに表示されません。
Ctrl + Shift + Rの「リソースを開く」からファイルを開くか、パッケージエクスプローラーのフィルターから「.*リソース」を表示するように設定します。
image: java:8-jdk

stages:
  - build
  - test

before_script:
  - export GRADLE_USER_HOME=`pwd`/.gradle
  - chmod +x ./gradlew

cache:
  paths:
    - .gradle/wrapper
    - .gradle/caches

build:
  stage: build
  script:
    - ./gradlew assemble
  artifacts:
    paths:
      - build/libs/*.war
    expire_in: 1 week
  only:
    - master
    - develop

test:
  stage: test
  script:
    - ./gradlew check
  artifacts:
    reports:
      junit: build/test-results/test/**/TEST-*.xml

ここでは、タスクとしてプロジェクトをビルドする「build」とテストする「test」の2つのタスクを定義しています。
今回は、詳細の説明は省略しますが、それぞれのタスクの「script」に実行するコマンドを、「artifacts」に実行後の成果物の生成方法を定義します。

CIの実行

プロジェクトに作成した「.gitlab-ci.yml」をGitにPUSHすると、自動的にCIが実行されます。
CIの実行状況を確認するには、GitLabのプロジェクトのページから、ロケットアイコンのCI/CD - Pipelinesをクリックします。

f:id:penguinlabo:20211103205617p:plain

パイプライン一覧が表示され、Statusがrunningとなっており、パイプラインが実行されていることがわかります。 このページの「Tests」タブから、JUnitの実行結果を確認することもできます。

f:id:penguinlabo:20211103205644p:plain

パイプライン一覧から、Pipeline IDのリンクをクリックすると、パイプラインの詳細画面が表示されます。 このページから成果物のダウンロードも可能で、例えば、Webアプリケーションのビルドであれば、warファイルをダウンロードすることができます。

f:id:penguinlabo:20211103205721p:plain

まとめ

いかがだったでしょう。GitLab CI/CDを実行できる環境さえ用意してしまえば、CI/CDの定義ファイルを作るだけで、CI/CDを実行できることがわかると思います。
基本的なことは簡単に、頑張れば凝ったこともできるものなので、まずは、今回の紹介のようにビルド&テストを試して、手軽さを実感していただきたいです。

QNAPのiSCSIはローカルHDDの代替になるか?

マツキは普段、ノートPCを使ってますが、重い処理をするためのデスクトップPCも使ってます。
で、そのデスクトップPCは、システムドライブはSSDですが、大きなデータ、例えば動画ファイルなんかを置くためにHDDも搭載しています

使っているHDDはこれ。

容量は2TBで回転数が5400rpmのHDDです。

購入当時は十分な容量で、5400rpmというのも、ごくごく普通のHDDでした。

容量が足りない! なんかもっさり!

このHDDにいろんなデータをため込んでいるので、最近、空き容量が減ってきたと感じました。
それに、複数の作業で同時にHDDにアクセスすると、速度が遅く、ストレスを感じるようになりました。
例えば、ファイルをコピーしながら動画を編集すると、動画のサムネイル表示がなかなか更新されず、もっと快適な環境にしたいなーと感じることが多々あります。

そういえば、iSCSIってどうなんだろう?

前の記事にも書きましたが、マツキ家にはQNAPのNASがあります。
で、このNASにはiSCSIという機能があります。これは、NASのHDDの一部の容量を切り出して、PCのローカルディスクのように使う機能です。

NASは潤沢な容量があるので、容量の問題はクリアできます。問題は速度です。iSCSIはネットワーク接続なので、SATA接続のHDDと比較してどうなのか?という点は気になります。

速度測定

早速、速度測定です。測定環境は以下の通りです。

ローカルディスク

製品 WD20EZRX
容量 2TB
回転速度 5400rpm

NAS

製品 TS-873
容量 10TB × 8 RAID5
ネットワーク速度 1G bps

測定結果は以下の通りです。

ローカルディスク iSCSI
f:id:penguinlabo:20211010223351p:plain f:id:penguinlabo:20211010223403p:plain

すべての項目でローカルHDDよりiSCSIのほうが早いです。
特にランダムアクセスが早いので、いろんな作業を同時に実行しても速度低下が抑えられそう。
シーケンシャルアクセスは120MB/s弱なので、ネットワーク速度の限界に達しているのがわかります。もっと早いネットワーク環境であれば、もっと速度が出そうです。

ちなみに、普通にNASをネットワークドライブに割り当てたときの速度も測定してみました。
iSCSIはネットワークドライブより速度が出やすいということだったのですが・・・

iSCSI ネットワークドライブ
f:id:penguinlabo:20211010223403p:plain f:id:penguinlabo:20211010224450p:plain

残念ながら、iSCSIと比較して優位性はなさそうです。

実際にデータ用ドライブとして使ってみた感想

NASのディスクを使っているということもあり、冗長性もNASの構成に準拠されます。RAID5を組んでいるのでディスク故障が即データ喪失につながらないので、安心感があります。
また、NASのスナップショット機能も使えるので、誤って大事なファイルを削除してしまっても、スナップショットからデータを取り戻すこともできます。

しかし、パフォーマンスはベンチマークで測定したもの通りではありませんでした。
iSCSI単体にアクセスしているのであれば、ベンチマーク通りのスピードが出ている感じですが、iSCSIドライブから、NASにファイルをコピーするとか、ネットでファイルをダウンロードしながらiSCSIドライブにアクセスするような、ネットワークの帯域を圧迫する処理iSCSIドライブのアクセスが重なると、途端に重くなる印象です。

結論

回転数の早い最近のHDDを買うのが一番幸せ。

ネットワークインフラを10Gbpsにアップグレードしたときに、iSCSIを再検討したいと思います。
とはいえ、HDDを買うお金もないので、今の遅くて容量の少ないHDDでもう少し我慢です。

Java Tips - メソッドの復帰値の型を総称型も含めて取得する

f:id:penguinlabo:20210908000148j:plain

Javaでプログラム内からメソッドの情報を取得する場合、java.lang.reflect.Methodクラスを使用して取得することができます。

今回、以下のメソッドの復帰値の型を取得します。

public List<String> getStringList() {
    ・・・
}

メソッドの復帰値の型を取得

java.lang.reflect.MethodクラスのgetReturnTypeメソッドにより、メソッドの復帰値の型を取得することができます。

Class returnType = method.getReturnType();
System.out.println("returnType = " + returnType.getName());

これにより、メソッドの復帰値の型がClassオブジェクトとして取得できます。
このコードの実行結果は以下のようになります。

returnType = java.util.List

メソッドの復帰値の型であるjava.util.Listクラスのクラスオブジェクトが取得できていることがわかります。

メソッドの復帰値の総称型を取得

java.lang.reflect.MethodクラスのgetReturnTypeメソッドでは、メソッドの復帰値の「型」の部分を取得できますが、総称型が取得できません。

総称型の情報を含めてメソッドの復帰値の型を取得する場合、Java5で追加されたjava.lang.reflect.MethodクラスのgetGenericReturnTypeメソッドを使用します。

Type returnType = method.getGenericReturnType();
if (returnType instanceof ParameterizedType) {
    ParameterizedType paramType = (ParameterizedType) returnType;
    Type[] argTypes = paramType.getActualTypeArguments();
    for (int i = 0; i < argTypes.length; i++) {
        System.out.println("returnType argument[" + i + "] = " + argTypes[i]);
    }
}

getGenericReturnTypeメソッドは、復帰値の型の情報だけを取得するのではなく、復帰値の型を総称型の情報を含めて取得するものです。

getGenericReturnTypeメソッドの復帰値はTypeインターフェースです。実際の復帰値はメソッドの復帰値の型によって変わります。
メソッドの復帰値の型が総称型を持つ場合、getGenericReturnTypeメソッドの復帰値はParameterizedTypeインターフェースとなります。このParameterizedTypeインターフェースのメソッドを使用して総称型を取得することになります。

総称型の取得方法

ParameterizedTypeインターフェースのgetActualTypeArgumentsメソッドを使用して、総称型を取得します。

    ParameterizedType paramType = (ParameterizedType) returnType;
    Type[] argTypes = paramType.getActualTypeArguments();

総称型は元クラスによって複数存在する場合があるので、getActualTypeArgumentsメソッドの復帰値は、Typeインターフェースの配列です。
例えば、メソッドの復帰値がListインターフェースの場合、総称型は1つですが、Mapインターフェースの場合、総称型は2つになります。
また、総称型が入れ子になる場合があるので、getActualTypeArgumentsメソッドの復帰値はClassの配列ではなく、Typeインターフェースの配列になっています。

総称型の判定方法

getActualTypeArgumentsメソッドの復帰値は、Typeインターフェースの配列のため、総称型に特定のクラスが指定されていることを判定する場合、一工夫必要です。

以下のコードで、総称型がStringクラスであることを判定できます。

Type[] argTypes = paramType.getActualTypeArguments();
for (int i = 0; i < argTypes.length; i++) {
    if (argTypes[i] instanceof Class && ((Class) argTypes[i]) == String.class) {
        // 総称型がStringクラス
        ・・・
    }
}

総称型がStringクラスの場合、getActualTypeArgumentsメソッドの復帰値はTypeインターフェースを実装したClassクラスになります。
なので、getActualTypeArgumentsメソッドの復帰値がClassクラスであることを判定した後、Classクラスにキャストして、そのClassオブジェクトがStringクラスであることを判定します。

今回のポイント

  1. メソッドの復帰値を総称型を含めて取得する場合、java.lang.reflect.MethodクラスのgetGenericReturnTypeメソッドを使用する。
  2. java.lang.reflect.MethodクラスのgetGenericReturnTypeメソッドの復帰値はTypeインターフェース。
  3. 復帰値のTypeインターフェースは、型の内容によって、Typeインターフェースを継承or実装した、いくつかのクラスに分岐する。

java.lang.reflect.MethodクラスのgetGenericReturnTypeメソッドは詳細な情報が取得できる半面、取得方法が複雑になります。
取得した情報のクラスが取得する内容によって変化することを気にする必要があります。

f:id:penguinlabo:20210907123730p:plain

Lombok - アノテーション一覧

定型的で冗長なコードの実装を省くことができるLombok
@Getterや@Setter、@Dataアノテーションぐらいしか使ったことがない人も多いと思いますが、これら以外にも便利なアノテーションが用意されています。

個々のアノテーションの詳細な説明は別記事で取り上げるとして、ここではLombokで用意されているアノテーションを一覧します。

アノテーション 指定場所 作用
@Getter クラス
フィールド
デフォルトのゲッターメソッドを自動生成します。
@Setter クラス
フィールド
デフォルトのセッターメソッドを自動生成します。
@NoArgsConstructor クラス 引数なしのコンストラクタを自動生成します。
@RequiredArgsConstructor クラス finalフィールドを引数で初期化するコンストラクタ、ファクトリメソッドを自動生成します。
@AllArgsConstructor クラス すべてのフィールドを引数で初期化するコンストラクタ、ファクトリメソッドを自動生成します。
@EqualsAndHashCode クラス すべてのフィールドを使用してequalsメソッドとhashCodeメソッドを自動生成します。
@ToString クラス すべてのフィールドを1つの文字列表現に変換するtoStringメソッドを自動生成します。
@Data クラス クラスに@Getter、@Setter、@RequiredArgsConstructor、@EqualsAndHashCode、@ToStringを指定したことと同じ作用になります。
@Value クラス 不変クラスを作成します。クラスと全てのフィールドがfinalで修飾され、@Getter、@RequiredArgsConstructor、@EqualsAndHashCode、@ToStringを指定したことと同じ作用になります。
@Builder クラス すべてのフィールドを引数で初期化するコンストラクタと、ビルダーパターンによる値の初期化のためのメソッド、ビルダークラスを自動生成します。
@With フィールド 引数の値でフィールドを初期化した新しいオブジェクトを返すメソッドを自動生成します。不変クラスのセッターのようなメソッドを生成する機能です。
@SneakyThrows メソッド メソッド内で発生する検査例外を非検査例外であるRuntimeExceptionにラップしてリスローするコードを自動生成します。
@Synchronized メソッド メソッド全体をsynchronizedブロックで囲むコードと、synchronizedで使用するロックオブジェクトの宣言を自動生成します。
@NonNull フィールド
引数
指定したフィールド、引数にnullを指定すると、NullPointerExceptionをスローするコードを自動生成します。
@Cleanup 変数 I/O系などのclose処理が必要な変数に指定することで、finallyでclose処理を呼び出すtryブロックで囲むコードを自動生成します。
@Log クラス Java標準Loggerのロガーインスタンスを保持するフィールドを自動生成します。
@CommonsLog クラス Apache Commons Loggingのロガーインスタンスを保持するフィールドを自動生成します。
@Flogger クラス Fluent Loggerのロガーインスタンスを保持するフィールドを自動生成します。
@JBossLog クラス JBoss Loggingのロガーインスタンスを保持するフィールドを自動生成します。
@Log4j クラス Apache log4jのロガーインスタンスを保持するフィールドを自動生成します。
@Log4j2 クラス Apache Log4j 2のロガーインスタンスを保持するフィールドを自動生成します。
@Slf4j クラス SLF4Jのロガーインスタンスを保持するフィールドを自動生成します。
@XSlf4j クラス SLF4Jの拡張ロガー(XLogger)インスタンスを保持するフィールドを自動生成します。
@CustomLog クラス Lombokの設定ファイルで指定されたロガーインスタンスを保持するフィールドを自動生成します。

@Getter

デフォルトのゲッターメソッドを自動生成します。

実装例

@Getter
public class Employee {
    private final long id;
    private String department;
}

展開イメージ

public class Employee {
    private final long id;
    private String department;

    public long getId() {
        return this.id;
    }

    public String getDepartment() {
        return this.department;
    }
}

@Setter

デフォルトのセッターメソッドを自動生成します。
finalフィールドのセッターは生成されません。

実装例

@Setter
public class Employee {
    private final long id;
    private String department;
}

展開イメージ

public class Employee {
    private final long id;
    private String department;

    public void setDepartment(final String department) {
        this.department = department;
    }
}

@NoArgsConstructor

引数なしのコンストラクタを自動生成します。

実装例

@NoArgsConstructor
public class Employee {
    private long id;
    private String department;
}

展開イメージ

public class Employee {
    private long id;
    private String department;

    public Employee() {
    }
}

@RequiredArgsConstructor

finalフィールドを引数で初期化するコンストラクタ、ファクトリメソッドを自動生成します。

実装例

@RequiredArgsConstructor
public class Employee {
    private final long id;
    private String department;
}

展開イメージ

public class Employee {
    private final long id;
    private String department;

    public Employee(final long id) {
        this.id = id;
    }
}

@AllArgsConstructor

すべてのフィールドを引数で初期化するコンストラクタ、ファクトリメソッドを自動生成します。

実装例

@AllArgsConstructor
public class Employee {
    private final long id;
    private String department;
}

展開イメージ

public class Employee {
    private final long id;
    private String department;

    public Employee(final long id, final String department) {
        this.id = id;
        this.department = department;
    }
}

@EqualsAndHashCode

すべてのフィールドを使用してequalsメソッドとhashCodeメソッドを自動生成します。

実装例

@EqualsAndHashCode
public class Employee {
    private final long id;
    private String department;
}

展開イメージ

public class Employee {
    private final long id;
    private String department;

    @java.lang.Override
    public boolean equals(final java.lang.Object o) {
        if (o == this) return true;
        if (!(o instanceof Employee)) return false;
        final Employee other = (Employee) o;
        if (!other.canEqual((java.lang.Object) this)) return false;
        if (this.id != other.id) return false;
        final java.lang.Object this$department = this.department;
        final java.lang.Object other$department = other.department;
        if (this$department == null ? other$department != null : !this$department.equals(other$department)) return false;
        return true;
    }

    protected boolean canEqual(final java.lang.Object other) {
        return other instanceof Employee;
    }

    @java.lang.Override
    public int hashCode() {
        final int PRIME = 59;
        int result = 1;
        final long $id = this.id;
        result = result * PRIME + (int) ($id >>> 32 ^ $id);
        final java.lang.Object $department = this.department;
        result = result * PRIME + ($department == null ? 43 : $department.hashCode());
        return result;
    }
}

@ToString

すべてのフィールドを1つの文字列表現に変換するtoStringメソッドを自動生成します。

実装例

@ToString
public class Employee {
    private final long id;
    private String department;
}

展開イメージ

public class Employee {
    private final long id;
    private String department;

    @java.lang.Override
    public java.lang.String toString() {
        return "Employee(id=" + this.id + ", department=" + this.department + ")";
    }
}

@Data

クラスに@Getter、@Setter、@RequiredArgsConstructor、@EqualsAndHashCode、@ToStringを指定したことと同じ作用になります。

実装例

@Data
public class Employee {
    private final long id;
    private String department;
}

展開イメージ

public class Employee {
    private final long id;
    private String department;

    public Employee(final long id) {
        this.id = id;
    }

    public long getId() {
        return this.id;
    }

    public String getDepartment() {
        return this.department;
    }

    public void setDepartment(final String department) {
        this.department = department;
    }

    @java.lang.Override
    public boolean equals(final java.lang.Object o) {
        if (o == this) return true;
        if (!(o instanceof Employee)) return false;
        final Employee other = (Employee) o;
        if (!other.canEqual((java.lang.Object) this)) return false;
        if (this.getId() != other.getId()) return false;
        final java.lang.Object this$department = this.getDepartment();
        final java.lang.Object other$department = other.getDepartment();
        if (this$department == null ? other$department != null : !this$department.equals(other$department)) return false;
        return true;
    }

    protected boolean canEqual(final java.lang.Object other) {
        return other instanceof Employee;
    }

    @java.lang.Override
    public int hashCode() {
        final int PRIME = 59;
        int result = 1;
        final long $id = this.getId();
        result = result * PRIME + (int) ($id >>> 32 ^ $id);
        final java.lang.Object $department = this.getDepartment();
        result = result * PRIME + ($department == null ? 43 : $department.hashCode());
        return result;
    }

    @java.lang.Override
    public java.lang.String toString() {
        return "Employee(id=" + this.getId() + ", department=" + this.getDepartment() + ")";
    }
}

@Value

不変クラスを作成します。クラスと全てのフィールドがfinalで修飾され、@Getter、@AllArgsConstructor、@EqualsAndHashCode、@ToStringを指定したことと同じ作用になります。

実装例

@Value
public class Employee {
    private final long id;
    private String department;
}

展開イメージ

public final class Employee {
    private final long id;
    private final String department;

    public Employee(final long id, final String department) {
        this.id = id;
        this.department = department;
    }

    public long getId() {
        return this.id;
    }

    public String getDepartment() {
        return this.department;
    }

    @java.lang.Override
    public boolean equals(final java.lang.Object o) {
        if (o == this) return true;
        if (!(o instanceof Employee)) return false;
        final Employee other = (Employee) o;
        if (this.getId() != other.getId()) return false;
        final java.lang.Object this$department = this.getDepartment();
        final java.lang.Object other$department = other.getDepartment();
        if (this$department == null ? other$department != null : !this$department.equals(other$department)) return false;
        return true;
    }

    @java.lang.Override
    public int hashCode() {
        final int PRIME = 59;
        int result = 1;
        final long $id = this.getId();
        result = result * PRIME + (int) ($id >>> 32 ^ $id);
        final java.lang.Object $department = this.getDepartment();
        result = result * PRIME + ($department == null ? 43 : $department.hashCode());
        return result;
    }

    @java.lang.Override
    public java.lang.String toString() {
        return "Employee(id=" + this.getId() + ", department=" + this.getDepartment() + ")";
    }
}

@Builder

すべてのフィールドを引数で初期化するコンストラクタと、ビルダーパターンによる値の初期化のためのメソッド、ビルダークラスを自動生成します。

実装例

@Builder
public class Employee {
    private final long id;
    private String department;
}

展開イメージ

public class Employee {
    private final long id;
    private String department;

    Employee(final long id, final String department) {
        this.id = id;
        this.department = department;
    }


    public static class EmployeeBuilder {
        private long id;
        private String department;

        EmployeeBuilder() {
        }

        public Employee.EmployeeBuilder id(final long id) {
            this.id = id;
            return this;
        }

        public Employee.EmployeeBuilder department(final String department) {
            this.department = department;
            return this;
        }

        public Employee build() {
            return new Employee(this.id, this.department);
        }

        @java.lang.Override
        public java.lang.String toString() {
            return "Employee.EmployeeBuilder(id=" + this.id + ", department=" + this.department + ")";
        }
    }

    public static Employee.EmployeeBuilder builder() {
        return new Employee.EmployeeBuilder();
    }
}

@With

引数の値でフィールドを初期化した新しいオブジェクトを返すメソッドを自動生成します。不変クラスのセッターのようなメソッドを生成する機能です。

実装例

@With
@AllArgsConstructor
public class Employee {
    private final long id;
    private String department;
}

展開イメージ

public class Employee {
    private final long id;
    private String department;

    public Employee withId(final long id) {
        return this.id == id ? this : new Employee(id, this.department);
    }

    public Employee withDepartment(final String department) {
        return this.department == department ? this : new Employee(this.id, department);
    }

    public Employee(final long id, final String department) {
        this.id = id;
        this.department = department;
    }
}

@SneakyThrows

メソッド内で発生する検査例外を非検査例外であるRuntimeExceptionにラップしてリスローするコードを自動生成します。

実装例

public class EmployeeBusiness {
    @SneakyThrows
    public static byte[] toUTF8(String source) {
        return source.getBytes("utf-8");
    }
}

展開イメージ

public class EmployeeBusiness {
    public static byte[] toUTF8(String source) {
        try {
            return source.getBytes("utf-8");
        } catch (final java.lang.Throwable $ex) {
            throw lombok.Lombok.sneakyThrow($ex);
        }
    }
}

@Synchronized

メソッド全体をsynchronizedブロックで囲むコードと、synchronizedで使用するロックオブジェクトの宣言を自動生成します。

実装例

public class EmployeeBusiness {
    private static List<String> cacheList;

    @Synchronized
    public static List<String> getCache() {
        if (cacheList == null) {
            cacheList = new ArrayList<>();
        }
        return cacheList;
    }
}

展開イメージ

public class EmployeeBusiness {
    private static final java.lang.Object $LOCK = new java.lang.Object[0];

    private static List<String> cacheList;

    public static List<String> getCache() {
        synchronized (EmployeeBusiness.$LOCK) {
            if (cacheList == null) {
                cacheList = new ArrayList<>();
            }
            return cacheList;
        }
    }
}

@NonNull

指定したフィールド、引数にnullを指定すると、NullPointerExceptionをスローするコードを自動生成します。

実装例

public class EmployeeBusiness {
    public static byte[] toUTF8(@NonNull String source) {
        return source.getBytes(StandardCharsets.UTF_8);
    }
}

展開イメージ

public class EmployeeBusiness {
    public static byte[] toUTF8(@NonNull String source) {
        if (source == null) {
            throw new java.lang.NullPointerException("source is marked non-null but is null");
        }
        return source.getBytes(StandardCharsets.UTF_8);
    }
}

@Cleanup

I/O系などのclose処理が必要な変数に指定することで、finallyでclose処理を呼び出すtryブロックで囲むコードを自動生成します。

実装例

public class EmployeeBusiness {
    public static void writeBytes(Path path, byte[] bytes) throws IOException {
        @Cleanup
        OutputStream out = Files.newOutputStream(path);
        out.write(bytes);
    }
}

展開イメージ

public class EmployeeBusiness {
    public static void writeBytes(Path path, byte[] bytes) throws IOException {
        OutputStream out = Files.newOutputStream(path);
        try {
            out.write(bytes);
        } finally {
            if (java.util.Collections.singletonList(out).get(0) != null) {
                out.close();
            }
        }
    }
}

@Log

Java標準Loggerのロガーインスタンスを保持するフィールドを自動生成します。

実装例

@Log
public class EmployeeBusiness {
}

展開イメージ

public class EmployeeBusiness {
    private static final java.util.logging.Logger log = java.util.logging.Logger.getLogger(EmployeeBusiness.class.getName());
}

@CommonsLog

Apache Commons Loggingのロガーインスタンスを保持するフィールドを自動生成します。

実装例

@CommonsLog
public class EmployeeBusiness {
}

展開イメージ

public class EmployeeBusiness {
    private static final org.apache.commons.logging.Log log = org.apache.commons.logging.LogFactory.getLog(EmployeeBusiness.class);
}

@Flogger

Fluent Loggerのロガーインスタンスを保持するフィールドを自動生成します。

実装例

@Flogger
public class EmployeeBusiness {
}

展開イメージ

public class EmployeeBusiness {
    private static final com.google.common.flogger.FluentLogger log = com.google.common.flogger.FluentLogger.forEnclosingClass();
}

@JBossLog

JBoss Loggingのロガーインスタンスを保持するフィールドを自動生成します。

実装例

@JBossLog
public class EmployeeBusiness {
}

展開イメージ

public class EmployeeBusiness {
    private static final org.jboss.logging.Logger log = org.jboss.logging.Logger.getLogger(EmployeeBusiness.class);
}

@Log4j

Apache log4jのロガーインスタンスを保持するフィールドを自動生成します。

実装例

@Log4j
public class EmployeeBusiness {
}

展開イメージ

public class EmployeeBusiness {
    private static final org.apache.log4j.Logger log = org.apache.log4j.Logger.getLogger(EmployeeBusiness.class);
}

@Log4j2

Apache Log4j 2のロガーインスタンスを保持するフィールドを自動生成します。

実装例

public class EmployeeBusiness {
}

展開イメージ

public class EmployeeBusiness {
    private static final org.apache.logging.log4j.Logger log = org.apache.logging.log4j.LogManager.getLogger(EmployeeBusiness.class);
}

@Slf4j

SLF4Jのロガーインスタンスを保持するフィールドを自動生成します。

実装例

@Slf4j
public class EmployeeBusiness {
}

展開イメージ

public class EmployeeBusiness {
    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(EmployeeBusiness.class);
}

@XSlf4j

SLF4Jの拡張ロガー(XLogger)インスタンスを保持するフィールドを自動生成します。

実装例

@XSlf4j
public class EmployeeBusiness {
}

展開イメージ

public class EmployeeBusiness {
    private static final org.slf4j.ext.XLogger log = org.slf4j.ext.XLoggerFactory.getXLogger(EmployeeBusiness.class);
}

@CustomLog

Lombokの設定ファイルで指定されたロガーインスタンスを保持するフィールドを自動生成します。

実装例

lombok.configのlombok.log.custom.declarationにロガーを取得するメソッドを記述します。

lombok.log.custom.declaration=penguin.log.LoggerFactory.getLogger(NAME)
@CustomLog
public class EmployeeBusiness {
}

展開イメージ

public class EmployeeBusiness {
    private static final penguin.log.LoggerFactory log = penguin.log.LoggerFactory.getLogger(EmployeeBusiness.class.getName());
}
hide index [:contents]

なぜequalsとhashCodeの両方をオーバーライドする必要があるのか

Javaで、データを格納するためのBeanクラスを作成したときに、ほとんどの場合、getter、setterも併せて実装します。
それとは別に、コンストラクタやequalsメソッド、hashCodeメソッドも実装するかもしれません。
今回は、このequalsメソッド、hashCodeメソッドのお話です。

はじめに、皆さん、このequalsメソッドとhashCodeメソッドの処理内容ってご存じでしょうか?

equalsメソッド

equalsメソッドは値の同値性を検証するものです。

String型オブジェクトで、同一の文字列であることを検証するときにequalsメソッドを使いますよね。

「==」演算子オブジェクトの同一性を検証するもので、「equals」メソッドは値の同値性を検証するものです。なので、Stringの文字列が同値であることを検証するのであれば、equalsメソッドを使用する必要があります。

        String s1 = new String("matsuki");
        String s2 = new String("matsuki");

        System.out.println(s1 == s2);      // 別オブジェクトなのでfalseになる
        System.out.println(s1.equals(s2)); // 同一の値なのでtrueになる

hashCodeメソッド

hashCodeメソッドはオブジェクトのハッシュ値を求めるものです。
ハッシュ値とは、オブジェクトの内容を基に算出された数値です。

ハッシュ値は次のような性質があります。

  • 元となる値が同一である場合、ハッシュ値少なからず同一の値になる
  • 元となる値が不一致である場合、ハッシュ値同じ値になるかもしれないし、別の値になるかもしれない

この特性から、断定的に言えることは、ハッシュ値が不一致の場合、元の値も不一致である、ということです。

hashCodeメソッドを直接使うことはあまりありませんが、Javaでは、2つのオブジェクトの同値性を検証するときに、hashCodeメソッドとequalsメソッドの2つが使われるケースがあります。

HashSetの「Hash」はハッシュ値の「Hash」

では、hashCodeメソッドとequalsメソッドが必要になるケースをJavaの標準ライブラリであるHashSetクラスを例に説明します。

HashSetクラスは、オブジェクトとともに、オブジェクトごとのハッシュ値を保持しています。

f:id:penguinlabo:20210511232457p:plain

オブジェクトの内容が同一の場合は、ハッシュ値必ず同一になりますが、見ての通り、オブジェクトの値が同一でない場合も、ハッシュ値同一になる場合があります

このHashSetに、新しいオブジェクトを追加するとします。HashSetは、値の重複は許さないので、初めに追加するオブジェクトと同じ値を持つオブジェクトが格納済みか検索します。

f:id:penguinlabo:20210511232931p:plain

検索方法としては、追加しようとしているオブジェクトのハッシュ値をhashCodeメソッドで取得します。
HashSetに格納されているオブジェクトは、ハッシュ値をすでに持っているので、追加するオブジェクトのハッシュ値とHashSetに格納されているハッシュ値を比較します。
ポイントは、hashCodeメソッドの呼び出しは、追加しようとしているオブジェクトで1回だけ呼び出されるということです。ハッシュ値の計算は1回だけです。

f:id:penguinlabo:20210511233246p:plain

ハッシュ値が一致しても値が一致するとは限らないので、ハッシュ値が一致したオブジェクトと追加しようとするオブジェクトの同値性をequalsメソッドで検証します。

なぜ、はじめにハッシュ値を比較するのか

この比較方法を見て、「ハッシュ値の比較なんてせず、はじめからequalsメソッドで比較すればいいのに」と思う人もいるかもしれません。

しかし、equalsメソッドはオブジェクトの中身のすべてが同一であることを検証するので、実行コストがかかる処理です。
それに対し、ハッシュ値の比較は、int値を比較するだけなので、極めて実行コストが低いのです。

実行コストの低い比較で、ある程度、ふるいにかけておいて、一致する可能性があるものを絞り込んだうえでequalsメソッドで同値性の検証をすることで、効率的に処理をしているのです。

試しに100万個のオブジェクトが格納されているHashSetに、新たなオブジェクトを追加するパフォーマンスを測定してみました。

hashCodeメソッドなし hashCodeメソッドあり
414ミリ秒 170ミリ秒

hashCodeメソッドがない場合は、ある場合と比較して2倍以上の処理時間がかかっています。
ただし、hashCodeがないからと言って、処理結果が変わるようなことはなく、処理自体は正しく行われます。

まとめ

equalsメソッドは、オブジェクトの同値性を検証するもの。オブジェクトの値が同一かどうかを検証するのであれば、equalsメソッドだけで実現できますが、比較にハッシュ値を利用する処理であれば、hashCodeメソッドを実装することでパフォーマンスの向上が見込めます。

値の同値性を求めるオブジェクトの場合は、equalsメソッドとhashCodeメソッドの両方を実装しておいたほうが良いです。
eclipseなどのIDEでコードの自動生成ができますし、lombokコンパイル時に動的に生成させることもできます。

Spring BootでテストするWebアプリケーション② - Controllerクラスのテスト

Spring Bootシリーズ。今回はWeb三層アプリケーションのControllerクラスのテストです。

テスト対象のControllerクラス

今回は、次の3つのメソッドが実装されているControllerクラスをテストします。

package penguin.web.controller;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import penguin.web.controller.form.ProfileForm;
import penguin.web.service.ProfileService;

@Controller
@RequestMapping("profile")
public class ProfileController {

    @Autowired
    private ProfileService profileService;

    @GetMapping("list")
    public String list(Model model) {
        List<ProfileForm> profileList = profileService.getProfileList()
            .stream()
            .map(e -> new ProfileForm(e.getName(), e.getBirthday(),
                    ChronoUnit.YEARS.between(e.getBirthday(), LocalDate.now())))
            .collect(Collectors.toList());
        model.addAttribute("profiles", profileList);

        return "profile/list";
    }

    @GetMapping("add")
    public String add(ProfileForm profileForm) {
        return "profile/add";
    }

    @PostMapping("add")
    public String add(@Validated ProfileForm profileForm, BindingResult result) {
        if (result.hasErrors()) {
            return "profile/add";
        }

        profileService.addProfile(profileForm.getName(), profileForm.getBirthday());

        return "redirect:/profile/list";
    }
}

Controllerクラスのテストクラス

テストクラスは次のようになります。
このテストクラスは、テストの実装方法を説明するための実装のみなので、テストケースとしては不足しています。

package penguin.web.controller;

import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.Map;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

import penguin.web.controller.form.ProfileForm;
import penguin.web.service.ProfileService;
import penguin.web.service.dto.ProfileDto;

@SpringBootTest
@AutoConfigureMockMvc
class ProfileControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ProfileService profileService;

    @Test
    @DisplayName("プロフィールが0件の場合、Modelに0件のProfileFormのリストが設定され、プロフィール一覧画面に遷移するること")
    void testGetListNoData() throws Exception {
        Mockito.when(profileService.getProfileList()).thenReturn(Collections.emptyList());

        MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/profile/list"))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.view().name("profile/list"))
            .andReturn();

        Map<String, Object> model = result.getModelAndView().getModel();

        Assertions.assertTrue(model.containsKey("profiles"));
        Assertions.assertNotNull(model.get("profiles"));
        Assertions.assertTrue(model.get("profiles") instanceof List<?>);
        List<ProfileForm> profileList = (List<ProfileForm>) model.get("profiles");
        Assertions.assertTrue(profileList.isEmpty());
    }

    @Test
    @DisplayName("プロフィールが1件の場合、Modelに1件のProfileFormのリストが設定され、プロフィール一覧画面に遷移するること")
    void testGetListOneData() throws Exception {
        Mockito.when(profileService.getProfileList())
                .thenReturn(Collections.singletonList(new ProfileDto(1L, "matsuki", LocalDate.of(1998, 1, 1))));

        try (MockedStatic<LocalDate> mockedLocalDate = Mockito.mockStatic(LocalDate.class, Mockito.CALLS_REAL_METHODS)) {
            LocalDate nowDate = LocalDate.of(2020, 7, 31);
            mockedLocalDate.when(LocalDate::now).thenReturn(nowDate);

            MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/profile/list"))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.view().name("profile/list"))
                .andReturn();

            Map<String, Object> model = result.getModelAndView().getModel();

            Assertions.assertTrue(model.containsKey("profiles"));
            Assertions.assertNotNull(model.get("profiles"));
            Assertions.assertTrue(model.get("profiles") instanceof List<?>);
            List<ProfileForm> profileList = (List<ProfileForm>) model.get("profiles");
            Assertions.assertEquals(1, profileList.size());
            ProfileForm profileForm = profileList.get(0);
            Assertions.assertEquals("matsuki", profileForm.getName());
            Assertions.assertEquals(LocalDate.of(1998, 1, 1), profileForm.getBirthday());
            Assertions.assertEquals(22, profileForm.getAge());
        }
    }

    @Test
    @DisplayName("プロフィール追加画面に遷移すること")
    void testGetAdd() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/profile/add"))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.view().name("profile/add"));
    }

    @Test
    @DisplayName("プロフィール追加で正当な値が入力された場合、プロフィールの追加処理が呼び出され、プロフィール一覧画面に遷移するること")
    void testPostAdd() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.post("/profile/add")
            .param("name", "penguin")
            .param("birthday", "1998-01-01"))
            .andExpect(MockMvcResultMatchers.status().isFound())
            .andExpect(MockMvcResultMatchers.view().name("redirect:/profile/list"));

        Mockito.verify(profileService, Mockito.times(1))
            .addProfile("penguin", LocalDate.of(1998, 1, 1));
    }

    @Test
    @DisplayName("プロフィール追加で生年月日が未入力の場合、生年月日にエラーがバインドされ、プロフィール追加画面に遷移するること")
    void testPostAddError() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.post("/profile/add")
            .param("name", "penguin")
            .param("birthday", ""))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.view().name("profile/add"))
            .andExpect(MockMvcResultMatchers.model().attributeHasErrors("profileForm"))
            .andExpect(MockMvcResultMatchers.model().errorCount(1))
            .andExpect(MockMvcResultMatchers.model().attributeHasFieldErrorCode("profileForm", "birthday", "NotNull"));
    }
}

テストクラスの実装内容

以下のようにクラスに2つのアノテーションが指定されています。

@SpringBootTest
@AutoConfigureMockMvc
class ProfileControllerTest {
@SpringBootTest

このアノテーションにより、Spring Bootのコンフィグレーションを自動検出して、テストに必要な設定がされます。

@AutoConfigureMockMvc

Controllerクラスはブラウザからのリクエストを受け付けるクラスです。
このアノテーションにより、テスト実行時にリクエストを発行するためのモックオブジェクト「MockMvc」が自動構成されます。

テストクラスのフィールド定義

このテストクラスには次の2つのフィールドが定義されています。

    @Autowired
    private MockMvc mockMvc;

クラスに指定した@AutoConfigureMockMvcアノテーションにより自動構成されたMockMvcオブジェクトをインジェクトします。
このオブジェクトを使って、各テストメソッドでControllerにリクエストを発行することになります。

    @MockBean
    private ProfileService profileService;

テスト対象のControllerクラスでインジェクトしているProfileServiceクラスを@MockBeanアノテーションを指定してフィールド定義しています。
@MockBeanアノテーションを指定することで、DIコンテナのProfileServiceクラスがモックオブジェクトになります。
モックオブジェクトのメソッドは呼び出しても何の処理もせず復帰します。復帰値は、オブジェクトの場合はnull、数値のプリミティブ型の場合は0、boolean型の場合はfalseを返すようになります。

テストメソッドの実装内容

個々のテストメソッドを確認していきます。

はじめに一番、単純なaddメソッドのテストメソッド実装から見ていきましょう。

テスト対象のメソッドは次の内容です。 画面表示するテンプレートのファイルパスを返すだけのメソッドです。

    @GetMapping("add")
    public String add(ProfileForm profileForm) {
        return "profile/add";
    }

この実装から、テストで検証するポイントを考えてみましょう。
次のような検証項目が考えられます。

  • URLは/profile/addであること
  • 受け付けるHTTPメソッドはGETであること
  • テンプレート名としてprofile/addを返却すること

これらの検証項目を検証するテストメソッドは次の内容です。

    @Test
    @DisplayName("プロフィール追加画面に遷移すること")
    void testGetAdd() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/profile/add"))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.view().name("profile/add"));
    }

では、テストメソッドの実装内容を分解して確認します。

        mockMvc.perform(MockMvcRequestBuilders.get("/profile/add"))

MockMvcを使って、/profile/addに対してGETリクエスを発行します。

            .andExpect(MockMvcResultMatchers.status().isOk())

リクエストした結果のHTTPステータスがOK(200)であることを確認します。

            .andExpect(MockMvcResultMatchers.view().name("profile/add"));

テンプレート名としてprofile/addが返却されることを確認します。

このように、MockMvcのperformメソッドでリクエストを発行し、andExpectメソッドでリクエストした結果を検証する流れとなります。

次に、プロフィールの追加画面でaddボタンをクリックしたときに呼び出されるaddメソッドのテストメソッド実装を見ていきましょう。

テスト対象のメソッドは次の内容です。 処理内容は大きく2つのルートに分かれます。

  • 入力エラーがある場合
    • テンプレート名としてprofile/addを返却して、再びプロフィールの追加画面を表示する。
  • 入力エラーがない場合
    • ProfileServiceクラスのaddProfileメソッドを呼び出してプロフィールを登録する。
    • テンプレート名としてredirect:/profile/listを返却して、プロフィール一覧画面にリダイレクトする。
    @PostMapping("add")
    public String add(@Validated ProfileForm profileForm, BindingResult result) {
        if (result.hasErrors()) {
            return "profile/add";
        }

        profileService.addProfile(profileForm.getName(), profileForm.getBirthday());

        return "redirect:/profile/list";
    }

テストメソッドは、入力エラーがある場合とない場合で分けて実装します。

入力エラーがないケースのテストメソッドは次の内容です。

    @Test
    @DisplayName("プロフィール追加で正当な値が入力された場合、プロフィールの追加処理が呼び出され、プロフィール一覧画面に遷移するること")
    void testPostAdd() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.post("/profile/add")
            .param("name", "penguin")
            .param("birthday", "1998-01-01"))
            .andExpect(MockMvcResultMatchers.status().isFound())
            .andExpect(MockMvcResultMatchers.view().name("redirect:/profile/list"));

        Mockito.verify(profileService, Mockito.times(1))
            .addProfile("penguin", LocalDate.of(1998, 1, 1));
    }

では、テストメソッドの実装内容を分解して確認します。

        mockMvc.perform(MockMvcRequestBuilders.post("/profile/add")
            .param("name", "penguin")
            .param("birthday", "1998-01-01"))

MockMvcを使って、/profile/addに対してPOSTリクエスを発行します。 POSTするパラメータはparamメソッドで指定します。

            .andExpect(MockMvcResultMatchers.status().isFound())

リクエストした結果のHTTPステータスがFound(302)であることを確認します。
リダイレクトの確認なので、OK(200)ではなく、Found(302)であることがポイントです。

            .andExpect(MockMvcResultMatchers.view().name("redirect:/profile/list"));

テンプレート名としてredirect:/profile/listが返却されることを確認します。

        Mockito.verify(profileService, Mockito.times(1))
            .addProfile("penguin", LocalDate.of(1998, 1, 1));

profileServiceはモックオブジェクトです。モックオブジェクトのメソッドは実装された処理が実行されないだけではなく、メソッドの呼び出しをトレースすることができます。
テスト対象の実装を確認すると、画面の入力内容を引数に、ProfileServiceのaddProfileメソッドが呼び出されるはずなので、実際に呼び出されたかを検証します。

Mockito.verifyメソッドを使って、profileServiceのaddProfileメソッドが、引数"penguin"とLocalDate.of(1998, 1, 1)で1回だけ呼び出されたことを検証できます。

次に、入力エラーがあるケースのテストメソッドです。内容は次の通りです。

    @Test
    @DisplayName("プロフィール追加で生年月日が未入力の場合、生年月日にエラーがバインドされ、プロフィール追加画面に遷移するること")
    void testPostAddError() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.post("/profile/add")
            .param("name", "penguin")
            .param("birthday", ""))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.view().name("profile/add"))
            .andExpect(MockMvcResultMatchers.model().attributeHasErrors("profileForm"))
            .andExpect(MockMvcResultMatchers.model().errorCount(1))
            .andExpect(MockMvcResultMatchers.model().attributeHasFieldErrorCode("profileForm", "birthday", "NotNull"));
    }
}

入力エラーを発生させるために、必須入力項目である生年月日を未入力としています。

        mockMvc.perform(MockMvcRequestBuilders.post("/profile/add")
            .param("name", "penguin")
            .param("birthday", ""))

では、テストメソッドの実装内容を分解して確認します。

            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.view().name("profile/add"))

HTTPステータスがOK(200)であることと、テンプレート名としてprofile/addが返されることを検証します。

            .andExpect(MockMvcResultMatchers.model().attributeHasErrors("profileForm"))

Modelオブジェクトに格納されているprofileFormにエラーが含まれることを検証します。

            .andExpect(MockMvcResultMatchers.model().errorCount(1))

Modelオブジェクトにエラーが1件、含まれることを検証します。

            .andExpect(MockMvcResultMatchers.model().attributeHasFieldErrorCode("profileForm", "birthday", "NotNull"));

Modelオブジェクトに格納されているprofileFormのbirthday項目にNotNullのエラーがバインドされていることを検証します。

最後に、プロフィール一覧画面を表示するときに呼び出されるlistメソッドのテストメソッド実装を見ていきましょう。

テスト対象のメソッドは次の内容です。
ProfileServiceのgetProfileListメソッドによって、データベースから取得されたプロフィール一覧をProfileFormに詰め替えて、テンプレートファイルprofile/listによって、プロフィールの一覧を表示するものです。
このメソッドの最大の検証ポイントは、生年月日と現在の日付から年齢を算出する部分です。

    @GetMapping("list")
    public String list(Model model) {
        List<ProfileForm> profileList = profileService.getProfileList()
            .stream()
            .map(e -> new ProfileForm(e.getName(), e.getBirthday(),
                    ChronoUnit.YEARS.between(e.getBirthday(), LocalDate.now())))
            .collect(Collectors.toList());
        model.addAttribute("profiles", profileList);

        return "profile/list";
    }

テストメソッドは、プロフィール一覧が0件のケースと1件のケースで分けています。

プロフィール一覧が0件のケースのテストメソッドは以下の内容です。

    @Test
    @DisplayName("プロフィールが0件の場合、Modelに0件のProfileFormのリストが設定され、プロフィール一覧画面に遷移するること")
    void testGetListNoData() throws Exception {
        Mockito.when(profileService.getProfileList()).thenReturn(Collections.emptyList());

        MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/profile/list"))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.view().name("profile/list"))
            .andReturn();

        Map<String, Object> model = result.getModelAndView().getModel();

        Assertions.assertTrue(model.containsKey("profiles"));
        Assertions.assertNotNull(model.get("profiles"));
        Assertions.assertTrue(model.get("profiles") instanceof List<?>);
        List<ProfileForm> profileList = (List<ProfileForm>) model.get("profiles");
        Assertions.assertTrue(profileList.isEmpty());
    }

では、テストメソッドの実装内容を分解して確認します。

        Mockito.when(profileService.getProfileList()).thenReturn(Collections.emptyList());

このテストケースの肝です。
profileServiceはモックオブジェクトです。モックオブジェクトのメソッドの復帰値はオブジェクトの場合はnull、プリミティブ型の数値の場合は0、booleanの場合はfalseですが、変更可能です。
ここでは、profileServiceオブジェクトのgetProfileListメソッドを呼び出した時の復帰値を0件のListオブジェクト(Collections.emptyList())に変更しています。
これによって、テスト対象メソッド内でprofileServiceオブジェクトのgetProfileListメソッドを呼び出した時に、必ず0件のListを返すようになります。

        MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/profile/list"))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.view().name("profile/list"))
            .andReturn();

HTTPステータスがOK(200)であることと、テンプレート名としてprofile/listが返されることを検証します。
andReturnメソッドを呼び出して、リクエストの実行結果が格納されているMvcResultオブジェクトを取得します。このMvcResultオブジェクトを使用して、詳細な検証をします。

        Map<String, Object> model = result.getModelAndView().getModel();

MvcResultオブジェクトからModelを取得します。

        Assertions.assertTrue(model.containsKey("profiles"));
        Assertions.assertNotNull(model.get("profiles"));
        Assertions.assertTrue(model.get("profiles") instanceof List<?>);
        List<ProfileForm> profileList = (List<ProfileForm>) model.get("profiles");
        Assertions.assertTrue(profileList.isEmpty());

Modelオブジェクトに格納されているprofilesの内容を検証します。

プロフィール一覧が1件のケースのテストメソッドは以下の内容です。

基本的にはプロフィール一覧が0件のケースと同じ検証内容ですが、現在の日付がわかると年齢の期待値も変わってしまうので、現在の日付を固定化する必要があります。

    @Test
    @DisplayName("プロフィールが1件の場合、Modelに1件のProfileFormのリストが設定され、プロフィール一覧画面に遷移するること")
    void testGetListOneData() throws Exception {
        Mockito.when(profileService.getProfileList())
                .thenReturn(Collections.singletonList(new ProfileDto(1L, "matsuki", LocalDate.of(1998, 1, 1))));

        try (MockedStatic<LocalDate> mockedLocalDate = Mockito.mockStatic(LocalDate.class, Mockito.CALLS_REAL_METHODS)) {
            LocalDate nowDate = LocalDate.of(2020, 7, 31);
            mockedLocalDate.when(LocalDate::now).thenReturn(nowDate);

            MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/profile/list"))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.view().name("profile/list"))
                .andReturn();

            Map<String, Object> model = result.getModelAndView().getModel();

            Assertions.assertTrue(model.containsKey("profiles"));
            Assertions.assertNotNull(model.get("profiles"));
            Assertions.assertTrue(model.get("profiles") instanceof List<?>);
            List<ProfileForm> profileList = (List<ProfileForm>) model.get("profiles");
            Assertions.assertEquals(1, profileList.size());
            ProfileForm profileForm = profileList.get(0);
            Assertions.assertEquals("matsuki", profileForm.getName());
            Assertions.assertEquals(LocalDate.of(1998, 1, 1), profileForm.getBirthday());
            Assertions.assertEquals(22, profileForm.getAge());
        }
    }

では、テストメソッドの実装内容を分解して確認します。

        Mockito.when(profileService.getProfileList())
                .thenReturn(Collections.singletonList(new ProfileDto(1L, "matsuki", LocalDate.of(1998, 1, 1))));

0件のケースと同様、ProfileServiceのモッククラスのgetProfileListメソッドの復帰値を変更して、必ず1件のListを返すようになります。

        try (MockedStatic<LocalDate> mockedLocalDate = Mockito.mockStatic(LocalDate.class, Mockito.CALLS_REAL_METHODS)) {
            LocalDate nowDate = LocalDate.of(2020, 7, 31);
            mockedLocalDate.when(LocalDate::now).thenReturn(nowDate);

テスト対象のメソッドでは、LocalDateクラスのstaticメソッドであるnowメソッドを呼び出して、現在の日付を取得しています。このnowメソッドの復帰値を固定化するために、Mockitoの機能を使用します。

MockitoのmockStaticメソッドの引数にモック化したいLocalDateのクラスを指定します。通常、モック化したクラスのすべてのメソッドは、何も処理をせず、復帰値はオブジェクトの場合はnull、プリミティブ型の数値型は0、booleanはfalseを返すようになります。
ただし、今回、復帰値を変更したいメソッドはnowメソッドだけで、ほかのメソッドは今まで通りの動作をしてほしいので、MockitoのmockStaticメソッドの第2引数にMockito.CALLS_REAL_METHODSを指定します。

MockitoのmockStaticメソッドの復帰値であるMockedStaticは、最後にcloseメソッドを呼ぶ必要があります。closeメソッドを呼ばないと、モック化が解除されず、ほかのテストクラスの実行に影響を及ぼします。MockedStaticはAutoCloseableを実装しているので、try with resources構文を使ってクローズします。

            LocalDate nowDate = LocalDate.of(2020, 7, 31);
            mockedLocalDate.when(LocalDate::now).thenReturn(nowDate);

現在の日付を固定化する部分です。
MockitoのmockStaticメソッドの復帰値のMockedStaticオブジェクトを使用します。whenメソッドでモック化するメソッドをラムダ式で指定します。そして、thenReturnメソッドでモックメソッドの復帰値を指定します。

この実装を見て、ローカル変数「nowDate」はここでしか使用されていないので、以下のように1行にまとめたくなるかもしれません。

            mockedLocalDate.when(LocalDate::now).thenReturn(LocalDate.of(2020, 7, 31));

全く問題のないコードに見えますが、Mockito的に、このコードはNGです。実際に実行すると、例外が発生してしまいます。
理由は、モック化されたメソッドの復帰値の定義中にモック化されたメソッドを呼び出してはいけないからです。
1行にまとめた記述方法では、LocalDateのnowメソッドの復帰値の定義内で、モック化されたLocalDateのofメソッドを呼び出してしまいます。なので、ofメソッドをあらかじめ呼び出して、その復帰値をモックの復帰値の定義で指定する必要があるのです。

別の解決方法で、thenReturnではなく、thenAnswerを使う方法があります。thenAnswerの使い方は、今回の本題から外れてしまうので、別の機会に解説します。

            MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/profile/list"))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.view().name("profile/list"))
                .andReturn();

            Map<String, Object> model = result.getModelAndView().getModel();

            Assertions.assertTrue(model.containsKey("profiles"));
            Assertions.assertNotNull(model.get("profiles"));
            Assertions.assertTrue(model.get("profiles") instanceof List<?>);
            List<ProfileForm> profileList = (List<ProfileForm>) model.get("profiles");
            Assertions.assertEquals(1, profileList.size());
            ProfileForm profileForm = profileList.get(0);
            Assertions.assertEquals("matsuki", profileForm.getName());
            Assertions.assertEquals(LocalDate.of(1998, 1, 1), profileForm.getBirthday());
            Assertions.assertEquals(22, profileForm.getAge());

残りの処理は、0件のケースと同様です。処理を呼び出して、処理結果の検証をします。

次回予告

今回は、Spring Bootで作成したWeb三層アプリケーションのApplication層にあたるControllerクラスのユニットテストを実装しました。
次回は、Business層にあたるServiceクラスのユニットテストを実装します。
では、次回の「Spring BootでテストするWebアプリケーション③ - Serviceクラスのテスト」でお会いしましょう!!

Spring Frameworkで作るWeb三層アプリケーション

今さらですが、Spring FrameworkでWebアプリケーションを作るときに基本となる、Web三層について、実装を交えてまとめます。

そもそもWebアプリケーションって?

Webアプリケーションの種類にもよりますが、Webアプリケーションは基本的に次の流れに沿って処理するものが大半です。

  1. データベースの内容をブラウザに表示

  2. 利用者が表示された内容に対して、アクションを起こす

  3. 利用者のアクションの内容を処理・データベースに反映

  4. 処理結果をブラウザに表示

これを、ショッピングサイトに例えると、次のようになります。

  1. データベースから商品一覧をブラウザに表示

  2. 利用者が購入する商品を購入

  3. 購入内容から、在庫を確認し、在庫があれば注文結果をデータベースに保存

  4. 注文を受け付けたことをブラウザに表示

ほとんどのWebアプリケーションは、この手順の繰り返しになります。

Web三層アプリケーションって?

Webアプリケーションを作るときに、アプリケーションを次の3つの層に分けたアプリケーションを「Web三層アプリケーション」と呼びます。

Application層

ブラウザへの画面表示、ブラウザの入力内容の受け取りをするための層です。
具体的には次の処理を主に実装します。

  • 画面の入力内容のチェックとエラーメッセージの表示

  • 画面に表示するときの表示形式の変換

Domain層

業務処理を実装するための層です。
画面で入力されたデータを処理し、次に画面に表示するデータを用意します。

Infrastructure層

データベースや外部サービスとのやり取りを実装するための層です。
基本的には、データの取得や保存処理のみ実装します。

各層に実装するSpring Frameworkコンポーネント

各層には処理を実装するためのクラスと、層をまたいでデータを受け渡すためのBeanを実装します。

f:id:penguinlabo:20210507180535p:plain

この図を見てもわかるように、各層にクラスとBeanが1つずつ登場していることがわかります。
ただし、この図の登場人物をすべて実装することが必須ではありません。例えば、Application層からDomain層に渡すデータが単一の項目の場合、わざわざDTO Beanを使わずに、Service Classのメソッドの引数に指定する場合もあります。

ぱっと見、登場人物が多くて、複雑な図に見えるかもしれませんが、データを受け渡すためのBeanを省略して、処理の流れだけにフォーカスすると、とてもシンプルな流れであることがわかります。

f:id:penguinlabo:20210507180927p:plain

Spring FrameworkでWeb三層アプリケーションを実装

では、実際にSpring Frameworkを使って、Web三層アプリケーションを実装します。
今回は次のような画面を持つアプリケーションを実装します。

f:id:penguinlabo:20210507193423p:plain

プロジェクトの構成

Web三層アプリケーションのプロジェクト構成に特に規定はありませんが、各層をパッケージで分けるのがわかりやすいです。

f:id:penguinlabo:20210507195759p:plain

Application層の実装

Controllerクラス

パッケージ「controller」を作成して、そこにコントローラークラスを配置します。

package penguin.web.controller;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import penguin.web.controller.form.ProfileForm;
import penguin.web.service.ProfileService;

@Controller
@RequestMapping("profile")
public class ProfileController {

    @Autowired
    private ProfileService profileService;

    @GetMapping("list")
    public String list(Model model) {
        List<ProfileForm> profileList = profileService.getProfileList()
            .stream()
            .map(e -> new ProfileForm(e.getName(), e.getBirthday(),
                    ChronoUnit.YEARS.between(e.getBirthday(), LocalDate.now())))
            .collect(Collectors.toList());
        model.addAttribute("profiles", profileList);

        return "profile/list";
    }

    @GetMapping("add")
    public String add(ProfileForm profileForm) {
        return "profile/add";
    }

    @PostMapping("add")
    public String add(@Validated ProfileForm profileForm, BindingResult result) {
        if (result.hasErrors()) {
            return "profile/add";
        }

        profileService.addProfile(profileForm.getName(), profileForm.getBirthday());

        return "redirect:/profile/list";
    }
}
Form Bean

Form Beanは、「controller」パッケージ配下に「form」パッケージを作成して、そこに配置します。

package penguin.web.controller.form;

import java.time.LocalDate;

import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

import org.springframework.format.annotation.DateTimeFormat;

public class ProfileForm {

    @Size(min = 3, max = 15)
    private String name;

    @NotNull
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate birthday;

    private Long age;

    public ProfileForm(@Size(min = 3, max = 15) String name, @NotNull LocalDate birthday, Long age) {
        super();
        this.name = name;
        this.birthday = birthday;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public LocalDate getBirthday() {
        return birthday;
    }

    public void setBirthday(LocalDate birthday) {
        this.birthday = birthday;
    }

    public Long getAge() {
        return age;
    }

    public void setAge(Long age) {
        this.age = age;
    }
}
画面テンプレート

画面テンプレートはJavaファイルではないので、src/main/resourcesに「templates」フォルダを作成して、その下に配置します。
ここでは、「templates」フォルダ配下に「profile」フォルダを作成して、そこにテンプレートファイル「list.html」と「add.html」を作成します。

<!DOCTYPE html>
<html lang="ja" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>List</title>
<style type="text/css">
<!--
table {
    border-collapse: collapse;
}
th, td {
    border: 1px darkgray solid;
    padding: 0.2em 0.5em;
}
-->
</style>
</head>
<body>
<table>
    <caption>Profile List</caption>
    <thead>
        <tr>
            <th scope="col">Name</th>
            <th scope="col">Birthday</th>
            <th scope="col">Age</th>
        </tr>
    </thead>
    <tbody>
        <tr th:each="profile : *{profiles}">
            <td th:text="${profile.name}" />
            <td th:text="${profile.birthday}" />
            <td th:text="${profile.age}" />
        </tr>
    </tbody>
</table>
<a th:href="@{/profile/add}"><input type="button" value="add" /></a>
</body>
</html>
<!DOCTYPE html>
<html lang="ja" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Add</title>
<style>
.input-error {
    border: 1px solid red;
}
</style>
</head>
<body>
<form th:action="@{/profile/add}" th:object="${profileForm}" method="post">
    <p>
        <label for="name">名前</label>
        <input type="text" name="name" id="name" th:field="*{name}" th:errorclass="input-error">
        <span style="color: red;" th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></span>
    </p>
    <p>
        <label for="birthday">生年月日</label>
        <input type="date" name="birthday" id="birthday" th:field="*{birthday}" th:errorclass="input-error">
        <span style="color: red;" th:if="${#fields.hasErrors('birthday')}" th:errors="*{birthday}"></span>
    </p>
    <p>
        <a th:href="@{/profile/list}"><input type="button" value="Cancel" /></a>
        <input type="submit" value="Ok">
    </p>
</form>
</body>
</html>

Domain層の実装

Serviceクラス

パッケージ「service」を作成して、そこにサービスクラスのインターフェスと実装クラスを配置します。

package penguin.web.service;

import java.time.LocalDate;
import java.util.List;

import penguin.web.service.dto.ProfileDto;

public interface ProfileService {
    List<ProfileDto> getProfileList();

    Long addProfile(String name, LocalDate birthday);
}
package penguin.web.service;

import java.time.LocalDate;
import java.util.List;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import penguin.web.repository.ProfileRepository;
import penguin.web.repository.entity.ProfileEntity;
import penguin.web.service.dto.ProfileDto;

@Service
@Transactional(rollbackFor = Exception.class)
public class ProfileServiceImpl implements ProfileService {
    @Autowired
    private ProfileRepository profileRepository;

    @Override
    public List<ProfileDto> getProfileList() {
        return profileRepository.findAll()
            .stream()
            .map(e -> new ProfileDto(e.getId(), e.getName(), e.getBirthday()))
            .collect(Collectors.toList());
    }

    @Override
    public Long addProfile(String name, LocalDate birthday) {
        ProfileEntity profile = new ProfileEntity();
        profile.setName(name);
        profile.setBirthday(birthday);
        profile = profileRepository.save(profile);
        return profile.getId();
    }
}
DTO Bean

DTO Beanは、「service」パッケージ配下に「dto」パッケージを作成して、そこに配置します。

package penguin.web.service.dto;

import java.time.LocalDate;

public class ProfileDto {

    private Long id;

    private String name;

    private LocalDate birthday;

    public ProfileDto(Long id, String name, LocalDate birthday) {
        super();
        this.id = id;
        this.name = name;
        this.birthday = birthday;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public LocalDate getBirthday() {
        return birthday;
    }

    public void setBirthday(LocalDate birthday) {
        this.birthday = birthday;
    }
}

Infrastructure層の実装

Repositoryクラス

パッケージ「repository」を作成して、そこにリポジトリクラスのインターフェスクラスを配置します。

package penguin.web.repository;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import penguin.web.repository.entity.ProfileEntity;

@Repository
public interface ProfileRepository extends JpaRepository<ProfileEntity, Long> {
    List<ProfileEntity> findByName(String name);
}
Entity Bean

Entity Beanは、「repository」パッケージ配下に「entity」パッケージを作成して、そこに配置します。

package penguin.web.repository.entity;

import java.time.LocalDate;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

@Entity
@Table(name = "profile")
public class ProfileEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    private LocalDate birthday;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public LocalDate getBirthday() {
        return birthday;
    }

    public void setBirthday(LocalDate birthday) {
        this.birthday = birthday;
    }
}

その他のファイル

Web三層アプリケーションの実装とは関係ありませんが、以下のファイルが、本プロジェクトで実装しています。

Spring Bootアプリケーションクラス

本プロジェクトはSpring Bootで作成したので、Spring Bootの起動クラスを作成しています。

package penguin.web;

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

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
Spring Bootアプリケーションプロパティファイル
server.servlet.context-path=/penguin-web
spring.jpa.show-sql=true
Gradleファイル

本プロジェクトはGradleプロジェクトとして作成したので、build.gradleを以下の内容に編集しています。

plugins {
    id 'java-library'
    id 'org.springframework.boot' version '2.4.5'
}
apply plugin: 'war'
apply plugin: 'eclipse-wtp'
apply plugin: 'io.spring.dependency-management'

sourceCompatibility = '1.8'
targetCompatibility = '1.8'

compileJava.options.encoding = 'UTF-8'
compileTestJava.options.encoding = 'UTF-8'

eclipse.wtp {
    component.contextPath = 'penguin-web'
    facet {
        facet name: 'jst.java', version: '1.8'
        facet name: 'wst.jsdt.web', version: '1.0'
        facet name: 'jst.web', version: '3.1'
    }
}

repositories {
    jcenter()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.h2database:h2'
}