Elasticsearch Query Testing: Match, Bool, Aggregations, and Testcontainers

Elasticsearch Query Testing: Match, Bool, Aggregations, and Testcontainers

Elasticsearch query testing is notoriously difficult. The query DSL is complex, aggregations are stateful, and testing against a live cluster introduces flakiness. This guide covers testing strategies for Elasticsearch queries — from unit-level mock tests for query construction to Testcontainers integration tests that run against a real Elasticsearch node.

The Challenge with Elasticsearch Testing

Elasticsearch presents unique testing challenges:

  • Query DSL complexity: Bool queries with nested must/should/filter clauses are easy to misconstruct
  • Async operations: Indexing isn't immediately searchable — you need refresh in tests
  • Cluster state: Tests can interfere if they share indexes
  • Aggregations: Bucket and metric aggregations require data that matches your test intent

The right strategy: mock for query construction verification, use Testcontainers for behavioral verification.

Mocking the Elasticsearch Client

The official @elastic/elasticsearch client is straightforward to mock with Jest:

// test-utils/elasticsearch-mock.js
export function createMockEsClient() {
  return {
    index: jest.fn(async ({ index, id, document }) => ({
      _index: index,
      _id: id || 'generated-id',
      result: 'created',
      _shards: { total: 2, successful: 1, failed: 0 },
    })),
    bulk: jest.fn(async ({ operations }) => ({
      took: 5,
      errors: false,
      items: operations
        .filter((_, i) => i % 2 === 0) // action lines only
        .map((action, i) => ({
          index: { _id: String(i), result: 'created', status: 200 },
        })),
    })),
    search: jest.fn(async ({ index, body }) => ({
      took: 5,
      timed_out: false,
      _shards: { total: 1, successful: 1, skipped: 0, failed: 0 },
      hits: {
        total: { value: 0, relation: 'eq' },
        max_score: null,
        hits: [],
      },
    })),
    delete: jest.fn(async ({ index, id }) => ({
      _index: index,
      _id: id,
      result: 'deleted',
    })),
    indices: {
      create: jest.fn(async () => ({ acknowledged: true })),
      delete: jest.fn(async () => ({ acknowledged: true })),
      exists: jest.fn(async () => false),
      putMapping: jest.fn(async () => ({ acknowledged: true })),
    },
  };
}

Testing Match Queries

Match queries are the simplest but still need verification:

import { createMockEsClient } from '../test-utils/elasticsearch-mock';
import { ArticleSearchService } from '../services/article-search';

describe('ArticleSearchService', () => {
  test('builds correct match query for simple search', async () => {
    const client = createMockEsClient();
    const service = new ArticleSearchService(client);

    client.search.mockResolvedValueOnce({
      took: 3,
      hits: {
        total: { value: 5, relation: 'eq' },
        hits: [
          { _id: '1', _score: 1.5, _source: { title: 'Elasticsearch Guide', category: 'tutorial' } },
        ],
      },
    });

    const results = await service.search('elasticsearch');

    expect(client.search).toHaveBeenCalledWith({
      index: 'articles',
      body: {
        query: {
          match: {
            title: {
              query: 'elasticsearch',
              operator: 'or',
              fuzziness: 'AUTO',
            },
          },
        },
        from: 0,
        size: 20,
      },
    });

    expect(results.total).toBe(5);
    expect(results.items[0].title).toBe('Elasticsearch Guide');
  });
});

Testing Bool Queries

Bool queries with must/should/filter are the most common production pattern:

test('builds multi-field bool query with filters', async () => {
  const client = createMockEsClient();
  const service = new ArticleSearchService(client);

  client.search.mockResolvedValueOnce({
    took: 5,
    hits: { total: { value: 2, relation: 'eq' }, hits: [] },
  });

  await service.searchWithFilters({
    query: 'testing guide',
    category: 'tutorials',
    minPublishedDate: '2026-01-01',
    tags: ['elasticsearch', 'testing'],
  });

  expect(client.search).toHaveBeenCalledWith({
    index: 'articles',
    body: {
      query: {
        bool: {
          must: [
            {
              multi_match: {
                query: 'testing guide',
                fields: ['title^3', 'content', 'tags^2'],
                type: 'best_fields',
                fuzziness: 'AUTO',
              },
            },
          ],
          filter: [
            { term: { category: 'tutorials' } },
            { range: { published_at: { gte: '2026-01-01' } } },
            { terms: { tags: ['elasticsearch', 'testing'] } },
          ],
        },
      },
      from: 0,
      size: 20,
    },
  });
});

test('uses should clauses for optional boosting', async () => {
  const client = createMockEsClient();
  const service = new ArticleSearchService(client);

  client.search.mockResolvedValueOnce({
    took: 3,
    hits: { total: { value: 1, relation: 'eq' }, hits: [] },
  });

  await service.searchWithBoosting({
    query: 'docker testing',
    boostRecent: true,
    boostPopular: true,
  });

  const searchCall = client.search.mock.calls[0][0];
  const bool = searchCall.body.query.bool;

  expect(bool.should).toEqual(
    expect.arrayContaining([
      { range: { published_at: { gte: 'now-30d', boost: 1.5 } } },
      { range: { view_count: { gte: 1000, boost: 1.2 } } },
    ])
  );
  expect(bool.minimum_should_match).toBe(0);
});

Testing Aggregations

Aggregations are where ES query bugs have the most impact on analytics and faceted UIs:

test('builds terms aggregation for category facets', async () => {
  const client = createMockEsClient();
  const service = new ArticleSearchService(client);

  const mockAggResponse = {
    took: 8,
    hits: { total: { value: 50, relation: 'eq' }, hits: [] },
    aggregations: {
      categories: {
        buckets: [
          { key: 'tutorials', doc_count: 20 },
          { key: 'guides', doc_count: 15 },
          { key: 'news', doc_count: 15 },
        ],
      },
      tags: {
        buckets: [
          { key: 'elasticsearch', doc_count: 12 },
          { key: 'testing', doc_count: 10 },
        ],
      },
    },
  };

  client.search.mockResolvedValueOnce(mockAggResponse);

  const results = await service.searchWithFacets('elasticsearch', ['categories', 'tags']);

  expect(client.search).toHaveBeenCalledWith({
    index: 'articles',
    body: expect.objectContaining({
      aggs: {
        categories: {
          terms: { field: 'category.keyword', size: 20 },
        },
        tags: {
          terms: { field: 'tags.keyword', size: 20 },
        },
      },
      size: 20,
    }),
  });

  expect(results.facets.categories).toEqual([
    { label: 'tutorials', count: 20 },
    { label: 'guides', count: 15 },
    { label: 'news', count: 15 },
  ]);
});

test('builds date histogram aggregation for timeline', async () => {
  const client = createMockEsClient();
  const service = new ArticleSearchService(client);

  client.search.mockResolvedValueOnce({
    took: 5,
    hits: { total: { value: 100, relation: 'eq' }, hits: [] },
    aggregations: {
      articles_over_time: {
        buckets: [
          { key_as_string: '2026-01-01', key: 1735689600000, doc_count: 25 },
          { key_as_string: '2026-02-01', key: 1738368000000, doc_count: 40 },
        ],
      },
    },
  });

  const results = await service.getPublicationTimeline('testing');

  expect(client.search).toHaveBeenCalledWith({
    index: 'articles',
    body: expect.objectContaining({
      aggs: {
        articles_over_time: {
          date_histogram: {
            field: 'published_at',
            calendar_interval: 'month',
            format: 'yyyy-MM-dd',
          },
        },
      },
    }),
  });

  expect(results.buckets).toHaveLength(2);
  expect(results.buckets[0].month).toBe('2026-01-01');
  expect(results.buckets[0].count).toBe(25);
});

Testing Nested Queries

Nested document queries are a common source of Elasticsearch bugs:

test('queries nested comments field correctly', async () => {
  const client = createMockEsClient();
  const service = new ArticleSearchService(client);

  client.search.mockResolvedValueOnce({
    took: 4,
    hits: { total: { value: 1, relation: 'eq' }, hits: [] },
  });

  await service.searchInComments('great tutorial');

  expect(client.search).toHaveBeenCalledWith({
    index: 'articles',
    body: expect.objectContaining({
      query: {
        nested: {
          path: 'comments',
          query: {
            match: { 'comments.text': 'great tutorial' },
          },
          inner_hits: {
            highlight: {
              fields: { 'comments.text': {} },
            },
          },
        },
      },
    }),
  });
});

Integration Tests with Testcontainers

Testcontainers gives you a disposable Elasticsearch cluster per test suite:

import { ElasticsearchContainer } from '@testcontainers/elasticsearch';
import { Client } from '@elastic/elasticsearch';

describe('Elasticsearch integration tests', () => {
  let esContainer;
  let client;
  const INDEX_NAME = 'test-articles';

  beforeAll(async () => {
    esContainer = await new ElasticsearchContainer('elasticsearch:8.13.0')
      .withEnvironment({
        'discovery.type': 'single-node',
        'xpack.security.enabled': 'false',
        'ES_JAVA_OPTS': '-Xms512m -Xmx512m',
      })
      .start();

    client = new Client({ node: esContainer.getHttpUrl() });

    await client.indices.create({
      index: INDEX_NAME,
      mappings: {
        properties: {
          title: { type: 'text', analyzer: 'english' },
          content: { type: 'text', analyzer: 'english' },
          category: { type: 'keyword' },
          published_at: { type: 'date' },
          view_count: { type: 'integer' },
        },
      },
    });
  }, 120_000);

  afterAll(async () => {
    await esContainer?.stop();
  });

  beforeEach(async () => {
    await client.deleteByQuery({
      index: INDEX_NAME,
      body: { query: { match_all: {} } },
      refresh: true,
    });
  });

  test('match query finds documents with fuzziness', async () => {
    await client.bulk({
      refresh: true,
      operations: [
        { index: { _index: INDEX_NAME, _id: '1' } },
        { title: 'Elasticsearch Testing Guide', content: 'How to test ES', category: 'tutorial', published_at: '2026-01-01', view_count: 100 },
        { index: { _index: INDEX_NAME, _id: '2' } },
        { title: 'Docker Basics', content: 'Container fundamentals', category: 'guide', published_at: '2026-01-15', view_count: 50 },
      ],
    });

    const results = await client.search({
      index: INDEX_NAME,
      body: {
        query: {
          match: {
            title: { query: 'Elasticsarch', fuzziness: 'AUTO' }, // typo
          },
        },
      },
    });

    expect(results.hits.total.value).toBe(1);
    expect(results.hits.hits[0]._source.title).toBe('Elasticsearch Testing Guide');
  });

  test('bool filter reduces result set correctly', async () => {
    await client.bulk({
      refresh: true,
      operations: [
        { index: { _index: INDEX_NAME, _id: '1' } },
        { title: 'ES Tutorial', content: 'Content', category: 'tutorial', published_at: '2026-01-01', view_count: 100 },
        { index: { _index: INDEX_NAME, _id: '2' } },
        { title: 'ES Guide', content: 'Content', category: 'guide', published_at: '2026-02-01', view_count: 200 },
        { index: { _index: INDEX_NAME, _id: '3' } },
        { title: 'Another Tutorial', content: 'Content', category: 'tutorial', published_at: '2025-01-01', view_count: 50 },
      ],
    });

    const results = await client.search({
      index: INDEX_NAME,
      body: {
        query: {
          bool: {
            filter: [
              { term: { category: 'tutorial' } },
              { range: { published_at: { gte: '2026-01-01' } } },
            ],
          },
        },
      },
    });

    // Should only return tutorial from 2026, not 2025
    expect(results.hits.total.value).toBe(1);
    expect(results.hits.hits[0]._id).toBe('1');
  });

  test('terms aggregation counts correctly', async () => {
    await client.bulk({
      refresh: true,
      operations: [
        { index: { _index: INDEX_NAME, _id: '1' } },
        { title: 'T1', content: 'x', category: 'tutorial', published_at: '2026-01-01', view_count: 10 },
        { index: { _index: INDEX_NAME, _id: '2' } },
        { title: 'T2', content: 'x', category: 'tutorial', published_at: '2026-01-01', view_count: 20 },
        { index: { _index: INDEX_NAME, _id: '3' } },
        { title: 'G1', content: 'x', category: 'guide', published_at: '2026-01-01', view_count: 30 },
      ],
    });

    const results = await client.search({
      index: INDEX_NAME,
      body: {
        size: 0,
        aggs: { by_category: { terms: { field: 'category' } } },
      },
    });

    const buckets = results.aggregations.by_category.buckets;
    const tutorialBucket = buckets.find(b => b.key === 'tutorial');
    expect(tutorialBucket.doc_count).toBe(2);
  });
});

E2E Testing with HelpMeTest

Integration tests verify query behavior, but production search needs continuous monitoring. HelpMeTest tests your search UI end-to-end:

Navigate to https://your-app.com/search
Enter "elasticsearch testing" in the search box
Verify at least 5 results appear
Verify result titles contain relevant terms
Select "tutorials" from the category filter
Verify result count updates to show filtered results
Click on a search result
Verify the article page loads correctly

This runs continuously so you catch Elasticsearch downtime, index corruption, or mapping changes the moment they affect users.

Key Testing Principles for Elasticsearch

Test query shape, not ranking. Ranking algorithms are non-deterministic across nodes and versions. Test that your query DSL is correct, not that result #3 has a specific score.

Use refresh: true in integration tests. Elasticsearch doesn't make indexed documents immediately searchable. Always add { refresh: true } to bulk and index operations in tests.

Isolate indexes per test suite. Use unique index names per describe block to prevent test cross-contamination. Clean up in afterAll.

Test both the happy path and edge cases. Empty results, special characters in queries, and queries that match no filter terms are all common sources of production bugs.

Summary

Reliable Elasticsearch testing requires:

  1. Mock client tests for fast, precise verification of query construction (match, bool, nested, aggregations)
  2. Testcontainers integration tests for behavioral testing with real Elasticsearch
  3. Index lifecycle tests to ensure your mapping and settings are correct
  4. Aggregation tests with realistic mock data to verify facet behavior
  5. E2E monitoring with HelpMeTest to catch production regressions continuously

The combination of fast mock tests and real container tests gives you confidence without sacrificing test suite speed.

Read more