Skip to main content
search at depth

The OpenSearch Java Client and Testcontainers Setup

5 min read Chapter 3 of 60

The OpenSearch Java Client and Testcontainers Setup

The Symptom

A team writes unit tests for their search service by mocking the OpenSearch client. Every test passes. In production, the index mapping is incompatible with the query structure, field names are misspelled in the code but correct in the mock expectations, and the analyzer produces different tokens than the test assumes. The mocks test the mock, not the search behavior.

The Internals

The OpenSearch Java client (opensearch-java) is a type-safe client built on top of the JSON-B serialization framework. It provides a builder API that mirrors the OpenSearch query DSL, translating Java method calls into the JSON requests that OpenSearch expects. Unlike the low-level REST client, which sends raw JSON strings and returns raw JSON strings, the high-level client provides compile-time checking of field names in builder methods and deserialization into typed response objects.

Testcontainers spins up a real OpenSearch instance inside a Docker container for the duration of a test. The test talks to a real cluster with real analysis, real mapping enforcement, and real scoring. If the mapping is wrong, the test fails. If the analyzer does not tokenize a term the way the query expects, the test returns zero results.

The Implementation

Dependencies

<!-- pom.xml -->
<dependencies>
    <!-- OpenSearch Java client -->
    <dependency>
        <groupId>org.opensearch.client</groupId>
        <artifactId>opensearch-java</artifactId>
        <version>2.10.0</version>
    </dependency>

    <!-- Required for HTTP transport -->
    <dependency>
        <groupId>org.apache.httpcomponents.client5</groupId>
        <artifactId>httpclient5</artifactId>
        <version>5.3</version>
    </dependency>

    <!-- Testcontainers -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers</artifactId>
        <version>1.19.7</version>
        <scope>test</scope>
    </dependency>

    <!-- JUnit 5 integration -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>1.19.7</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Client Configuration

// HARDENED: OpenSearch client configuration with explicit timeouts

@Configuration
public class OpenSearchConfig {

    @Bean
    public OpenSearchClient openSearchClient(
            @Value("${opensearch.host}") String host,
            @Value("${opensearch.port}") int port) {

        var httpHost = new HttpHost("https", host, port);

        var transport = ApacheHttpClient5TransportBuilder
            .builder(httpHost)
            .setMapper(new JacksonJsonpMapper())
            .setHttpClientConfigCallback(httpClientBuilder ->
                httpClientBuilder
                    .setDefaultRequestConfig(RequestConfig.custom()
                        .setConnectTimeout(Timeout.ofSeconds(5))
                        .setResponseTimeout(Timeout.ofSeconds(30))
                        .build()
                    )
                    .setDefaultIOReactorConfig(IOReactorConfig.custom()
                        .setIoThreadCount(4)
                        .build()
                    )
            )
            .build();

        return new OpenSearchClient(transport);
    }
}
// FRAGILE: No timeouts, default everything
// Under load, a single slow query blocks the connection pool
// and cascades into timeouts across the application.

var transport = ApacheHttpClient5TransportBuilder
    .builder(new HttpHost("https", host, port))
    .build();
return new OpenSearchClient(transport);

Testcontainers Integration Test

@Testcontainers
class DocumentSearchRepositoryIntegrationTest {

    @Container
    static GenericContainer<?> opensearch = new GenericContainer<>(
            DockerImageName.parse("opensearchproject/opensearch:2.12.0"))
        .withExposedPorts(9200)
        .withEnv("discovery.type", "single-node")
        .withEnv("plugins.security.disabled", "true")
        .withEnv("OPENSEARCH_INITIAL_ADMIN_PASSWORD", "Test_password1!")
        .waitingFor(Wait.forHttp("/_cluster/health")
            .forPort(9200)
            .forStatusCode(200)
            .withStartupTimeout(Duration.ofSeconds(120)));

    private OpenSearchClient client;
    private DocumentSearchRepository repository;

    @BeforeEach
    void setUp() {
        String host = opensearch.getHost();
        int port = opensearch.getMappedPort(9200);

        var transport = ApacheHttpClient5TransportBuilder
            .builder(new HttpHost("http", host, port))
            .setMapper(new JacksonJsonpMapper())
            .build();

        client = new OpenSearchClient(transport);
        repository = new DocumentSearchRepository(client);
    }

    @Test
    void indexAndSearchDocument() throws Exception {
        // Create the index with the documentation platform mapping
        client.indices().create(c -> c
            .index("docs-v1")
            .mappings(m -> m
                .properties("tenant_id", p -> p.keyword(k -> k))
                .properties("title", p -> p.text(t -> t
                    .analyzer("standard")
                    .fields("exact", f -> f.keyword(k -> k.ignoreAbove(512)))
                ))
                .properties("body", p -> p.text(t -> t.analyzer("standard")))
                .properties("version", p -> p.keyword(k -> k))
            )
        );

        // Index a document
        var page = new DocumentSearchRepository.DocPage(
            "tenant-acme",
            "Configuring Retry Policies",
            "The HTTP client supports configurable retry policies for failed requests. "
                + "Set the maximum retry count and backoff interval.",
            "configuring-retry-policies",
            "HttpClient.setRetryPolicy",
            "3.2.0",
            "guide",
            List.of("client.setRetryPolicy(RetryPolicy.exponentialBackoff(3));")
        );

        repository.indexDocument(page);

        // Force refresh so the document is searchable
        client.indices().refresh(r -> r.index("docs-v1"));

        // Search for the document
        SearchResponse<DocumentSearchRepository.DocPage> response = client.search(s -> s
                .index("docs-v1")
                .query(q -> q
                    .match(m -> m
                        .field("title")
                        .query(FieldValue.of("retry"))
                    )
                ),
            DocumentSearchRepository.DocPage.class
        );

        assertThat(response.hits().total().value()).isEqualTo(1);
        assertThat(response.hits().hits().getFirst().source().title())
            .isEqualTo("Configuring Retry Policies");
    }

    @Test
    void searchRespectsMapping() throws Exception {
        // Create index with the standard mapping
        client.indices().create(c -> c
            .index("docs-v1")
            .mappings(m -> m
                .properties("api_method", p -> p.keyword(k -> k))
                .properties("title", p -> p.text(t -> t.analyzer("standard")))
            )
        );

        // Index a document
        client.index(i -> i
            .index("docs-v1")
            .id("1")
            .document(Map.of(
                "api_method", "HttpClient.getConnection",
                "title", "Connection management"
            ))
        );

        client.indices().refresh(r -> r.index("docs-v1"));

        // Keyword field requires exact match
        SearchResponse<Map> exact = client.search(s -> s
                .index("docs-v1")
                .query(q -> q.term(t -> t
                    .field("api_method")
                    .value("HttpClient.getConnection")
                )),
            Map.class
        );
        assertThat(exact.hits().total().value()).isEqualTo(1);

        // Partial match on keyword field returns nothing
        SearchResponse<Map> partial = client.search(s -> s
                .index("docs-v1")
                .query(q -> q.term(t -> t
                    .field("api_method")
                    .value("getConnection")
                )),
            Map.class
        );
        assertThat(partial.hits().total().value()).isEqualTo(0);
    }
}

This test does what mocks cannot: it verifies that the mapping, the analyzer, and the query DSL work together against a real OpenSearch instance. The searchRespectsMapping test catches a mistake that no mock would surface, the difference between a text field (which is analyzed and supports partial matching) and a keyword field (which requires exact match). If a developer changes api_method from keyword to text in the mapping, the exact-match test breaks, revealing the change before it reaches production.

The Measurement

Run integration tests with Testcontainers and inspect the OpenSearch container logs for mapping errors, analysis exceptions, and shard allocation failures. The test output itself is the measurement: if the test passes, the indexing path and query path are compatible with the mapping. If it fails, the incompatibility is caught before deployment.

For CI pipelines, the Testcontainers OpenSearch container startup time is typically 30 to 60 seconds. This is acceptable for integration test suites running per pull request. Running these tests against mocks would be faster and wrong.

The Decision Rule

Use Testcontainers for any test that validates mapping compatibility, analyzer behavior, or query result correctness. The real OpenSearch instance catches mapping conflicts, analyzer mismatches, and scoring surprises that mocks hide.

Use unit tests with mocks only for testing application logic that sits above the search client: request validation, response transformation, error handling retry logic. Never mock the OpenSearch client to test search relevance or mapping behavior.