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
| Layer | Pitfall | E2E fix |
|---|---|---|
| Index lag | Row inserted; search returns 0 hits | Poll index probe until objectID visible |
| Dual write | DB commit succeeds; indexer fails silently | Probe DB row + index document |
| Facets | UI count ≠ index facet stats | Assert facet via search API, not chip label |
| Replicas | Read replica stale after write | Target primary in test env or wait for refresh |
| Synonyms | Query matches unexpected SKU | Seed known token; assert exact objectID set |
| Ranking | Position drift breaks nth-child | Assert 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
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| Index sync wait | Webhook/indexer delay | Empty results flake | expect.poll index probe for runId SKU |
| Algolia task queue | taskID not finished | Intermittent miss | Poll waitTask or admin GET object |
| ES refresh interval | Near-real-time lag | Assert too early | refresh=true on test index or probe API |
| Facet AND logic | Over-filtered empty set | False pass on skeleton | Seed item matching all facets; probe count |
| Facet OR groups | Wrong disjunctive logic | Too many hits | Probe IDs ⊆ expected set |
| Geo search | Lat/lng rounding | Borderline radius miss | Seed fixed coordinates; probe distance sort |
| Typo tolerance | Algolia typo settings | Unexpected match | Assert with/without typo per scenario |
| Delete propagation | Soft-delete in DB still indexed | Ghost results | Probe index miss after delete webhook |
| Reindex job | Full reindex during test | Random empty windows | Gate tests on index health probe |
| Shared staging index | Parallel CI same objectID | Cross-talk hits | Namespace objectID with runId prefix |
| Secured API keys | Frontend key can't read all fields | Probe misses hidden attr | Server probe route with admin key |
| Pagination cursor | Page 2 after filter change | Duplicate or 404 | Reset page; probe first-page IDs |
| Sort stability | Tie-breaker changes | nth result flake | Assert set membership not order |
| Multi-index | Products vs articles federated | Wrong index queried | Probe 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, pollindex.getObject(objectID)orclient.waitTask(taskID)before UI Act. - For replica indices, probe the same replica the frontend queries (
sortByreplica name). - Secured API keys with facet filters: seed keys per
runIdin test env or probe server-side.
Elasticsearch-specific notes
- Set
index.refresh_intervalto1sor use single-docrefresh=wait_forin E2E writes—not30sproduction defaults. - Assert on
_source.run_idandhits.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-pattern | Why it fails | Better approach |
|---|---|---|
| Assert spinner hidden | Index still empty | Poll index probe first |
| Shared staging index | Parallel hit pollution | runId on objectID + filter |
| Admin key in Playwright | Credential leak | Server probe routes |
sleep(5000) after seed | Still flaky on slow reindex | expect.poll on object existence |
| nth-child result assert | Ranking changes | Assert objectID in set |
| Disable indexer in test | False confidence | Test real sync path |
| UI facet count only | Stale InstantSearch state | Probe facet stats API |
| Delete DB row only | Ghost search hits | Probe index miss post-delete |
External references
- Algolia wait for indexing
- Algolia secured API keys
- Elasticsearch refresh and near real-time search
- Playwright expect.poll
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.
- Arrange: Seed catalog row and bulk index enqueue with runId-scoped objectID via test seed route.
- Act: Open storefront; search SKU prefix; apply category facet.
- 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.
Related
- Search, filters, and faceted navigation
- Seed routes and probe Assert
- RAG and semantic search testing
- UI-only assertions miss backend bugs
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.