🐙

Arquillianを活用したJava EE アプリテストの実践

に公開

はじめに

こんにちは、ウェルスナビでバックエンド開発を担当している田中です。
この記事はウェルスナビ アドベントカレンダー 2024の23日目の記事です。

この記事では、Arquillianを使ったテストの実行について紹介します。具体的には、テスト環境の構築から実行までのプロセスや、直面した問題を解決するためのアプローチについて説明します。

対象読者

この記事は、以下の読者を対象としています。

  • Java開発者: Java EEやJakarta EEを使用している開発者で、テスト自動化に興味がある方。特に、テストフレームワークに関心があり、Arquillianを使ったテストの実行方法を学びたい方。

背景

なぜArquillianを使うのか?

Java EEやJakarta EEを使用するプロジェクトにおいても、単体テストを効率的に行うためのツールが必要です。Arquillianは、こうしたニーズに応えるために設計されたテストフレームワークの一つです。

Arquillianを使う理由は以下の通りです:

  • 本番環境に近いテスト環境: Arquillianは、実際のアプリケーションサーバー上でテストを実行するため、本番環境に近い条件でテストを行うことができます。これにより、環境依存の問題を早期に発見できます。
  • Java EEコンポーネントの単体テスト: EJBやJPAなどのJava EEコンポーネントを、実際のアプリケーションサーバー上で単体テストできます。これにより、Java EE特有の機能や依存関係を正確にテストできます。
  • 依存関係の管理: テスト環境に必要な依存関係を簡単に管理でき、テストのセットアップが容易になります。これにより、複雑な依存関係を持つプロジェクトでもスムーズにテストを実行できます。
  • 柔軟な設定: 多様なコンテナやテストフレームワークと連携できるため、プロジェクトの要件に応じた柔軟なテスト環境を構築できます。

テスト環境の構築 (IntelliJからプロジェクト構築)

必要な環境

  1. 開発ツール:

    • IntelliJ IDEA
  2. アプリケーションサーバー:

    • GlassFish
  3. 依存関係:

    • Enterprise Java Beans (EJB)
    • Java Persistence API (JPA)
    • Java Transaction API (JTA)
    • JPAの実装としてEclipseLink
  4. ビルドツール:

    • Maven

ステップ1: プロジェクトの作成

  • 公式ドキュメントの参照: Arquillianの公式ドキュメントを参考に、プロジェクトを構築します。
    プロジェクトは以下のように構成します。
  • ファイル構成:
    adventholiday/
        ├── src/
        │   ├── main/
        │   │   ├── java/
        │   │   │   └── com.example.adventholiday
        │   │   └── resources/
        │   │       └── META-INF/
        │   │           ├── beans.xml
        │   │           └── persistence.xml
        │   └── test/
        │       ├── java
        │       └── resources
        └── pom.xml
    

ステップ2: 依存関係の追加(初期設定)

Arquillianのドキュメントに沿って、依存関係を追加します。

依存関係名 バージョン
JUnit 4.12
Arquillian BOM 1.4.0.Final
Arquillian TestRunner JUnit Container 1.4.0.Final

修正後のpom.xmlです。

pom.xml
<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.12</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.jboss.arquillian</groupId>
    <artifactId>arquillian-bom</artifactId>
    <version>1.4.0.Final</version>
    <scope>import</scope>
    <type>pom</type>
</dependency>
<dependency>
    <groupId>org.jboss.arquillian.junit</groupId>
    <artifactId>arquillian-junit-container</artifactId>
    <version>1.4.0.Final</version>
    <scope>test</scope>
</dependency>

ステップ3: データベースを利用するテストの作成

ここからはデータベースを利用するテストを行うため、Testing Java Persistenceを参考にします。
プロジェクト構成

adventholiday/
    ├── src/
    │   ├── main/
    │   │   ├── java/
    │   │   │   └── com.example.adventholiday/
    │   │   │       ├── dao/
    │   │   │       │   └── DateDao.java
    │   │   │       └── entity/
    │   │   │           └── DateEntity.java
    │   │   └── resources/
    │   │       └── META-INF/
    │   │           ├── beans.xml
    │   │           └── persistence.xml
    │   └── test/
    │       ├── java/
    │       │   └── com.advent.adventcalendarapp.dao/
    │       │       └── DateDaoTest.java
    │       ├── resources/
    │       │   └── arquillian.xml
    │       └── resources-glassfish-embedded/
    │           ├── glassfish-resources.xml
    │           └── test-persistence.xml
    └── pom.xml

エンティティクラスの作成

DateEntity.java
@Entity
@Table(name = "dates")
public class DateEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "holidayname")
    private String holidayName;

    @Column(name = "date")
    private LocalDate date;

    public Long getId() {
        return id;
    }

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

    public String getHolidayName() {
        return holidayName;
    }

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

    public LocalDate getHolidayDate() {
        return date;
    }

    public void setHolidayDate(LocalDate date) {
        this.date = date;
    }
}

DAOクラスの作成

DateDao.java
@Named
@Stateless
public class DateDao {

    @Inject
    private EntityManager em;

    public void create(DateEntity dateEntity) {
        em.persist(dateEntity);
    }

    public DateEntity find(Long id) {
        return em.find(DateEntity.class, id);
    }

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

    public void update(DateEntity dateEntity) {
        em.merge(dateEntity);
    }

    public void delete(Long id) {
        DateEntity dateEntity = find(id);
        if (dateEntity != null) {
            em.remove(dateEntity);
        }
    }

    public DateEntity findByHolidayName(String holidayName) {
        TypedQuery<DateEntity> query = em.createQuery("SELECT d FROM DateEntity d WHERE d.holidayName = :holidayName", DateEntity.class);
        query.setParameter("holidayName", holidayName);
        try {
            return query.getSingleResult();
        } catch (NoResultException e) {
            return null;
        }
    }
}

続けてtest-persistence.xmlを作成します。

test-persistence.xml
<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="http://java.sun.com/xml/ns/persistence" version="2.0">
    <persistence-unit name="testPU">
        <jta-data-source>jdbc/arquillian</jta-data-source>
    </persistence-unit>
</persistence>

そのままglassfish-resources.xmlを作成します。

glassfish-resources.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE resources PUBLIC
        "-//GlassFish.org//DTD GlassFish Application Server 3.1 Resource Definitions//EN"
        "http://glassfish.org/dtds/glassfish-resources_1_5.dtd">
<resources>
    <jdbc-resource pool-name="ArquillianEmbeddedDerbyPool"
                   jndi-name="jdbc/arquillian"/>
    <jdbc-connection-pool name="ArquillianEmbeddedDerbyPool"
                          res-type="javax.sql.DataSource"
                          datasource-classname="com.mysql.cj.jdbc.MysqlConnectionPoolDataSource"
                          is-isolation-level-guaranteed="false">
        <property name="url" value="jdbc:mysql://localhost:3306/testdb"/>
        <property name="User" value="hoge"/>
        <property name="Password" value="password"/>
        <property name="driverClass" value="com.mysql.cj.jdbc.Driver"/>
    </jdbc-connection-pool>
</resources>

次に、glassfish-resources.xmlを読み込むようにarquillian.xmlを作成します。

arquillian.xml
<?xml version="1.0" encoding="UTF-8"?>
<arquillian xmlns="http://jboss.org/schema/arquillian"
            xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
            xsi:schemaLocation="
        http://jboss.org/schema/arquillian
        http://jboss.org/schema/arquillian/arquillian_1_0.xsd">
    <container qualifier="glassfish-embedded" default="true">
        <configuration>
            <property name="resourcesXml">
                src/test/resources-glassfish-embedded/glassfish-resources.xml
            </property>
        </configuration>
    </container>
</arquillian>

ステップ4: GlassFishのテスト準備

各リソースの設定が完了したので、次はGlassFishのテスト準備を進めます。

まず、データベースクライアントライブラリを追加します。今回はMySQL Connector/J 8.0.33を利用します。
次に、ArquillianのGlassFish向けコンテナアダプターライブラリを追加します。以下の内容をpom.xmlに追加してください。

pom.xml
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <version>8.0.33</version>
</dependency>
<dependency>
    <groupId>org.jboss.arquillian.container</groupId>
    <artifactId>arquillian-glassfish-embedded-3.1</artifactId>
    <version>1.0.2</version>
</dependency>

次に、テスト実行時に必要なリソースをGlassFishにデプロイし、テストが正しく実行されるようにテストリソースのクラスを記述します。以下にpom.xmlの全体像を示します。

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>advent-holiday</artifactId>
    <version>1.0-SNAPSHOT</version>
    <name>advent-holiday</name>
    <packaging>war</packaging>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.target>1.8</maven.compiler.target>
        <maven.compiler.source>1.8</maven.compiler.source>
    </properties>

    <dependencies>
        <dependency>
            <groupId>javax.enterprise</groupId>
            <artifactId>cdi-api</artifactId>
            <version>2.0.SP1</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>javax.ejb</groupId>
            <artifactId>javax.ejb-api</artifactId>
            <version>3.2.2</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>javax.ws.rs</groupId>
            <artifactId>javax.ws.rs-api</artifactId>
            <version>2.1.1</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
            <version>4.0.1</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>javax.transaction</groupId>
            <artifactId>javax.transaction-api</artifactId>
            <version>1.3</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.eclipse.persistence</groupId>
            <artifactId>eclipselink</artifactId>
            <version>2.7.14</version>
        </dependency>

        <!-- Arquillian テストを行うための依存関係 -->
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.jboss.arquillian</groupId>
            <artifactId>arquillian-bom</artifactId>
            <version>1.4.0.Final</version>
            <scope>import</scope>
            <type>pom</type>
        </dependency>
        <dependency>
            <groupId>org.jboss.arquillian.junit</groupId>
            <artifactId>arquillian-junit-container</artifactId>
            <version>1.4.0.Final</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <version>8.0.33</version>
        </dependency>
        <dependency>
            <groupId>org.jboss.arquillian.container</groupId>
            <artifactId>arquillian-glassfish-embedded-3.1</artifactId>
            <version>1.0.2</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-war-plugin</artifactId>
                <version>3.3.2</version>
            </plugin>
        </plugins>
        <testResources>
            <testResource>
                <directory>src/test/resources</directory>
            </testResource>
            <testResource>
                <directory>src/test/resources-glassfish-embedded</directory>
            </testResource>
        </testResources>
    </build>
</project>

いざテスト

今回のテストで以下の2点を検証します。
・祝日名を条件に指定した検索で祝日情報が正しく取得できること
・祝日名を更新した場合にデータベースに正しく反映されること
以下のようなテストクラスを作成します。

DateDaoTest.java
@RunWith(Arquillian.class)
public class DateDaoTest {
    @Deployment
    public static Archive<?> createDeployment() {
        return ShrinkWrap.create(WebArchive.class, "test.war")
                .addPackages(true, DateEntity.class.getPackage(), DateDao.class.getPackage())
                .addAsResource("test-persistence.xml", "META-INF/persistence.xml")
                .addAsWebInfResource(EmptyAsset.INSTANCE, "beans.xml")
                .addAsResource("arquillian.xml");
    }

    @PersistenceContext(unitName = "testPU")
    private EntityManager em;

    @Inject
    private UserTransaction userTransaction;

    @Inject
    private DateDao dateDao;

    @Before
    public void setUp() throws Exception {
        userTransaction.begin();
    }

    @After
    public void tearDown() throws Exception {
        userTransaction.rollback();
    }

    @Test
    public void findByHolidayName() {
        DateEntity result = dateDao.findByHolidayName("元日");

        assertNotNull(result);
        assertEquals("元日", result.getHolidayName());
        assertEquals(LocalDate.of(2024, 1, 1), result.getHolidayDate());
    }

    @Test
    public void updateHolidayName() {
        DateEntity result = dateDao.findByHolidayName("元日");
        assertNotNull("DateEntity should not be null", result);
        result.setHolidayName("正月");

        dateDao.update(result);

        DateEntity updatedResult = dateDao.findByHolidayName("正月");
        assertNotNull(updatedResult);
        assertEquals("正月", updatedResult.getHolidayName());
    }
}

以下のコマンドを使用して、Mavenでテストを実行します。

mvn clean test

テストを実行しましたが、合計5つのエラーが発生しました。以降はエラー対処の記録となります。

テストが通らずエラーと格闘

1つ目のエラー

1回目の実行でエラーが発生しました。スタックトレースを見てみましょう。

[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.344 s <<< FAILURE! -- in com.example.adventholiday.dao.DateDaoTest
[ERROR] com.example.adventholiday.dao.DateDaoTest -- Time elapsed: 0.337 s <<< ERROR!
java.lang.RuntimeException: Could not create new instance of class org.jboss.arquillian.test.impl.EventTestRunnerAdaptor
...
Caused by: java.lang.reflect.InvocationTargetException
...
Caused by: java.lang.NoClassDefFoundError: org/glassfish/embeddable/GlassFishException
...
Caused by: java.lang.ClassNotFoundException: org.glassfish.embeddable.GlassFishException

原因
プロジェクトの初期設定時に、Arquillianのドキュメントに記載されているGlassFishの埋め込み型サーバーの依存関係を見落としていました。そのため、GlassFishの依存関係がプロジェクトに含まれていないことが原因です。

対処
依存関係にEmbedded GlassFish All In Oneを追加します。

pom.xml
<dependency>
    <groupId>org.glassfish.main.extras</groupId>
    <artifactId>glassfish-embedded-all</artifactId>
    <version>5.0</version>
    <scope>provided</scope>
</dependency>

2つ目のエラー

再びテストを実行しましたが、以下のエラーが発生しました。

java.lang.NoSuchMethodError: org.jboss.arquillian.container.spi.client.deployment.Validate.archiveHasExpectedFileExtension(Lorg/jboss/shrinkwrap/api/Archive;)Z

原因
Arquillianのドキュメントに記載されている通りにバージョンも含めて依存関係を追加しました。その結果、依存関係にあるライブラリ間でバージョンの不一致が発生しました。

依存関係に追加したライブラリのバージョンを確認してみましょう。

ライブラリ名 バージョン リリース日
Arquillian BOM 1.4.0.Final 2018年2月27日
Arquillian TestRunner JUnit Container 1.4.0.Final 2018年2月27日
Arquillian Container GlassFish Embedded 3.1 1.0.0.CR3 2012年3月15日
Embedded GlassFish All In One 5.0 2017年9月8日

Arquillian Container GlassFish Embedded 3.1のリリース日が他のライブラリよりも古いようです。

対処
Arquillian Container GlassFish Embedded 3.1を1.0.2へアップグレードします。

3つ目のエラー

今回のテストは3度目の実行。前回のエラーから学び、少しずつ修正を加えてきましたが、エラーは依然として立ちはだかります。

Internal Exception: java.sql.SQLException: Error in allocating a connection. Cause: Connection could not be allocated because: Cannot open file:/var/folders/0d/8fd_7h351dxdvrzslvs969040000gq/T/gfembed5169876886779547640tmp/config/keystore.jks [Keystore was tampered with, or password was incorrect]

(中略)

Caused by: com.mysql.cj.exceptions.SSLParamsException: Cannot open file:/var/folders/0d/8fd_7h351dxdvrzslvs969040000gq/T/gfembed5169876886779547640tmp/config/keystore.jks [Keystore was tampered with, or password was incorrect]

考えられる原因

  • キーストアファイルの問題
  • SSL証明書設定の不備
  • 一時ファイルの問題

MySQLへのSSL接続設定が不完全である可能性があります。

対処
SSL証明書設定を見直すのはコストがかかるため、SSLの設定をオフにしました。

- <property name="url" value="jdbc:mysql://localhost:3306/testdb?/>
+ <property name="url" value="jdbc:mysql://localhost:3306/testdb?useSSL=false"/>

4つ目のエラー

4回目の実行。これが最後だと、心の中で何度も繰り返していました。しかし、どれだけ願っても、エラーの文字は消えてはくれませんでした。

Exception [EclipseLink-4002] (Eclipse Persistence Services - 2.7.14.v20231208-d05cebc9b0): org.eclipse.persistence.exceptions.DatabaseException
Internal Exception: java.sql.SQLException: Error in allocating a connection. Cause: Connection could not be allocated because: Public Key Retrieval is not allowed

(中略)

java.lang.IllegalArgumentException: ArquillianServletRunner not found. Could not determine ContextRoot from ProtocolMetadata, please contact DeployableContainer developer.

原因
データベース接続の問題です。MySQL 8.0以降では、デフォルトでcaching_sha2_password認証方式が使用され、接続時にサーバーから公開鍵がクライアントに送信されます。しかし、allowPublicKeyRetrievalオプションがfalseに設定されていると、クライアントが公開鍵を取得できず、接続できなくなります。

対処
JDBC接続文字列にallowPublicKeyRetrievalオプションをtrueにして追加します。

- <property name="url" value="jdbc:mysql://localhost:3306/testdb?useSSL=false"/>
+ <property name="url" value="jdbc:mysql://localhost:3306/testdb?useSSL=false&amp;allowPublicKeyRetrieval=true"/>

最後のエラー

5回目の実行が終わった瞬間、ただ静かにモニターを見つめていました。その目に浮かぶのは、もはや驚きでもなく、むしろ諦めかけた感情が漂っていました。

Exception during lifecycle processing
org.glassfish.deployment.common.DeploymentException: CDI deployment failure:WELD-001408: Unsatisfied dependencies for type EntityManager with qualifiers @Default
  at injection point [BackedAnnotatedField] @Inject private com.example.adventholiday.dao.DateDao.em
  at com.example.adventholiday.dao.DateDao.em(DateDao.java:0)
...
(中略)
Caused by: org.jboss.weld.exceptions.DeploymentException: WELD-001408: Unsatisfied dependencies for type EntityManager with qualifiers @Default
  at injection point [BackedAnnotatedField] @Inject private com.example.adventholiday.dao.DateDao.em
  at com.example.adventholiday.dao.DateDao.em(DateDao.java:0)
...
(中略)
java.lang.IllegalArgumentException: ArquillianServletRunner not found. Could not determine ContextRoot from ProtocolMetadata, please contact DeployableContainer developer.

原因
CDIコンテナがEntityManagerをインジェクションできていません。

設定の見直し
テストクラスの@PersistenceContextで指定したunitNameがtest-persistence.xml内の<persistence-unit>の名前と一致しているかを確認しましょう。
どちらもtestPUとなっていますね。

ではEntityManagerのインジェクションの場所を見直してみましょう。
よく見ると、DaoクラスにおいてEntityManagerが適切にインジェクションされていません。@PersistenceContextを使ってインジェクションする必要がありました。

DateDao.java
- @Inject
+ @PersistenceContext(unitName = "testPU")
  private EntityManager em;

テストクラスも修正します。

DateDaoTest.java
- @PersistenceContext(unitName = "testPU")
- private EntityManager em;

再びテストを実行しましょう。

[INFO] Results:
[INFO] 
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO] 
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS

テスト成功!!!
その文字が目に入った瞬間、思わず手を震わせながら画面を見つめました。信じられない。何度も見間違えていないか確認しましたが、そこには確かに「BUILD SUCCESS」の文字が浮かんでいました。

まとめ

本記事ではArquillianを導入し、単体テストを実行するプロセスについて説明しました。エラー解決のポイントは以下の通りです。

  • まずは公式ドキュメント通りに実装する
  • ただし、公式ドキュメントを鵜呑みにするのではなく、周辺知識も併せて確認する
  • エラー解消のアプローチには、根本原因の解決と回避が存在する
  • Java EEの技術知識を知っていれば解決できた

新しいことに挑戦すると思いもよらぬエラーや問題が発生することがあります。しかし、一つずつ順番に取り組んでいくことで必ず解決できます。まずはドキュメントを再確認し、関連する周辺情報を調べることで解決策が見えてきます。また、使用する技術に対する知識や理解を深めることも重要で、例えばJava EEの技術知識を持っていれば今回の問題に直面した際により効率的に解決できたと思います。

Arquillianの導入と単体テストを通して問題解決のプロセスを効率的に進めるためには、まずエラーメッセージを正しく読み取ることが重要だと実感しました。その後、問題を分解して根本的な原因を特定し、適切な解決策を選定して実行することが効果的だと感じました。

本記事で紹介したArquillianは一見レガシーな技術を扱っているように見えますが、そこで得られる考え方や周辺知識は最新のフレームワークにも非常に役立ちます。技術は進化しますが、問題解決のアプローチや基礎的な理解は常に共通しています。

参考資料

https://arquillian.org/
https://mvnrepository.com/
https://javaee.github.io/javaee-spec/javadocs/
https://docs.oracle.com/cd/E84527_01/wls/WLPRG/cdi.htm

WealthNavi Engineering Blog

Discussion