How to Test Scrolling with Pytest and Playwright
Test page, container, infinite-scroll and reachability behavior with reliable Pytest and Playwright patterns.
Use the scrolling primitive that matches the behavior you are testing, then assert a visible application result. Playwright usually scrolls actionable elements into view automatically. Make scrolling explicit when the scroll gesture, container movement, lazy loading, or reachability is the behavior under test.
With Python, Pytest and Playwright, the three useful approaches are:
locator.scroll_into_view_if_needed()when the endpoint is an element.page.mouse.wheel(delta_x, delta_y)when you need to model a user wheel gesture.locator.evaluate()when a particular nested container must receive a precisescrollTopchange.
Always verify what scrolling caused: a target becomes visible, more rows load, a marker appears, or the container position changes. Calling a scroll method alone does not prove that the product behaved correctly.
1. Install Pytest and Playwright
The official Python integration provides browser fixtures such as page. Install the plugin and browser binaries:
python -m pip install pytest-playwright
playwright install
The same test model runs against Chromium, Firefox and WebKit. The official setup and fixture documentation is in the Playwright Python introduction.
Create a test file such as tests/test_scrolling.py:
from playwright.sync_api import Page, expect
def test_footer_becomes_visible(page: Page):
page.goto("https://example.test/long-page")
footer = page.get_by_role("contentinfo")
footer.scroll_into_view_if_needed()
expect(footer).to_be_visible()
Replace example.test and the locators with elements from your application.
2. Scroll an element into view
Use scroll_into_view_if_needed() when your test has a semantic destination. Playwright waits for actionability and scrolls the element unless it is already completely visible according to its visibility intersection check. See the Locator API.
from playwright.sync_api import Page, expect
def test_load_more_button_is_reachable(page: Page):
page.goto("https://example.test/feed")
load_more = page.get_by_role("button", name="Load more")
load_more.scroll_into_view_if_needed()
expect(load_more).to_be_visible()
expect(load_more).to_be_enabled()
This is usually the most stable option for a footer, submit button, sentinel, or card. It expresses the goal instead of depending on a viewport height or pixel distance.
Testing an infinite list
Scroll a sentinel or footer near the end of the list, then wait for the application’s observable result. Avoid a fixed sleep when a count, loading state, or end marker is available.
from playwright.sync_api import Page, expect
def test_infinite_list_loads_more(page: Page):
page.goto("https://example.test/feed")
items = page.get_by_role("listitem")
before = items.count()
sentinel = page.get_by_test_id("feed-footer")
sentinel.scroll_into_view_if_needed()
# Adapt this to your application contract.
expect(items).to_have_count(before + 20, timeout=10_000)
If the number of new rows varies, assert a lower bound with a locator count retrieved after the loading indicator disappears, or assert that a known new card is visible:
def test_infinite_list_reveals_next_card(page: Page):
page.goto("https://example.test/feed")
page.get_by_test_id("feed-footer").scroll_into_view_if_needed()
expect(page.get_by_role("heading", name="Next article")).to_be_visible()
3. Simulate a user wheel gesture
Use page.mouse.wheel() when the behavior under test is the user’s wheel input, such as a reader panel, carousel, or custom scroll surface. Hover the intended surface first so the gesture is directed at the correct element. Playwright’s input guidance covers this pattern in its mouse wheel documentation.
from playwright.sync_api import Page, expect
def test_wheel_reaches_next_section(page: Page):
page.goto("https://example.test/reader")
reader = page.get_by_test_id("scrolling-container")
reader.hover()
page.mouse.wheel(0, 600)
expect(page.get_by_role("heading", name="Chapter 2")).to_be_visible()
The delta is application-specific. A positive vertical delta normally moves downward; a negative value moves upward. Do not treat 600 as a universal distance. If content loads asynchronously, assert the loaded state rather than assuming the wheel event completed the request.
Multiple wheel steps
def test_reader_can_scroll_through_two_sections(page: Page):
page.goto("https://example.test/reader")
reader = page.get_by_test_id("scrolling-container")
reader.hover()
for _ in range(2):
page.mouse.wheel(0, 500)
expect(page.get_by_role("heading", name="Chapter 3")).to_be_visible()
Keep the loop small and tie its final assertion to a stable product outcome. For a test of keyboard or touch scrolling, use the corresponding input method instead of wheel events.
4. Scroll a nested div precisely
A dashboard, chat panel, or virtualized table may scroll inside a fixed-height element while the document itself stays still. Run JavaScript in that element with locator.evaluate() to change its scrollTop. The page’s scroll position is not modified.
from playwright.sync_api import Page, expect
def test_inner_panel_scrolls(page: Page):
page.goto("https://example.test/dashboard")
panel = page.get_by_test_id("scrolling-container")
panel.evaluate("element => element.scrollTop += 300")
expect(page.get_by_test_id("panel-end-marker")).to_be_visible()
For a deterministic jump to the bottom:
def test_panel_reaches_end(page: Page):
page.goto("https://example.test/dashboard")
panel = page.get_by_test_id("scrolling-container")
panel.evaluate("element => { element.scrollTop = element.scrollHeight; }")
expect(page.get_by_test_id("panel-end-marker")).to_be_visible()
If the application exposes no semantic marker, inspect the scroll position as a secondary diagnostic:
position = panel.evaluate("element => ({ top: element.scrollTop, height: element.scrollHeight, viewport: element.clientHeight })")
assert position["top"] + position["viewport"] >= position["height"]
Prefer a loaded-row count or end marker for the main assertion because layout changes can alter pixel values.
5. Test automatic scrolling and reachability
Locator actions use automatic scrolling by default, including scrolling nested containers when needed. To prove that an element cannot be reached without a prior scroll, disable that behavior with scroll="none" and make the expected failure part of the test.
import pytest
from playwright.sync_api import Page
def test_continue_requires_scrolling(page: Page):
page.goto("https://example.test/long-form")
continue_button = page.get_by_role("button", name="Continue")
with pytest.raises(Exception):
continue_button.click(scroll="none", timeout=1_000)
A stronger version checks the product state before and after a deliberate scroll:
from playwright.sync_api import Page, expect
def test_continue_becomes_reachable_after_scroll(page: Page):
page.goto("https://example.test/long-form")
button = page.get_by_role("button", name="Continue")
page.get_by_test_id("form-footer").scroll_into_view_if_needed()
expect(button).to_be_visible()
expect(button).to_be_enabled()
Use this mode sparingly. Ordinary interaction tests should normally keep Playwright’s automatic scrolling enabled.
6. Choose robust locators
Locators provide Playwright’s auto-waiting and retry behavior. Prefer semantic locators recommended in the locator guide:
page.get_by_role("button", name="Load more")
page.get_by_role("heading", name="Chapter 2")
page.get_by_label("Search")
page.get_by_placeholder("Filter results")
page.get_by_test_id("scrolling-container")
page.get_by_text("Footer text")
Avoid long CSS or XPath chains coupled to DOM structure, such as #app > div:nth-child(2) > div:nth-child(3). They break when wrappers or layout components change. Use a test id when the element has no stable accessible role or text.
7. Sync and async Pytest forms
The synchronous API is often the simplest form:
from playwright.sync_api import Page, expect
def test_sync_scroll(page: Page):
page.goto("https://example.test")
target = page.get_by_test_id("target")
target.scroll_into_view_if_needed()
expect(target).to_be_visible()
In an async project, await the same operations and use the async API:
from playwright.async_api import Page, expect
async def test_async_scroll(page: Page):
await page.goto("https://example.test")
target = page.get_by_test_id("target")
await target.scroll_into_view_if_needed()
await expect(target).to_be_visible()
Keep the scrolling intent and assertion identical between forms. Only the calling convention changes.
8. Scrolling edge cases
Lazy-loaded images
Scrolling may trigger image requests. Wait for a visible image or application-ready state rather than sleeping for an arbitrary duration:
def test_lazy_image_loads(page: Page):
page.goto("https://example.test/gallery")
image = page.get_by_role("img", name="Product photo 20")
image.scroll_into_view_if_needed()
expect(image).to_be_visible()
expect(image).to_have_attribute("src", timeout=10_000)
Virtualized lists
Virtualized lists reuse DOM nodes, so a row count may remain constant. Assert the requested row’s text, a selected item, or an end marker after scrolling. A DOM locator for an off-screen item may not exist until the virtualizer renders it.
Sticky headers and overlays
A target can be technically in the viewport while covered by a sticky header. Prefer an interaction assertion, such as a successful click, or scroll the target and verify its bounding box relative to the header. If the application intentionally uses an overlay, test the expected overlay state instead of weakening the assertion.
Reduced motion and smooth scrolling
CSS smooth scrolling can make timing variable. Disable animation for deterministic tests through a test-only stylesheet or application setting, then assert the final state. Do not replace the assertion with a long fixed sleep.
Mobile and viewport differences
Run scrolling tests at the viewport sizes your users support. A target that is visible on a desktop viewport may require several gestures on a phone-sized viewport. Keep the endpoint semantic and avoid universal pixel assumptions.
9. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| The page moved, but the panel did not | The wheel event targeted the document. | Hover the nested container before mouse.wheel(), or use container.evaluate() to set scrollTop. |
| The locator is not found after scrolling | A virtualized list has not rendered the item yet, or the locator is brittle. | Scroll a sentinel, wait for the loading result, and use a role, text, label or test-id locator. |
| The test times out while waiting for more rows | The request failed, the sentinel is wrong, or the list has reached its end. | Assert the loading indicator and “no more results” state separately; inspect network and console logs. |
| Click fails even though the element looks visible | A sticky header or modal covers it. | Assert the overlay state, close it if appropriate, and verify the target is actionable. |
| Tests pass locally but fail in CI | Viewport, browser engine, reduced-motion settings or network timing differ. | Run the same browser project locally and in CI, use observable waits, and capture a trace on failure. |
An expected failure never occurs with click() |
Playwright auto-scrolled the target. | Use click(scroll="none") when reachability without scrolling is the behavior under test. |
10. Performance and reliability
- Use the smallest meaningful action. A target scroll is usually faster and less variable than many wheel events.
- Reserve wheel tests for wheel behavior. They add input and layout variables that are unnecessary when you only need an element visible.
- Wait on state, not time. Locator assertions, loading indicators and end markers adapt to real network speed.
- Reuse browser setup. The Pytest plugin manages browser fixtures; keep authentication and expensive setup in fixtures where appropriate.
- Test more than one engine when layout matters. Chromium, Firefox and WebKit can expose different overflow and sticky-position behavior.
- Collect traces for intermittent failures. A trace showing the viewport, DOM and action timing is more useful than increasing every timeout.
Scrolling tests cost browser time and network traffic. Keep test data finite, stub slow third-party resources when they are not the behavior under test, and use a deterministic fixture for infinite feeds.
11. Or skip the browser setup
If your goal is a clean image of the final scrolled page rather than testing the scroll interaction itself, ScreenshotNeo provides a GET endpoint that returns a PNG, JPEG, WebP or PDF. Its API can capture a full page, wait for a selector, delay or network idle, load lazy images, click an element, hide selectors, run custom JavaScript and CSS, and choose a viewport or device preset. Read the ScreenshotNeo documentation for the available parameters.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Cookie banners, newsletter popups and chat widgets are removed before the shot. Bot checks, blank pages, failed loads and cache hits are not billed, and response headers identify the page verdict and billing status. An MCP server provides take_screenshot, get_page_info and capture_pdf tools for Claude, Cursor and other MCP clients. The Free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000.
Create a free ScreenshotNeo account and start with 1,000 screenshots per month at no charge.
12. FAQ
Should I test scrolling by checking scrollTop?
Use scrollTop as a diagnostic or when position is the product contract. For most tests, assert the user-visible result that scrolling enables.
Is scroll_into_view_if_needed() a replacement for wheel testing?
No. It verifies that a target can be brought into view. Use wheel input when the gesture itself, its direction, or its event handling matters.
How do I know whether the page or a nested div should scroll?
Inspect the application’s scroll surface. Hover and wheel the panel for user-like input, or evaluate that panel’s scrollTop for precise container control.
Can I use fixed sleeps for infinite scroll?
You can, but they are fragile. Prefer a loaded row, loading indicator transition, end marker or network-backed application state.
Why does Playwright scroll when I did not call a scroll method?
Locator actions automatically scroll actionable targets into view. Disable it with scroll="none" only when testing that an off-screen element is not reachable yet.


