🏄

CDI-Unitを試行してみた

に公開

CDI-Unitを試行してみた

📌 3秒まとめ

  • ✅ 簡単に CDI のユニットテストができる

  • @Inject などの基本的な CDI 機能をサポート

  • ✅ 統合テストは Arquillian、ユニットテストは CDI-Unit という使い分けが可能

  • ✅ データベーステストは、DeltaSpike以外はサポートされておらず、工夫が必要

👫 どんな人向けの記事

  • Jakarta EE のアプリケーションに対してJUnitで単体テストを書いている人

  • Arquillian* を使って CDI の自動テストを書いている人

  • これから CDI の自動テストツールを選定する人

* Arquillian は、Java EEおよびJakarta EE向けの自動テストを作成するためのプラットフォームです

🎯 はじめに

Jakarta EE の CDI (Context and Dependency Injection) をテストする際、Arquillian を使っている方も多いと思います。

Arquillian は アプリケーションサーバでの統合テスト に優れていますが、ユニットテストを素早く実行したい場合 には、起動が遅く、セットアップが煩雑になることがあります。

そこで今回は、CDI-Unit というテストライブラリを試してみます。

CDI-Unit を使うと CDI の機能を軽量な環境でテスト でき、単体テストレベルでは、Arquillian の代替手段として有用だと見立てています。

本記事では、CDI-Unitについて簡単な説明と、試行で躓いたデータベースに対するテスト方法を解説します。

CDI-Unitの基本的な使い方は、CDI-Unitのドキュメントを上からなぞればできる為、本記事では解説しません。

CDI-Unit とは?

CDI-Unit は、Jakarta EE の CDI軽量な環境でテストするためのライブラリ です。

通常、CDI をテストする場合、WildFlyPayara Micro のようなアプリケーションサーバを起動することが多いです。しかし、CDI-Unit を使えば、JUnit 上で CDI のみを簡単に有効化 し、素早くテストを実行できます。

✅ CDI-Unit のメリット

  • CDI 環境のみを素早く起動できる

  • アプリケーションサーバが不要

  • @Inject をそのまま利用可能

  • Weld(Jakarta EE における CDI の実装の1つ) との互換性がある

  • JUnit5 に対応


Arquillian との違い

Arquillian を使っている人向けに、CDI-Unit の特徴を整理すると、次のようになります。

項目 Arquillian CDI-Unit
目的 統合テスト(実際のアプリを動かす) ユニットテスト(CDI のみを対象)
アプリケーションサーバ 必要 ※ 不要(Weld SE で CDI のみ起動)
テスト速度 遅い(アプリケーションサーバの起動が必要) 高速(JUnit のみで実行)
CDI のサポート 広い(ほぼ全ての機能が利用可) 部分的(@Inject@EJB@Resource は可)
モックのサポート なし あり
DB テスト 可能(JPA + トランザクション管理) DeltaSpike(JPA + トランザクション管理)

CDI を 軽量なユニットテストで試したいとき は、CDI-Unit の方が適しています。

実際の環境で動作確認する場合 は、Arquillian が向いています。

※ 厳密には、Arquillianもアプリケーションサーバ不要で CDI テストが可能です。
ただし「目的」の通り、Arquillianは本物の環境を想定したテストツールなので、実態としてサーバ上でテストされることが多いです。また、モックをサポートしていないので、ユニットテストとしての使い方も推奨されないです。

⚠️ CDI-Unitを利用する上で注意点

先述の通り、CDI-Unitでは、CDIのサポートが制限されていることに注意が必要です。

特に、DeltaSpikeを利用していないアプリケーションに対しては、JPA (@PersistenceContext@Transactional) の完全なサポートがないので、例えば以下のようなコードに対しては依存性の注入ができません。

class Sample {

    // CDI-Unitでは以下のコードに依存性が注入されない
    @PersistenceContext
    EntityManager em;

}

これを回避するには、自前の実装が必要です(後述の「🛠️ テスト対象のクラス」を参照)。

実装を工夫することで、トランザクション管理はサポートされないものの、EntityManagerの依存性を解決することができます。

この方法論では、トランザクション管理のテストは他(例:Arquillian)で実施する必要があります。


CDI-Unit のセットアップ

基本的には、公式ガイド公式のGitHubの内容に従います。

今回は、データベースの事前準備と期待値の検証でDatabase Riderを追加で利用します。

📦 依存関係の追加

Maven プロジェクトで pom.xml に以下の依存関係を追加します。

<dependencies>

    <!-- JUnit 5 -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-api</artifactId>
        <version>5.12.1</version>
        <scope>test</scope>
    </dependency>

    <!-- CDI-Unit -->
    <dependency>
        <groupId>io.github.cdi-unit</groupId>
        <artifactId>cdi-unit</artifactId>
        <version>5.0.0-EA7</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.jboss.weld.se</groupId>
        <artifactId>weld-se-core</artifactId>
        <version>5.1.5.Final</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.jboss.weld.module</groupId>
        <artifactId>weld-web</artifactId>
        <version>5.1.5.Final</version>
        <scope>test</scope>
    </dependency>

    <!-- Database Rider -->
    <dependency>
        <groupId>com.github.database-rider</groupId>
        <artifactId>rider-junit5</artifactId>
        <version>1.44.0</version>
        <classifier>jakarta</classifier>
        <scope>test</scope>
    </dependency>

    <!-- Jakarta EE関連の依存関係 -->

</dependencies>

実際に CDI のテストを書いてみる

CDI-Unit を使って、CDI のテストを動かす方法 を紹介します。
テスト対象クラスは簡単なRepositoryクラスを想定します。

🛠️ テスト対象のクラス

テスト対象は、TaskRepositoryクラスです。

Taskエンティティに対するデータベース操作を行うリポジトリクラスで、主要なCRUD操作を提供しています。

package com.example.task;

import java.util.List;
import java.util.Optional;

import jakarta.ejb.Stateless;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.transaction.Transactional;
import lombok.NoArgsConstructor

@Stateless
@NoArgsConstructor
@Transactional
public class TaskRepository {

    private EntityManager em;

    @Inject
    public TaskRepository(EntityManager em) {
        this.em = em;
    }

    public List<Task> findAll() {
        return em.createQuery("SELECT c FROM Task c", Task.class).getResultList();
    }

    public Optional<Task> findById(int id) {
        return Optional.ofNullable(em.find(Task.class, id));
    }

    public Task create(Task task) {
        em.persist(task);
        return task;
    }

    public void delete(int id) {
        var task = findById(id)
            .orElseThrow(() -> new IllegalArgumentException("Invalid task Id:" + id));
        em.remove(task);
    }

    public Task update(int id, String title) {
        Task ref = em.getReference(Task.class, id);
        ref.setTitle(title);
        return em.merge(ref);
    }
}
💡 テスト対象の周辺クラス

Taskエンティティ

タスクの情報を保持するためのエンティティクラスです。

JPA(Java Persistence API)を使用してデータベースのテーブル(Task)にマッピングされます。

package com.example.task;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Getter
@Setter
@NoArgsConstructor
public class Task {

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

    @Column
    private String title;

}

Taskテーブル

Taskエンティティに対応するデータベースのテーブルです。

CREATE TABLE IF NOT EXISTS Task (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL
);

データベース設定(persistence.xml)

H2 Database(H2DB)を利用するための設定ファイルです。

具体的には、TaskPersistenceUnitという名前の永続ユニットを定義し、そのユニットで使用するエンティティクラス(Task)データベース接続プロパティを指定しています。

<?xml version="1.0" encoding="UTF-8"?>
<persistence version="3.0" xmlns=https://jakarta.ee/xml/ns/persistence
             xmlns:xsi=http://www.w3.org/2001/XMLSchema-instance
             xsi:schemaLocation=https://jakarta.ee/xml/ns/persistence https://jakarta.ee/xml/ns/persistence/persistence_3_0.xsd>
    <persistence-unit name="TaskPersistenceUnit">
        <class>com.example.task.Task</class>
        <properties>
            <property name="hibernate.connection.url" value="jdbc:h2:mem:test;DB_CLOSE_DELAY=-1" />
            <property name="hibernate.dialect" value="org.hibernate.dialect.H2Dialect"></property>
            <property name="hibernate.connection.driver_class" value="org.h2.Driver" />
            <property name="hibernate.connection.password" value="admin" />
            <property name="hibernate.connection.username" value="admin" />
            <property name="hibernate.hbm2ddl.auto" value="update" />
            <property name="hibernate.show_sql" value="true"/>
        </properties>
    </persistence-unit>
</persistence>

EntityManager生成クラス

EntityManagerを生成し、CDIコンテナに登録するためのプロデューサークラスです。

persistence.xmlファイルに定義されたTaskPersistenceUnitという名前の永続ユニットを使用してEntityManagerFactoryを作成し、そのファクトリからEntityManagerを生成します。

package com.example.task;
 
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.context.RequestScoped;
import jakarta.enterprise.inject.Produces;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.Persistence;

@ApplicationScoped
public class EntityManagerProducer {

    private static final EntityManagerFactory emf = Persistence.createEntityManagerFactory("TaskPersistenceUnit");

    @Produces
    @RequestScoped
    public EntityManager createEntityManager() {
        return emf.createEntityManager();
    }
}

🧪 CDI-Unit を使ったテスト

TaskRepositoryクラスに対するテストをCDI-Unitで実装します。

今回は代表して、新規作成(create)、更新(update)、削除(delete)の3つのメソッドに対してテストを実装します。

package com.example.task;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.List;
import java.util.Optional;

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import com.github.database.rider.core.api.dataset.DataSet;
import com.github.database.rider.core.api.dataset.ExpectedDataSet;
import com.github.database.rider.junit5.api.DBRider;

import io.github.cdiunit.AdditionalClasses;
import io.github.cdiunit.InRequestScope;
import io.github.cdiunit.junit5.CdiJUnit5Extension;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;

@ExtendWith({CdiJUnit5Extension.class})
@AdditionalClasses({EntityManagerProducer.class})
@DBRider
class TaskRepositoryTest {

    @Inject
    TaskRepository taskRepository;

    @Inject
    EntityManager entityManager;

    @BeforeAll
    @DataSet(executeScriptsBefore = "datasets/create_task.sql")
    static void initial() {}

    @InRequestScope
    @Test
    @ExpectedDataSet("datasets/expected_tasks_after_insert.yml")
    void testCreateTask() {

        entityManager.getTransaction().begin();

        Task task1 = new Task();
        task1.setTitle("task1");
        taskRepository.create(task1);

        entityManager.getTransaction().commit();
    }

    @InRequestScope
    @Test
    @DataSet(value = "datasets/tasks.yml")
    @ExpectedDataSet("datasets/expected_tasks_after_update.yml")
    void testUpdateTask() {

        entityManager.getTransaction().begin();

        Task updatedTask = taskRepository.update(1, "updatedTask");

        entityManager.getTransaction().commit();

    }

    @InRequestScope
    @Test
    @DataSet(value = "datasets/tasks.yml")
    @ExpectedDataSet("datasets/expected_tasks_after_delete.yml")
    void testDeleteTask() {

        entityManager.getTransaction().begin();

        assertDoesNotThrow(() -> taskRepository.delete(1));

        entityManager.getTransaction().commit();
    }
}
💡 テストで利用しているファイル

create_task.sql

TaskテーブルのDDLです。

CREATE TABLE IF NOT EXISTS Task (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL
);

Database Riderはテスト起動時にテーブル作成機能を持っていません。

今回は、H2 Database(H2DB)をインメモリで利用している為、テスト起動時にテーブル作成の必要があり、本DDLを利用します。

expected_tasks_after_insert.yml

Database Riderの主要機能の1つに、テスト実行後のデータベースの状態をファイル(例:yml)と比較して検証する機能があります。

expected_tasks_after_insert.ymlでは、testCreateTaskメソッド実行後のデータベースの状態を定義しています。

Task:
  - id: 1
    title: "task1"

tasks.yml

Database Riderの他の機能に、テスト実行前にファイル(例:yml)の内容に基づいて、テーブルをセットアップする機能があります。

tasks.ymlでは、testUpdateTaskメソッド、testDeleteTaskメソッド実行前の、Taskテーブルの状態を定義しています。

Task:
  - id: 1
    title: "task1"
  - id: 2
    title: "task2"

expected_tasks_after_update.yml

expected_tasks_after_insert.ymlと同様に、testUpdateTaskメソッド実行後のデータベースの状態を定義しています。

Task:
  - id: 1
    title: "updatedTask"  # task1 が更新される
  - id: 2
    title: "task2"

datasets/expected_tasks_after_delete.yml

expected_tasks_after_insert.ymlと同様に、testDeleteTaskメソッド実行後のデータベースの状態を定義しています。

Task:
  - id: 2
    title: "task2"  # task1 が削除されている

dbunit.yml

Database Riderの設定ファイルです。Database Riderが利用するデータベースの接続情報は、persistence.xmlとは別管理になるため、本ファイルで定義します。

connectionConfig:
  driver: "org.h2.Driver"
  url: "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"
  user: "admin"
  password: "admin"

テストクラスの解説

@ExtendWith({CdiJUnit5Extension.class})
@AdditionalClasses({EntityManagerProducer.class})
@DBRider
class TaskRepositoryTest {
}
  • @ExtendWith({CdiJUnit5Extension.class})
    • Junit5でCDI-Unitを実行するための設定です。
  • @AdditionalClasses({EntityManagerProducer.class})
    • CDI-Unitの機能の1つで、テストクラス内の@Injectで注入するクラスを明示的に宣言します。
  • @DBRider
    • Database Riderを利用するためのアノテーションです。
@Inject
TaskRepository taskRepository;

@Inject
EntityManager entityManager;
  • @Inject TaskRepository taskRepository
    • テスト対象クラスです。 CDI-Unitが自動的に依存先を検出して注入します。
  • @Inject EntityManager entityManager
    • テスト内で利用する、EntityManagerです。@AdditionalClassesで指定したEntityManagerProducer.classから注入されます。
    • DeltaSpikeを利用しない場合、CDI-Unitではトランザクション管理がサポートされません。テストクラスからトランザクション管理するために、利用します。
@BeforeAll
@DataSet(executeScriptsBefore = "datasets/create_task.sql")
static void initial() {}
  • データベースのテストをするために、テスト実行前にTaskテーブルを作成します。
@InRequestScope
@Test
@DataSet(value = "xxxxxx")
@ExpectedDataSet("yyyyyy")
void testZZZZZTask() {

    entityManager.getTransaction().begin();

    // テストコード

    entityManager.getTransaction().commit();
}
  • @InRequestScope
    • テストを実行する際のメソッド呼び出しのスコープを定義します。今回はデータベースアクセスなので、リクエストスコープを指定します。
  • @DataSet(value = "xxxxxx")@ExpectedDataSet("yyyyyy")
    • Database Riderの主要機能で、「テスト実行前」と「テスト実行後」のデータベースの状態をファイルで定義します。
  • entityManager.getTransaction().begin();entityManager.getTransaction().commit();
    • これまで説明の通り、CDI-Runnerは@Transactionalをサポートしていない為、明示的にトランザクションの「開始」と「終了」を宣言します。
    • この場合、トランザクション管理のテストが実施未済となるため、統合テスト時にArquillianで実施するなど、テスト観点漏れの考慮が必要です。

📊 Arquillianで実施するテストとの比較

CDI-UnitとArquillianの主要な違いは前項までに述べた通りですが、テストクラス実装といった観点で見たときは、以下の点でCDI-Unitが優れていると感じました。

  • @Injectで注入するクラスの指定がほぼ不要
    • Arquillianは、@Injectするクラスなどは全て@Deploymentで明示的に宣言が必要です。パッケージ単位に指定したり、記述を省略する手段はあるものの、かなり手間です。
    • 一方で、CDI-Unitは基本的に@Injectするクラスの宣言は不要です。
    • 本記事のEntityManagerProducerクラスなど、一部、自動的に依存先を検出できないものがあり、その場合のみ明示的な指定が必要です。公式ガイド > Controlling the CDI environmentに例が記載されています。

まとめ

CDI-Unit を使うことで、 CDI のユニットテストを素早く実行できます。
また、データベースに対するテストも、一部制約がありますが、実施可能です。

試行した感触では、設定も簡単で快適に利用できました。

アプリケーションサーバ上でのテストではない為、実際の環境と異なる挙動(例:トランザクション管理など)は、別のテストツールでカバーが必要ですが、単体テストツールとしては、候補の1つになると感じました。

なお、掲載したソースコードはサンプルになります。本ソースコードを使用することで発生するいかなる損害や不利益について、当社は一切の責任を負いませんので自己の責任においてご利用ください。

📝 参考資料

Discussion