PostgreSQLのjsonbを検索し、READYのイベントだけを返す検証を表したイラスト

実装・エラー解決

Spring Bootの@DataJpaTestでPostgreSQLを動かす|TestcontainersでRepositoryを検証

この記事でわかること

Spring Bootの @DataJpaTest をTestcontainersのPostgreSQLへ接続し、jsonb から指定したステータスの行だけが返ることを確かめます。

新人SE
新人SE
Repositoryのテストは通りました。PostgreSQLで使うjsonbのクエリも、このまま本番で動くと考えていいですか?

ポンコツSE
ポンコツSE
テストがどのDBに接続しているかを見よう。そのクエリをPostgreSQLに実際に送って、返ったIDまで比べたい。

外部システムから受信したイベントを保存し、処理待ちの READY だけを別のバッチへ渡します。本文はPostgreSQLの jsonb 列にあり、完了済みの DONE や、古いデータで status 自体がないイベントも同じテーブルに入っています。ここで確認するのは、Repositoryがこの3種類を正しく選り分けることです。

実行用サンプル

ZIPには、DDL、Entity、Repository、IncomingEventRepositoryTest.java、pom.xml、READMEを収録。起動済みのDocker、JDK 17、Maven 3.9系で mvn test を実行します。

Repositoryを実DBへつなぎ、検索条件を確かめる

ServiceのテストでRepositoryをモックにすれば、「READYの一覧を受け取った後に何をするか」を確認できます。今回知りたいのは、RepositoryがSQLを発行したときに、PostgreSQLが jsonb の条件を解釈して、必要な行だけを返すかどうかです。DBへ送るSQLの動作は、Repositoryの戻り値を作って渡すだけでは確かめられません。

@DataJpaTest ではJPAに必要な構成を起動します。このサンプルはControllerやWebサーバーを起動せず、Repository、Entity、DBへの接続を使います。組み込みDBを使うテストは、DB製品固有のSQLを使わない検証なら引き続き候補になります。この例はPostgreSQLの演算子を使うので、テストもPostgreSQLへ接続します。

関連記事Spring BootのServiceをJUnit・Mockitoでテストする方法|Repositoryをモックする基本 Repositoryの戻り値を決め、取得結果を使うServiceの分岐を確かめるテストです。 記事を読む

DDLでjsonbを作り、Repositoryでstatusを取り出す

テスト用DDLの src/test/resources/schema.sql です。接続先に incoming_event を作り、payload を jsonb にします。Hibernateによる自動生成は使わず、ddl-auto=validate でテストのEntityとDDLを照らし合わせます。本番でマイグレーションを管理している場合は、同じDDLをテストでも適用できる構成にすると、テストだけ別の列型になるのを防げます。

create table incoming_event (
    id bigint generated by default as identity primary key,
    payload jsonb not null
);

Entityでは Map<String, String> を @JdbcTypeCode(SqlTypes.JSON) でJSONにマッピングします。columnDefinition = "jsonb" だけでJavaのMapがJSONへ変換されるわけではないので、Hibernate側の型指定と、テーブル側の列定義をそれぞれ確認します。

package info.syachiku.postgres;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.Map;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;

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

    @JdbcTypeCode(SqlTypes.JSON)
    @Column(columnDefinition = "jsonb", nullable = false)
    private Map<String, String> payload;

    protected IncomingEvent() {
    }

    public IncomingEvent(Map<String, String> payload) {
        this.payload = Map.copyOf(payload);
    }

    public Long getId() {
        return id;
    }

    public Map<String, String> getPayload() {
        return Map.copyOf(payload);
    }
}

Repositoryでは、payload ->> 'status' がJSONのフィールドをテキストとして取り出します。status キーがない行ではNULLとなり、= :status に一致しません。READY などの検索値は引数で渡します。JSONのフィールド名は固定で、利用者の入力をSQLへ連結しません。 PostgreSQL 16のJSON演算子

package info.syachiku.postgres;

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface IncomingEventRepository extends JpaRepository<IncomingEvent, Long> {
    @Query(value = """
            select id, payload
              from incoming_event
             where payload ->> 'status' = :status
             order by id
            """, nativeQuery = true)
    List<IncomingEvent> findByPayloadStatus(@Param("status") String status);
}

@ServiceConnectionでテスト用PostgreSQLに接続する

環境はJDK 17、Maven 3.9系、Dockerです。サンプルはSpring Boot 3.5.16、親POMが管理するTestcontainers 1.21.4 とPostgreSQL JDBCドライバー 42.7.11 を使います。PostgreSQLの公式イメージは16.14-alpineを使い、テストクラスでイメージのdigestを固定しています。起動前に docker version を実行し、ClientとServerの両方を取得できることを確かめます。

@Container はJUnitのテスト用コンテナを示し、@ServiceConnection はSpring Bootへ接続情報を渡します。接続先ポートはコンテナの割り当てに任せるため、ローカルの5432番へ向ける設定は要りません。@AutoConfigureTestDatabase(replace = NONE) は、このサンプルではコンテナのDataSourceを置き換えないことを明示する設定です。spring-boot-testcontainers はServiceConnection用、TestcontainersのPostgreSQLモジュールはDBコンテナ用です。 Spring Boot 3.5のService Connections

READY、DONE、キーなしを入れ、返ったIDを比べる

テストの全文です。3行を一緒に保存してから、READYとDONEをそれぞれ検索します。READYの期待結果を「件数1」だけにすると、別の1行を返しても気づけません。保存したイベントのIDを使い、返ってきた行まで確かめます。

package info.syachiku.postgres;

import jakarta.persistence.EntityManager;
import java.sql.SQLException;
import java.util.List;
import java.util.Map;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

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

@DataJpaTest(properties = {
        "spring.sql.init.mode=always",
        "spring.jpa.hibernate.ddl-auto=validate"
})
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class IncomingEventRepositoryTest {
    @Container
    @ServiceConnection
    static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>(
            "postgres"
            + "@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777");

    @Autowired
    private IncomingEventRepository repository;
    @Autowired
    private EntityManager entityManager;
    @Autowired
    private DataSource dataSource;
    @Autowired
    private JdbcTemplate jdbc;

    @Test
    void readyだけが一致しdoneとキーなしは除外される() {
        var ready = new IncomingEvent(Map.of("status", "READY", "source", "api"));
        var done = new IncomingEvent(Map.of("status", "DONE"));
        var missing = new IncomingEvent(Map.of("source", "batch"));
        repository.saveAllAndFlush(List.of(ready, done, missing));
        entityManager.clear();

        assertThat(repository.findByPayloadStatus("READY"))
                .extracting(IncomingEvent::getId)
                .containsExactly(ready.getId());
        assertThat(repository.findByPayloadStatus("DONE"))
                .extracting(IncomingEvent::getId)
                .containsExactly(done.getId());
    }

    @Test
    void 一致しないステータスは空リストを返す() {
        repository.saveAndFlush(new IncomingEvent(Map.of("status", "READY")));

        assertThat(repository.findByPayloadStatus("QUEUED")).isEmpty();
    }

    @Test
    void 接続先がPostgreSQLでDDLの列がjsonbである() throws SQLException {
        try (var connection = dataSource.getConnection()) {
            assertThat(connection.getMetaData().getDatabaseProductName()).isEqualTo("PostgreSQL");
        }
        var event = repository.saveAndFlush(new IncomingEvent(Map.of("status", "READY")));

        assertThat(jdbc.queryForObject("show server_version_num", String.class)).isEqualTo("160014");
        assertThat(jdbc.queryForObject(
                "select pg_typeof(payload)::text from incoming_event where id = ?",
                String.class, event.getId())).isEqualTo("jsonb");
    }
}

テストは saveAllAndFlush で挿入をDBに反映し、entityManager.clear() で既存の管理対象を外してから検索します。QUEUED のように該当するイベントがなければ空リストです。接続確認ではDataSourceが報告する製品名、PostgreSQLが返すバージョン、pg_typeof(payload) の結果を比較します。

mvn testの結果から、失敗した段階を見分ける

ZIPを展開し、pom.xml のある場所で mvn test を実行します。初回は依存ライブラリやコンテナイメージの取得に時間がかかることがあります。

展開したZIPを実行し、Docker Desktop 27.5.1のPostgreSQL 16.14で3件が成功しました。スキップはありません。

Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
  • Dockerを検出できない場合は、まず docker version のServer側が取得できるか確かめる。
  • PostgreSQLは起動するがテーブルがない場合は、schema.sql の場所と spring.sql.init.mode=always を見る。
  • 検索結果のIDが異なる場合は、投入した status、キー欠落、クエリの条件を突き合わせる。

テストが絞り込みの間違いを見つけられるか、配布ZIPを別の場所に展開して確認できます。クエリの 'status' だけを 'state' に変えると、PostgreSQLは一致する行を返さず、READYのIDを比較するテストが失敗します。試した後はキーを 'status' に戻してください。

レビューには「READY、DONE、statusキーなしを一緒に保存し、READYのIDだけが返ることをPostgreSQLで確認した」と書きます。これで、単にアプリが起動したことと、検索条件が意図したデータを選んだことを切り分けられます。本番と異なるマイグレーションやDB設定を使う場合は、その差をテスト結果に含めないよう確認範囲を明記します。

JPAのEntity、Repository、トランザクションの役割を整理したい場合は、『Spring徹底入門 第2版』が次の学習に向きます。この記事のTestcontainers設定は、本文で示したバージョンと配布コードを参照してください。

書籍「Spring徹底入門 第2版」の表紙
商品画像:Amazon.co.jp
PR 入門後の参考書|Spring Framework 5向け

Spring徹底入門 第2版

株式会社NTTデータ (著), 株式会社NTTデータグループ (編集)

アプリを作った後に、DIやDB更新の仕組みを調べる。

DI、MVC、データアクセス、テスト、セキュリティまで幅広く確認できます。小さなWebアプリを一通り作り、Springの仕組みを詳しく学びたい人に向く参考書です。

  • Springで小さなWebアプリを作ったことがある
  • DIやトランザクションの仕組みを確認したい

Spring Framework 5向けです。Boot 3の実装に使うコード・設定は、採用している版の公式資料と照合してください。

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

この記事とあわせて読みたい

関連記事Spring Data JPAのRepository戻り値|検索0件・1件・複数件の違い 検索0件、1件、複数件を扱うときに、ListやOptionalの契約をどう選ぶかを確認できます。 記事を読む 関連記事Spring Boot初心者におすすめの本3冊|現場で必要な力を身につける Spring Bootの機能を作り、説明し、直せるようになるために、どの本から読むかを選べます。 記事を読む

DB固有の条件は、同じDBへクエリを送って確かめる

PostgreSQLの jsonb を使うRepositoryでは、接続先がPostgreSQLであることと、クエリでどのIDが返ったかをテストに残します。検索条件の動作を実DBで確かめられれば、その検索結果を使うServiceの判断は別のテストで扱えます。

-実装・エラー解決
-, , , , ,