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


外部システムから受信したイベントを保存し、処理待ちの 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へ接続します。
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版
アプリを作った後に、DIやDB更新の仕組みを調べる。
DI、MVC、データアクセス、テスト、セキュリティまで幅広く確認できます。小さなWebアプリを一通り作り、Springの仕組みを詳しく学びたい人に向く参考書です。
- Springで小さなWebアプリを作ったことがある
- DIやトランザクションの仕組みを確認したい
Spring Framework 5向けです。Boot 3の実装に使うコード・設定は、採用している版の公式資料と照合してください。
当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。
この記事とあわせて読みたい
DB固有の条件は、同じDBへクエリを送って確かめる
PostgreSQLの jsonb を使うRepositoryでは、接続先がPostgreSQLであることと、クエリでどのIDが返ったかをテストに残します。検索条件の動作を実DBで確かめられれば、その検索結果を使うServiceの判断は別のテストで扱えます。



