Spring Bootのテスト

Spring BootプロジェクトでのJUnitテスト

テスト、JUnitの概要」の単元では、JUnitについて学び、素のJavaプロジェクトにおいてJUnitを使用して単体テストを行う基本の方法を学習しました。
JUnitの基礎知識をベースに、このページではSpring Bootプロジェクトを対象とした単体テストの実践方法を学習します。

テスト環境の準備

spring-boot-starter-testの導入

Spring Bootでテストを実施するには、spring-boot-starter-testを使用します。
本マニュアルの環境では基本的にプロジェクト作成時に導入されますが、プロジェクト直下のpom.xmlに下記の記述があることを確認してください。
記述がない場合は追記し、プロジェクトを更新します。(dependenciesタグ内)

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-test</artifactId>
	<scope>test</scope>
</dependency>

spring-boot-starter-testには以下のライブラリが含まれます。

ライブラリ説明
JUnit 5Javaアプリケーションのユニットテストの事実上の標準ライブラリ。テストの実行、テストメソッドの定義などを担う。
Spring Test & Spring Boot TestSpring Bootアプリケーションのユーティリティと統合テストのサポート。 @SpringBootTestなどのSpring固有のテストアノテーションや機能を提供。
AssertJ流暢なアサーションライブラリ。テスト結果の検証(期待値と実際の値の比較など)を読みやすく記述可能。
Hamcrestテスト結果の検証に使用できる、マッチャーオブジェクト(制約または述語とも呼ばれる)のライブラリ
MockitoJavaモックフレームワーク。依存関係にあるオブジェクトをモック(偽物)に置き換えて、テスト対象クラスを隔離してテストできる。
JSONassertJSON用のアサーションライブラリ。レスポンスなどのJSON構造を検証できる。
JsonPathJSONにおいてXPathのようなクエリ形式で要素を検索するライブラリ。 JSONデータから特定の要素を抽出可能。
Awaitility非同期システムをテストするためのライブラリ

インメモリデータベース(H2)の活用、テスト環境の設定

Webアプリケーションのユニットテストでは、データベースアクセスに関してもテストを行うことができます。
しかし、テストでDBを直接扱うとなると、もしテスト実行の度にテーブルやそのデータが書き換わってしまったら何かと困る可能性があります。
そこで、普段使いのDB接続(本番環境)とは別に、「テスト環境」を使用することができます。
まずはこのテスト環境向けの設定をしていきましょう。
Springでは通常のDB接続設定などはそのままに、「テストを実行する時はこのDBやスキーマを使って」といった設定をできます。

ここでは、テスト用のDBとしてインメモリ型のH2データベースを使用します。
H2 Databaseとは、Javaプラットフォーム上で動く、インメモリ型(全てのデータをメモリ上に持つ)で高速・軽量な、オープンソースのRDBMSです。
メモリ上にしかデータを保存しないため、Spring Boot起動中以外にはデータベースを利用することができません。アプリケーションが終了したりシステムが再起動されたりすると、データが失われます。本番には向きませんが、裏を返すとテスト利用には有用です。

pom.xmlのdependenciesタグ内に下記の依存関係を追記することで、H2データベースを利用できます。

<dependency>
	<groupId>com.h2database</groupId>
	<artifactId>h2</artifactId>
</dependency>


続いて、「テスト時にのみ有効にする」設定です。
下記のようにテストフォルダ階層に「application.properties」を配置し、その中に記述します。
通常時の起動時にはこれまで使用してきた「src/main/resources/application.properties」のみが使用され、テスト実行時には下記の「src/test/resources/application.properties」の記述が優先して扱われます。
【src/test/resources/application.properties】

テスト用application.propertiesには、下記の内容を記載してください。
記載後、プロジェクトの更新を実行します。

#### DB接続情報
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sample
spring.datasource.password=
spring.sql.init.encoding=UTF-8

#Spring Bootによる外部SQLスクリプトの実行(neverの場合:無効になり、schema.sqlやdata.sqlの実行は自分で管理する)
spring.sql.init.mode=never

#JPA/Hibernateによるエンティティからのスキーマ自動生成(noneの場合:Entityから全テーブル生成を実行されないようにする)
spring.jpa.hibernate.ddl-auto=none

ここでは、DB接続情報の他、spring.sql.init.mode と spring.jpa.hibernate.ddl-autoについても設定しています。
・spring.sql.init.mode については、テストを行う際にテーブル作成やデータ挿入を行わせるSQLファイルを用意してテストに合わせて使用しますが、そのSQLの実行を制御するために記述しています。上記の”never”を設定をせずデフォルト(always)とした場合、src/main/resources や src/test/resources に用意したSQLファイルがすべて自動で実行されます。
・spring.jpa.hibernate.ddl-auto については、デフォルト(create-drop)の場合、テストの都度Hibernateにより @Entity クラスの情報を元に全テーブルを自動生成・終了時に削除されます。本章では全て作成する必要はないので、ここでは”none”とし無効にしています。

ここで紹介した設定はごく一部です。この他にもありますので、必要な時は適宜調べて使用しましょう。
参考サイト:Guide on Loading Initial Data with Spring Boot | Baeldung

各機能の単体テスト

Repositoryクラスのテスト

データベースアクセスを行うRepositoryクラスが正しくデータ操作を実行できることを確認します。

@DataJpaTestアノテーションの使用

Repositoryのテストクラスには@DataJpaTestアノテーションを付与します。
@DataJpaTestは、JPAコンポーネントのテストに特化したアノテーションで、以下の設定が自動的に行われます。
・H2(インメモリデータベース)のセットアップ
・@Entity クラスをスキャン、JPAリポジトリをコンポーネントとして構成
・トランザクションを有効化し、テストメソッドの実行後に自動でロールバック(テストごとにデータベースの状態がリセットされるため、各テストが独立して実行できる)

Repositoryテストの実装

テスト対象のメソッド

EmployeeRepositoryに下記2つのメソッドが含まれている状態とし、この2つのメソッドに対してテストを実装していきます。(Repositoryにその他のメソッドが存在しても問題ありません)
【src/main/java/com/cmps/spring/repository/EmployeeRepository.java】

    /**
     * 名前フィールドに引数の文字列を含むデータを取得する(あいまい検索)
     * 
     * @param name String 名前
     * @return Iterable<Employee> Employeeのコレクション
     */
    List<Employee> findByNameContaining(String name);

    /**
     * 名前フィールドが引数の文字列と一致するデータを取得する
     * 
     * @param name String 名前
     * @return Employee
     */
    Optional<Employee> findByNameEquals(String name);
テスト用ファイルの配置

今回は、下記のように3ファイルを用意します。

テストクラスで使用するファイル、”/schema-emoloyee.sql”, “/data-emoloyee.sql”を用意します。
SQL文についてはH2データベース準拠にする必要があるので、OracleDBのSQLとは一部異なる部分があります。
【src/test/resources/schema-emoloyee.sql】

DROP TABLE IF EXISTS employees;
CREATE TABLE employees (
    id INTEGER GENERATED BY DEFAULT AS IDENTITY,
    code CHAR(4),
    name VARCHAR(100),
    age INTEGER,
    created_at TIMESTAMP WITH TIME ZONE,
    modified_at TIMESTAMP WITH TIME ZONE,
    PRIMARY KEY(id)
);

【src/test/resources/data-emoloyee.sql】

INSERT INTO employees (code, name, age, created_at, modified_at) VALUES('0001', '田中', 25, NULL, NULL);
INSERT INTO employees (code, name, age, created_at, modified_at) VALUES('0002', '山田', 30, NULL, NULL);
INSERT INTO employees (code, name, age, created_at, modified_at) VALUES('0003', '佐々木', 26, NULL, NULL);
INSERT INTO employees (code, name, age, created_at, modified_at) VALUES('0004', '鈴木', 18, NULL, NULL);
INSERT INTO employees (code, name, age, created_at, modified_at) VALUES('0005', '中村', 44, NULL, NULL);
INSERT INTO employees (code, name, age, created_at, modified_at) VALUES('0006', '田中', 40, NULL, NULL);
INSERT INTO employees (code, name, age, created_at, modified_at) VALUES('0007', '山田', 52, NULL, NULL);


テストクラス
【src/test/java/com/cmps/spring/repository/EmployeeRepositoryTest.java

package com.cmps.spring.repository;

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;

import java.util.List;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.jdbc.Sql;
import com.cmps.spring.entity.Employee;

@DataJpaTest
class EmployeeRepositoryTest {

@Autowired
private EmployeeRepository repository;

/**
* 名前の一致データ取得メソッド
*/
@Test
@Sql("/schema-emoloyee.sql")
@DisplayName("名前が一致するレコードを取得する")
void testFindByNameEquals() throws Exception {

// 準備(オブジェクト生成、DBに登録)
Employee expected = new Employee(null, "0900", "秋葉", 30, null, null);
repository.save(expected);

// 実行
Employee employee = repository.findByNameEquals("秋葉").orElse(null);

// 検証
// nullでないことを確認
assertNotNull(employee);
//データの確認
assertEquals(expected, employee);
}

/**
* 名前を含むリストを取得するメソッド
*/
@Test
@Sql({ "/schema-emoloyee.sql", "/data-emoloyee.sql" }) // 準備→SQLファイルで実行
@DisplayName("名前を含むリストを取得する - 取得件数の一致を確認")
void testFindByNameContaining() throws Exception {

// 実行
List<Employee> list = repository.findByNameContaining("田中");

// 検証
assertThat(list.size()).isEqualTo(2);
}
}

・まず、2つのテストメソッドに共通して登場する@Sqlアノテーションについて、これは指定したSQLファイルをSpringのテスト実行時に自動的に実行するためのアノテーションです。このアノテーションを用いることで、テストの準備としてデータベースのスキーマ作成やテストデータの投入を簡単に行うことができます。
・testFindByNameEqualsメソッドでは、テーブル作成の”/schema-emoloyee.sql”のみ使用し、検証に用いるデータ自体はsaveメソッドで登録しています。
・testFindByNameContainingメソッドではデータ挿入する”/data-emoloyee.sql”も使用しています。

・1つ目のメソッド 名前の一致データを取得する testFindByNameEqualsメソッドについて
ここではCRUDメソッドのsaveメソッドによるデータ挿入には問題ないことを前提として、saveメソッドでテストデータを登録しています。
このテストデータが問題なく取得できるはず、という想定のもと、findByNameEqualsメソッドを実行して実測値を取得します。
検証として、assertNotNullメソッドでemployeeがnullでないこと(データが取得できていること)、assertEqualsメソッドでEmployeeオブジェクト同士を比較し一致することを確認しています。
なお、「どのようなアサーション(検証)メソッドを使用して正しいことを示すか」については絶対的な正解はありませんので、プロジェクトや現場の方針によります。

・2つ目のメソッド 名前を含むリストを取得する testFindByNameContainingメソッドについて
前述した通り、@SQLに指定したSQLファイルでテストデータが挿入された後に、このメソッドの処理は実行されます。
ここでは、テストデータにnameが”田中”のデータが2データ含まれていることから、「テスト対象のメソッドを実行したときに得られるリストは、2件のEmployeeオブジェクトを含むはず」という予想が立てられます。したがって検証の内容としては、取得件数が合っていることを判定しています。

テストの実行

※テスト実行前にSpringBootアプリケーションを実行中かどうか確認し、実行中の場合は停止しておきます。

テストの実行方法は、以前実施したJUnitと同様で以下の通り
テストファイルを右クリック>実行>JUnitテスト (または Alt+Shift+X, T)

実行が完了するとコンソール出力が止まり、JUnitビューから結果を確認できます。
次の章以降で紹介するテストについても、実行方法は同様です。

Serviceクラスのテスト

ビジネスロジックの核となるServiceクラスが期待通りに動作することを確認します。
Serviceクラスは通常、Repositoryや他のServiceに依存しています。単体テストでは、これらの依存関係をモック化することで、Serviceクラス自身のロジックのみに焦点を当てて検証します。
モック化とは、テスト対象が依存しているコンポーネントを偽物のオブジェクト(モックオブジェクト)に置き換えることを言います。Serviceクラスの場合、Repository等をモック化する、すなわち実際のデータベースアクセスは実行しません。モック化はテストの高速化にも役立ちます。

モック化:Mockitoライブラリの使用

・@ExtendWith(MockitoExtension.class)アノテーションの記述で、JUnit5でMockitoのアノテーションを有効にできます。
・@Mock:依存関係にあるコンポーネントに使用することで、モックオブジェクトを作成します。
・@InjectMocks:テスト対象のServiceクラスに使用します。作成したモックオブジェクトが自動的に注入されます。

Serviceテストの実装

テスト対象のメソッド

EmployeeServiceに下記のメソッドが含まれる前提とし、このメソッドに対してテストを実装していきます。
idで検索してEmployeeオブジェクトを返却するメソッドです。
【src/main/java/com/cmps/spring/service/EmployeeService.java】

    /**
     * ID検索して見つかればEmployeeオブジェクトを取得、見つからない場合はnullを返す
     * 
     * @param id int
     * @return Employee
     */
    public Employee findById(int id) {
        Employee employee = employeeRepository.findById(id).orElseGet(null);

        return employee;
    }
テストクラス

【src/test/java/com/cmps/spring/service/EmployeeServiceTest.java

package com.cmps.spring.service;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

import java.util.Optional;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import com.cmps.spring.entity.Employee;
import com.cmps.spring.repository.EmployeeRepository;

@ExtendWith(MockitoExtension.class) // モックBeanの生成を有効化(Mockito機能)
public class EmployeeServiceTest {

    // モックオブジェクト(実際のデータではなく検証用)を生成
    @Mock
    private EmployeeRepository repository;

    // モックを注入(使用)するテスト対象
    @InjectMocks
    private EmployeeService service;

    /**
     * ID検索するメソッド_検索できた時
     */
    @Test
    @DisplayName("ID検索_検索できた時")
    void testFindById_found() throws Exception {

        // 想定結果の準備
        Employee expected = new Employee(1, "0001", "田中", 25, null, null);

        // モックの設定:repository.findById()を実行したときの戻り値を、thenReturnで定義
        when(repository.findById(expected.getId())).thenReturn(Optional.of(expected));

        // メソッドを呼び出して実測値を取得
        // 上記モックを踏まえてServiceのfindByIdメソッドを実行・取得する
        Employee result = service.findById(expected.getId());

        // 想定結果と実測値が等しいか確認
        assertEquals(result, expected);

        // モックのfindByIdが1回だけ呼ばれたことを確認
        verify(repository, times(1)).findById(expected.getId());
    }

    /**
     * ID検索するメソッド_見つからなかった時
     */
    @Test
    @DisplayName("ID検索_見つからなかった時")
    void testFindById_notFound() throws Exception {

        // モックの設定
        //一致するデータがないidを指定したとき、repositoryは空のOptionalインスタンスを返す
        int unExistId = 1000;
        when(repository.findById(unExistId)).thenReturn(Optional.empty());

        // メソッドを呼び出して実測値を取得
        Employee result = service.findById(unExistId);

        // 想定結果がnullであることを確認
        assertNull(result);

        // モックのfindByIdが1回だけ呼ばれたことを確認
        verify(repository, times(1)).findById(unExistId);
    }

}

・前述した通り、テストクラスに @ExtendWith(MockitoExtension.class) を記述し、Mockito機能を有効にしています。
・また、@Mock、@InjectMocksアノテーションで、モックオブジェクトの生成と、モックの注入先の指定をしています。

・モックを使用する場合、モックの動きの肝になるのが when(△).thenReturn(○);です。
これはモックオブジェクトのメソッドが呼び出された時の振る舞い(戻り値や例外)を定義します。
when で「このメソッドが呼び出されたら」を指定し、これに対し「どんな戻り値を返すか」を thenReturn で定義します。
verify(repository, times(1)).findById(○); については、コメントに記載の通り、モックオブジェクトのうち正しいメソッドが正しい引数でその回数だけ呼び出されているかを確認しています。

・1つ目の 検索できた場合の検証を行う testFindById_foundメソッドでは
モックの定義として、repository.findById(expected.getId()) が実行されたら、Optional.of(expected) を返すよう定義しています。Optional.of() は読んで字のまま、このコード内ではOptional<Employee> を返します。
これにより、対象のServiceメソッドを実行し、メソッド内でemployeeRepository.findById が実行された時、データベースアクセスを行うことなく、上記でモックとして定義した通りの戻り値を返します。
検証としては、想定結果と実測値でオブジェクトが一致することを確認しています。

・2つ目の ID検索して見つからなかったときの検証 testFindById_notFoundメソッド では、
repository.findById(unExistId) が実行されたら、Optional.empty() (空のOptionalインスタンス)を返すように定義しています。
テスト対象のメソッドでは見つからなかったときに null を返すようになっていますので、返却された実測値がnullであることをassertNull で検証します。

・なお、ここまで出てきたwhen、verify、timesといったメソッドはMockitoクラスのメソッドです。import static org.mockito.Mockito.*; とMockitoクラスを静的インポート(staticインポート)することで、クラス名を省略してメソッドを呼び出せるようになっています。

RepositoryとServiceのテスト(結合テスト)

ここまで見てきたServiceクラスのテストでは、モックを用いる方法を学んできましたが、モックを用いずに実際のデータベースアクセスを行って、Serviceメソッドを介してRepositoryとServiceをまとめてテストすることもできます。
複数の部品を組み合わせてテストする、という意味で狭義にはこれも結合テストとも言えます。

結合テストについて

単体テストがメソッドなどのプログラムを構成する小さな単位・1機能ごとに対する検証を行うのに対し、結合テスト複数の機能が連携して正しく動作することを確認します。
例えば、「一覧表示機能」「新規登録機能」があったとき、データを新規登録した後に一覧表示画面を確認すると登録したデータが確認できる、といったような場合です。

Repository+Serviceテストの実装

テスト対象のメソッド

今回はServiceクラスのテストで用いたメソッドと同じ、EmployeeService の findByIdメソッド を対象に実施します。

テストクラス

Serviceのテストと同じ2パターンのテストメソッドを用意しています。

【src/test/java/com/cmps/spring/serviceIntegration/EmployeeServiceIntegrationTest.java

package com.cmps.spring.serviceIntegration;

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

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.jdbc.Sql;

import com.cmps.spring.entity.Employee;
import com.cmps.spring.repository.EmployeeRepository;
import com.cmps.spring.service.EmployeeService;

@DataJpaTest
@Import(EmployeeService.class)
public class EmployeeServiceIntegrationTest {

    @Autowired
    private EmployeeRepository repository;

    @Autowired
    private EmployeeService service;

    /**
     * ID検索するメソッド_検索できた時
     */
    @Test
    @Sql("/schema-emoloyee.sql")
    @DisplayName("ID検索_検索できた時")
    void testFindById_found() throws Exception {
        
        // 準備(オブジェクト生成、DBに登録)
        Employee expected = new Employee(null, "0900", "神山", 30, null, null);
        repository.save(expected);

        // 実行
        Employee result = service.findById(expected.getId());

        // 想定結果と実測値が等しいか確認
        assertEquals(expected, result);
        assertNotNull(result);
    }
    
    /**
     * ID検索するメソッド_見つからなかった時
     */
    @Test
    @Sql("/schema-emoloyee.sql")
    @DisplayName("ID検索_見つからなかった時")
    void testFindById_notFound() throws Exception {
        
        // 実行
        Employee result = service.findById(1);
        
        // 想定結果がnullであることを確認
        assertNull(result);
    }
}

・コードの内容としても、2つのテストを組み合わせたような形になっています。

・クラスに付与している @DataJpaTest はRepositoryのテストで使用したものと同じです。
Serviceクラスのテストではモックを使用するために @ExtendWith(MockitoExtension.class) と記述しましたが、ここではモックを使用しないので記述しません。代わりにServiceクラスをロードするために @Importアノテーションを記述し、@Autowiredで使用できるようにしています。

・Repositoryテストと同様に、@Sqlで読み込むSQLファイルを指定しています。

・コード上は変化はありませんが、Serviceのメソッドを呼び出したときには、Repositoryをモック化していないため実際にデータベースにアクセスして実測値を取得しています。

今後の学習内では、こちらのRepositoryとServiceを同時にテストする方法を基本としましょう。

その他

Repository、Serviceの他にもControllerの単体テストを作成することも可能です。
Webアプリケーションでは、ブラウザ上での表示が正しいことや、ユーザー入力値を受け取ってサーバー側で処理を行うといった一連の挙動が最終的に重要になります。xUnitによる確認よりも手動でのテストが最終的には必須になることから、ここでは紹介を割愛します。

本ページで紹介した記述はSpring BootのVer.3系に準拠しています。バージョンが異なる場合、必要なアノテーションや記述等が異なります。
参考サイト:
Testing Spring Boot Applications
SpringBoot/テスト – KobeSpiral2021

タイトルとURLをコピーしました