How to Test Infinite Scroll and Pagination
Short answer
Infinite scroll fails on duplicate fetches, race conditions at scroll boundaries, and missing end-of-list states—not on whether more rows appear once. Seed paged APIs with known totals, wait for network responses or monotonic item counts, assert unique IDs, and probe cursor/page tokens instead of arbitrary scroll sleeps.
Part of Testing Guides by UI patterns.
Who this is for
Teams shipping feeds, activity timelines, chat history loaders, or product grids that append pages on scroll or "Load more" clicks. Typical stacks: cursor-based GraphQL, offset REST (?page=2), Intersection Observer hooks, React Virtualized lists.
Why testing infinite scroll matters
Scroll loaders look simple but break in costly ways:
- Duplicate rows — double fetch at scroll threshold shows duplicate orders or charges in admin consoles.
- Missing end state — users scroll forever into blank loaders; support tickets spike on "stuck loading."
- Stale pages — filter change leaves page-3 cursor while UI shows page-1 items.
- API cost — runaway prefetch in mobile webviews hammers backend; only visible at scale.
- Accessibility — focus lost when new rows inject; keyboard users cannot reach appended items.
Assert monotonic unique IDs, terminal end state, and probe page metadata—not pixel scroll position.
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| Duplicate fetch | Scroll fires twice | Duplicate IDs in DOM | Assert Set size equals row count |
| Fast scroll | Requests overlap | Out-of-order append | Wait for response before next scroll |
| End of list | No sentinel | Infinite spinner | Assert "end" probe or disabled loader |
| Empty first page | Zero items | Loader never stops | Probe total=0 upfront |
| Filter reset | Old cursor reused | Mixed items | Assert cursor resets on filter |
| Scroll container | Window vs div | Wrong scroll target | Scroll locator not page |
| Load more button | Disabled at end | Click loops | Assert button hidden/disabled |
| Reverse chronology | Prepends vs appends | Wrong order | Probe ordered IDs |
| Race on mount | Double initial fetch | Duplicate page 1 | Network count assertion |
| Virtual list | Rows unmounted | count flaky | Probe total loaded via API hook |
| Back navigation | Restores scroll + stale data | Wrong items | bfcache or remount assert |
| Page size change | User preference 25→50 | Gap in items | Probe cumulative count |
Seed paged API
// POST /api/test/seed-feed { runId, total: 47, pageSize: 10 }
await request.post('/api/test/seed-feed', { data: { runId, total: 47, pageSize: 10 } });
Known total lets you compute expected pages: ceil(47/10) = 5 loads, last page 7 items.
Scroll and wait pattern
const feed = page.getByTestId('scroll-feed');
async function loadNextPage() {
const before = await feed.getByTestId('feed-item').count();
const resp = page.waitForResponse(r => r.url().includes('/api/feed') && r.ok());
await feed.evaluate(el => { el.scrollTop = el.scrollHeight; });
await resp;
await expect.poll(() => feed.getByTestId('feed-item').count()).toBeGreaterThan(before);
return before;
}
let count = await feed.getByTestId('feed-item').count();
while (count < 47) {
count = await loadNextPage() + /* growth handled in poll */;
count = await feed.getByTestId('feed-item').count();
}
await expect(page.getByText(/end of feed|no more/i)).toBeVisible();
For cursor APIs, probe after each load:
const { nextCursor, items } = await request.get(`/api/test/feed-state?runId=${runId}`).then(r => r.json());
expect(nextCursor).toBeNull(); // terminal
expect(items.length).toBe(47);
Duplicate detection
const ids = await feed.getByTestId('feed-item').evaluateAll(nodes =>
nodes.map(n => n.getAttribute('data-id'))
);
expect(new Set(ids).size).toBe(ids.length);
Run after aggressive scroll jiggle tests that intentionally trigger double intersection events.
Pagination (numbered) variant
await page.getByRole('link', { name: '2' }).click();
await expect(page).toHaveURL(/page=2/);
const probe = await request.get(`/api/test/list?page=2&runId=${runId}`);
expect((await probe.json()).items).toHaveLength(10);
Numbered pagination shares filter-reset rules with infinite scroll—reset page index when filters change.
CI checklist
- Seed feeds with non-round totals (e.g., 47 items, page size 10)
- Scroll the correct container—log
scrollHeightin debug on failure - Wait for network or poll count—never fixed 2s sleep
- Assert unique IDs after stress scroll
- Terminal state spec mandatory
- Parallel workers use isolated
runIdfeeds
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
window.scrollTo(99999) on div feed | No load triggered | Scroll container element |
| Assert row count only once | Miss duplicates | Unique ID set |
| Skip end-of-list | UX hang untested | Probe null cursor |
| Ignore filter+cursor reset | Mixed pages | Reseed + assert page 1 |
| Full 10k row scroll in CI | Slow, flaky | Seed small totals; unit test math |
| Screenshot-only assert | Miss ID dupes | Probe + Set uniqueness |
Example scenario
Situation: User rapidly scrolls activity feed to bottom twice at network boundary.
Expected outcome: 47 unique events loaded; no duplicate IDs; end sentinel shown.
Why UI-only automation breaks: Row count reaches 47 but two rows share same event id—admin export double-counts.
- Arrange: Seed feed with 47 unique event ids for runId.
- Act: Scroll feed container with double intersection trigger at page boundaries.
- Assert: Probe items.length=47, nextCursor=null; DOM id Set size=47; end message visible.
TestChimp workflow: Optional: instrument feed_page_loaded with page_index; compare prod depth vs test when users routinely load 5+ pages.
Same Arrange/Act/Assert pattern as expired-coupon checkout.
Connect scenarios to your QA workflow
Capture business rules in markdown test plans and enforce them with seed routes and probe Assert. Link SmartTests with // @Scenario: for requirement traceability. Use /testchimp test on PRs; /testchimp explore on SmartTest paths for non-functional gaps (ExploreChimp).
Related scenarios
- Search and filters — filtered infinite lists
- Data grids — virtual scroll tables
- WebSockets — live prepended items
External references
Frequently asked questions
How do I test infinite scroll without flaky scroll positions?
Scroll the feed container to scrollHeight, wait for the feed API response or poll item count until it increases, repeat until probe reports terminal cursor or expected total.
Window scroll vs scrollable div?
Most apps use an inner div—target that locator with evaluate scrollTop. Window scrollTo often does nothing and tests pass falsely.
How do I detect duplicate fetches?
After stress scrolling, collect data-id attributes from all rows and assert Set size equals array length. Optionally count network requests per page index.
Should I test numbered pagination separately?
Yes if your app supports both patterns—URL page param, probe page API, and filter reset behavior differ from cursor infinite scroll.
How do virtualized lists change assertions?
DOM row count may cap at viewport size—use probe API for total loaded items and spot-check visible rows via scrollIntoViewIfNeeded.
What end-of-list signal should I assert?
Prefer probe nextCursor=null or totalReached flag. UI may show text, hide loader, or disable button—probe is authoritative for data completeness.
When should TrueCoverage guide infinite scroll tests?
When prod users routinely load many pages but CI stops early—instrument pages_loaded and expand SmartTests for deep scroll paths.
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.