Integration Testing with Hibernate Search

This section documents Hibernate ORM 7.4.x (User Guide, Introduction, Query Language Guide, Data Repositories Guide), Jakarta Persistence 3.2, Hibernate Search 8.4.x, and the Hibernate Validator / Hibernate Reactive references — which are the references these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production.

Three older reference books were consulted as bibliography only while preparing these pages and are not the primary or main source for any page. All three predate Jakarta Persistence 3.2 and Hibernate ORM 6/7 (the javax.persistencejakarta.persistence namespace change, the ORM 6 query-engine rewrite, the Hibernate Search 6+ Elasticsearch backend), so the official documentation above wins on any discrepancy.

This section’s bibliography lists the reference material consulted while preparing these pages.

Integration Testing with Hibernate covers testing Hibernate ORM itself — bootstrapping a test SessionFactory, in-memory databases, and Testcontainers for the database. This page covers the analogous ground for Hibernate Search: mapping annotations, analyzers, queries, faceting and projections all deserve their own tests, independent of whether the underlying entities are also database-tested.

Hibernate Search Backends covers the Lucene backend’s production limitation (it does not scale across application instances). For a test suite, that same embedded Lucene backend is exactly the right tool — and setting hibernate.search.backend.directory.type=local-heap keeps the index entirely in the JVM heap, with no filesystem directory left behind between test runs, the same role H2 plays for the database side:

hibernate:
  search:
    backend:
      directory:
        type: local-heap    # index lives in the JVM heap; gone when the JVM exits -- no cleanup needed

As with H2 (Integration Testing with Hibernate's own "in-memory databases" caveat), this is a fast smoke test of mapping and query shape, not a guarantee of production parity — the Lucene backend’s own analysis chain and query execution differ in some respects from running the same mapping against a real Elasticsearch/OpenSearch cluster (see Hibernate Search Backends for how the two backends differ). Treat Lucene-backed tests as the fast, default layer, and reach for Testcontainers (below) for the smaller set of tests that must prove behavior against the actual production backend.

Testing custom analyzers

Hibernate Search Analyzers shows defining a language-specific stemming analyzer and an n-gram-based autocomplete analyzer. Neither Hibernate Search’s Mutiny-free synchronous API nor the Lucene backend expose analyzer tokenization as a public, directly unit-testable method — so an analyzer is tested the same way the rest of the mapping is: index a small, targeted set of documents built specifically to exercise the analyzer’s behavior, then assert on which queries do and do not match:

@Test
void englishStemmingAnalyzer_matchesAcrossInflections() {
    SearchSession searchSession = Search.session(entityManager);

    entityManager.getTransaction().begin();
    entityManager.persist(new Book("Effective Java", "Running a JVM smoothly", "en"));
    entityManager.getTransaction().commit();

    List<Book> hits = searchSession.search(Book.class)
            .where(f -> f.match().field("description").matching("runs"))   // stemmed query term
            .fetchHits(20);

    assertThat(hits).extracting(Book::getTitle).containsExactly("Effective Java");  // matched via "run" stem
}

@Test
void autocompleteAnalyzer_matchesPartialPrefix() {
    // same shape: index a document through the "autocomplete" analyzer's field,
    // then assert a partial query term ("hiber") matches "Hibernate" but "xyz" does not
}

This treats the analyzer as a black box exercised through the same SearchSession API application code uses — appropriate for Hibernate Search’s own analyzers, since asserting on Hibernate Search’s actual match behavior is the thing that matters, not the raw token stream. If a pure, Hibernate-Search-independent unit test of the tokenization itself is wanted (no index, no entity, just "does this analyzer chain produce the tokens I expect"), that is a plain Apache Lucene Analyzer test instead — build the same LuceneAnalysisConfigurer’s tokenizer/filter chain directly against Lucene’s own `Analyzer/TokenStream API, entirely decoupled from Hibernate Search.

Testing queries, faceting, and projections

The same in-memory-backed SearchSession verifies the query DSL, aggregation, and projection behavior covered on Hibernate Search Fundamentals:

@Test
void termsAggregation_countsHitsPerGenre() {
    seedBooks();   // persists a handful of Book entities across a few genre values

    AggregationKey<Map<String, Long>> countsByGenre = AggregationKey.of("countsByGenre");

    SearchResult<Book> result = searchSession.search(Book.class)
            .where(f -> f.matchAll())
            .aggregation(countsByGenre, f -> f.terms().field("genre", String.class))
            .fetch(20);

    assertThat(result.aggregation(countsByGenre))
            .containsEntry("Fiction", 2L)
            .containsEntry("Non-Fiction", 1L);
}

@Test
void idProjection_returnsOnlyMatchingIdentifiers() {
    seedBooks();

    List<Long> ids = searchSession.search(Book.class)
            .select(f -> f.id(Long.class))
            .where(f -> f.match().field("title").matching("persistence"))
            .fetchHits(20);

    assertThat(ids).containsExactly(persistenceBookId);
}

Because indexing runs synchronously by default against the Lucene backend in a single-threaded test (see Hibernate Search Backends's "Sync vs. async index writes"), a search immediately after persist/commit sees the just-indexed documents without needing to poll or sleep — a real convenience for tests that would otherwise need to wait for asynchronous indexing to settle.

Testcontainers with a real Elasticsearch/OpenSearch image

When a test must prove behavior against the actual production backend — an Elasticsearch-specific relevance tuning detail (see Elasticsearch for the query DSL and relevance concepts this backend runs), a coordination-strategy interaction, or simply a pre-release confidence check — Testcontainers' Elasticsearch module starts a real cluster as a disposable Docker container, the same pattern Integration Testing with Hibernate uses for the database:

@Testcontainers
class BookSearchElasticsearchIT {

    @Container
    static ElasticsearchContainer elasticsearch =
            new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:9.0.0")
                    .withEnv("xpack.security.enabled", "false");

    static EntityManagerFactory emf;

    @BeforeAll
    static void bootstrap() {
        emf = Persistence.createEntityManagerFactory("test", Map.of(
                "hibernate.search.backend.type", "elasticsearch",
                "hibernate.search.backend.hosts", elasticsearch.getHttpHostAddress(),
                "hibernate.search.backend.protocol", "http",
                "hibernate.search.schema_management.strategy", "drop-and-create-and-drop"));
    }

    // test methods as above, unchanged -- the SearchSession/query DSL calls are backend-agnostic
}

elasticsearch.getHttpHostAddress() reads the running container’s actual host:port, the same role postgres.getJdbcUrl() plays for a database container on Integration Testing with Hibernate. Test methods written against the query DSL, aggregations, and projections do not need to change between the Lucene-backed and Elasticsearch-backed variants — only the EntityManagerFactory bootstrap properties differ — so it is common to run the fast Lucene-backed suite on every build and the Elasticsearch-backed variant on a smaller, slower CI job or before a release.