Skip to main content

How to Test Algolia and Elasticsearch Search in Playwright

Short answer

Search E2E fails when tests assert UI hit counts while the index lags Postgres or a reindex job is still running. Seed catalog rows per runId, wait for index sync via probe routes or Algolia/ES admin APIs, and assert result IDs and facet counts—not skeleton loaders or InstantSearch loading states alone. Pair with search and filter patterns for URL and facet combinatorics.

Part of Testing Guides by integrations.

Who this is for

Teams shipping InstantSearch, Algolia Autocomplete, Elasticsearch frontends (OpenSearch, AWS ES), or hybrid stacks where Postgres is source-of-truth and search is a derived index. Common pain: green CI while production shows stale facets after deploy, or parallel workers polluting a shared staging index.

Why Algolia and Elasticsearch need different Arrange/Assert

LayerPitfallE2E fix
Index lagRow inserted; search returns 0 hitsPoll index probe until objectID visible
Dual writeDB commit succeeds; indexer fails silentlyProbe DB row + index document
FacetsUI count ≠ index facet statsAssert facet via search API, not chip label
ReplicasRead replica stale after writeTarget primary in test env or wait for refresh
SynonymsQuery matches unexpected SKUSeed known token; assert exact objectID set
RankingPosition drift breaks nth-childAssert membership in result set, not row index

UI-only asserts miss indexer bugs—a spinner can disappear while the index still returns the previous page of results.

Complexity map

ScenarioEdge caseWhy tests breakApproach
Index sync waitWebhook/indexer delayEmpty results flakeexpect.poll index probe for runId SKU
Algolia task queuetaskID not finishedIntermittent missPoll waitTask or admin GET object
ES refresh intervalNear-real-time lagAssert too earlyrefresh=true on test index or probe API
Facet AND logicOver-filtered empty setFalse pass on skeletonSeed item matching all facets; probe count
Facet OR groupsWrong disjunctive logicToo many hitsProbe IDs ⊆ expected set
Geo searchLat/lng roundingBorderline radius missSeed fixed coordinates; probe distance sort
Typo toleranceAlgolia typo settingsUnexpected matchAssert with/without typo per scenario
Delete propagationSoft-delete in DB still indexedGhost resultsProbe index miss after delete webhook
Reindex jobFull reindex during testRandom empty windowsGate tests on index health probe
Shared staging indexParallel CI same objectIDCross-talk hitsNamespace objectID with runId prefix
Secured API keysFrontend key can't read all fieldsProbe misses hidden attrServer probe route with admin key
Pagination cursorPage 2 after filter changeDuplicate or 404Reset page; probe first-page IDs
Sort stabilityTie-breaker changesnth result flakeAssert set membership not order
Multi-indexProducts vs articles federatedWrong index queriedProbe per-index with runId tag

Seed pattern (catalog + index sync probe)

// app/api/test/seed-catalog/route.ts
export async function POST(req: Request) {
if (process.env.E2E_TEST_MODE !== 'true') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { runId, items } = await req.json();
const tagged = items.map((item: { sku: string; title: string; tags: string[] }) => ({
...item,
sku: `${runId}-${item.sku}`,
objectID: `${runId}-${item.sku}`,
run_id: runId,
}));

await db.catalog.insertMany(tagged);
await searchIndexer.enqueueBulk(tagged); // your worker or direct Algolia/ES client

return NextResponse.json({ runId, skus: tagged.map((t) => t.sku) });
}
// app/api/test/probe-search-index/route.ts
import algoliasearch from 'algoliasearch';

const admin = algoliasearch(process.env.ALGOLIA_APP_ID!, process.env.ALGOLIA_ADMIN_KEY!);
const index = admin.initIndex(process.env.ALGOLIA_INDEX!);

export async function GET(req: Request) {
if (process.env.E2E_TEST_MODE !== 'true') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const runId = new URL(req.url).searchParams.get('runId');
const query = new URL(req.url).searchParams.get('q') ?? '';
const { hits, nbHits } = await index.search(query, {
filters: `run_id:${runId}`,
attributesToRetrieve: ['objectID', 'sku'],
});
return NextResponse.json({ nbHits, objectIDs: hits.map((h) => h.objectID) });
}

For Elasticsearch, replace the probe with _search against a test index and term filter on run_id.keyword—optionally pass refresh=true on write in E2E env only.

Playwright spec with index sync wait

// @Scenario: search/facet-combo-red-widgets
import { test, expect } from '../fixtures/run';

test('facet combo returns only red widgets for run', async ({ page, request, runId }) => {
await request.post('/api/test/seed-catalog', {
data: {
runId,
items: [
{ sku: 'widget-red', title: 'Red Widget', tags: ['widget', 'red'] },
{ sku: 'widget-blue', title: 'Blue Widget', tags: ['widget', 'blue'] },
],
},
});

// Wait for indexer—never assert UI before index agrees
await expect.poll(async () => {
const res = await request.get(
`/api/test/probe-search-index?runId=${runId}&q=widget`,
);
return (await res.json()).nbHits;
}, { timeout: 30_000 }).toBe(2);

await page.goto('/catalog');
await page.getByRole('searchbox', { name: 'Search products' }).fill('widget');
await page.getByRole('checkbox', { name: 'Red' }).check();

await expect.poll(async () => {
const res = await request.get(
`/api/test/probe-search-index?runId=${runId}&q=widget`,
);
const { objectIDs } = await res.json();
return objectIDs.filter((id: string) => id.includes('red')).length;
}).toBe(1);

await expect(page.getByRole('article')).toHaveCount(1);
});

Link facet URL behavior to search, filters, and faceted navigation—this guide focuses on index authority and sync waits.

Algolia-specific notes

  • Use admin API in server probes only—never ship admin keys to Playwright browser context.
  • After saveObjects, poll index.getObject(objectID) or client.waitTask(taskID) before UI Act.
  • For replica indices, probe the same replica the frontend queries (sortBy replica name).
  • Secured API keys with facet filters: seed keys per runId in test env or probe server-side.

Elasticsearch-specific notes

  • Set index.refresh_interval to 1s or use single-doc refresh=wait_for in E2E writes—not 30s production defaults.
  • Assert on _source.run_id and hits.total.value, not just DOM row count.
  • For alias swaps (blue/green reindex), health probe should check alias target before tests start.

TestChimp workflow

Use // @Scenario: comments to tie specs to markdown plans—/testchimp test preserves probe Assert when InstantSearch refactors change CSS class hooks. Run /testchimp init to scaffold seed/probe routes; prioritize high-traffic filter combos via TrueCoverage rather than exhaustive facet Cartesian products.

Anti-patterns

Anti-patternWhy it failsBetter approach
Assert spinner hiddenIndex still emptyPoll index probe first
Shared staging indexParallel hit pollutionrunId on objectID + filter
Admin key in PlaywrightCredential leakServer probe routes
sleep(5000) after seedStill flaky on slow reindexexpect.poll on object existence
nth-child result assertRanking changesAssert objectID in set
Disable indexer in testFalse confidenceTest real sync path
UI facet count onlyStale InstantSearch stateProbe facet stats API
Delete DB row onlyGhost search hitsProbe index miss post-delete

External references

Example scenario

Situation: Merchandiser adds SKU via admin; storefront search should show it within seconds.

Expected outcome: Search returns new SKU; facet counts update; no ghost listings after delete.

Why UI-only automation breaks: Results panel renders but index never received object—empty state flashes then shows stale catalog.

  1. Arrange: Seed catalog row and bulk index enqueue with runId-scoped objectID via test seed route.
  2. Act: Open storefront; search SKU prefix; apply category facet.
  3. Assert: Poll probe-search-index for objectID; optional UI card visible; after admin delete, probe confirms miss.

TestChimp workflow: // @Scenario: links catalog search spec to markdown; /testchimp test keeps index poll Assert when InstantSearch widgets regen.

Same Arrange/Act/Assert pattern as expired-coupon checkout.

Frequently asked questions

How long should index sync polls wait in CI?

Start with 30s timeout on expect.poll; tune from p95 indexer lag in staging. Fail fast with clear probe logs when objectID never appears.

Algolia vs Elasticsearch probe strategy?

Both need server-side admin/search API probes returning objectIDs and hit counts. Never rely on InstantSearch loading=false alone.

Can we use Algolia browse in tests?

Browse with admin key works in server probes for runId filters. Frontend browse keys may lack facet permissions—keep probes on the server.

How to test dual-write failures?

Seed DB row then simulate indexer failure in a dedicated scenario; probe DB has row while index probe returns zero—catches silent indexer regressions.

Shared staging index with production data?

Avoid. Use runId namespacing on objectID and mandatory filter facet; tear down test objects in globalTeardown via admin deleteBy filter.

Elasticsearch refresh in production-like env?

Do not set refresh=wait_for globally in prod. Use E2E-only index settings or probe with retry; document lag budget in scenario markdown.

How does this relate to search filter guide?

pat-search-filters covers URL state and facet UX; this guide covers index authority, sync waits, and Algolia/ES admin probes.

TestChimp with search stacks?

/testchimp init scaffolds seed-catalog and probe-search-index routes; // @Scenario links SmartTests to markdown; TrueCoverage prioritizes filter combos users actually hit.

Apply these patterns in your repo

Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.

Start free on TestChimp · Book a demo