Reliably verifying tap, swipe, and pinch-zoom
Mobile storefronts live on touch gestures: swiping through image galleries, tapping filter buttons, dragging off-canvas menus. Testing these interactions with simulated mouse clicks alone misses ghost clicks, blocked scroll directions, and hover menus that are simply unreachable on a touchscreen. This article shows how to reliably simulate real touch events in Cypress and Playwright.
Table of Contents
- 1. Why touch interactions need their own test strategy
- 2. Touch Events, Pointer Events, and MouseEvents compared
- 3. The click delay and the ghost-click problem
- 4. CSS touch-action and scroll-vs-gesture conflicts
- 5. Automating touch gestures in Playwright
- 6. Automating touch gestures in Cypress
- 7. Testing swipeable image galleries and carousels
- 8. Testing mobile filter drawers and off-canvas panels
- 9. Click simulation vs. real touch event simulation
- 10. Summary
- 11. FAQ
1. Why touch interactions need their own test strategy
Most E2E frameworks simulate a mouse click by default, even when the test emulates a mobile viewport. A cy.click() or page.click() internally dispatches a chain of MouseEvents, regardless of whether viewport('iphone-x') is set. The problem: the vast majority of visitors to a Magento or Hyvä store navigate via a touchscreen in real life, not a mouse. A test that only fires MouseEvents is therefore validating an interaction model the actual audience never uses.
In practice, this produces a deceptively green test suite: a dropdown menu that opens via CSS :hover passes every click-based test without complaint, because Selenium, Cypress, and Playwright can all simulate a hover state by default. On a real smartphone, that hover state simply does not exist, and the menu stays unreachable for real users. The same is true for swipe carousels and off-canvas filters whose JavaScript logic listens exclusively for touchstart, touchmove, and touchend. Testing these patterns properly means leaving the level of synthetic events behind and reconstructing real touch sequences.
2. Touch Events, Pointer Events, and MouseEvents compared
A TouchEvent carries three lists of contact points: touches (all current contacts on the screen), targetTouches (contacts on the current target element), and changedTouches (the contacts that triggered the current event). Each entry has its own clientX/clientY coordinates and an identifier ID, which lets multiple simultaneous fingers be tracked separately, for instance for a pinch-zoom gesture with two contact points.
The PointerEvent interface unifies mouse, touch, and pen under one shared API and additionally carries a pointerType attribute (mouse, touch, or pen). Modern browsers fire a fixed sequence on a real touch: pointerdown, followed by touchstart, and only afterward, for backward compatibility with legacy code, synthetic mousedown/click events. This exact compatibility layer is why a plain click() call in tests often "works" even though it never exercises the touch-based code path of the application: the test directly triggers the synthetic tail end of the chain and skips touchstart/touchmove entirely.
3. The click delay and the ghost-click problem
Mobile browsers historically waited around 300 milliseconds after touchend before firing the click event, to distinguish a possible double-tap-to-zoom from a single tap. With <meta name="viewport" content="width=device-width"> this delay is usually gone in modern browsers, but the underlying problem of duplicated events remains: on some devices and in hybrid WebViews, a single touch fires both a real touch handler and a subsequent synthetic click, causing actions like "add to cart" to fire twice by accident.
This phenomenon, known as a ghost click, typically happens when a touchend handler already triggers an action and preventDefault() is forgotten, so the browser additionally fires the following click. A test that only calls click() can never surface this bug, because it never dispatches the full touch sequence with touchstart and touchend that produces the double trigger in the first place. Only a test that sends real touch events and then counts the number of triggered network requests or store updates reliably surfaces ghost clicks.
4. CSS touch-action and scroll-vs-gesture conflicts
The CSS property touch-action determines which native gestures the browser still handles itself for an element, and which it hands off to JavaScript. touch-action: pan-y still allows vertical page scrolling, but disables horizontal panning, so a carousel can evaluate the horizontal swipe gesture itself via a touchmove handler without colliding with native scroll behavior. touch-action: none removes all native gesture handling for an element entirely, for instance for a draggable off-canvas panel.
A classic bug: if touch-action is missing on a carousel container, the browser first interprets every horizontal swipe as an attempt to scroll the page, and the carousel only responds after a noticeable delay, or not at all. For tests, this means a browser context with touch emulation enabled (hasTouch: true) is required, since touch-action only applies to real or emulated touch pointers. CI runners support this via the Chrome DevTools Protocol (Input.dispatchTouchEvent), which both Cypress plugins and Playwright's native touch API rely on internally.
5. Automating touch gestures in Playwright
Playwright ships with a native touch API via page.touchscreen.tap(x, y), but it only covers single taps, not swipe or pinch gestures. It requires a browser context with hasTouch: true, which is already set automatically by the built-in device profiles such as devices['iPhone 13']. For swipe gestures, the raw event chain has to be assembled manually, either via locator.dispatchEvent() with a constructed Touch object, or directly through the Chrome DevTools Protocol for a more realistic, multi-step movement with intermediate positions.
It's important that a simulated swipe consists of several touchmove steps rather than a single jump from start to end position, since many carousel implementations compute velocity from the difference between several intermediate points to distinguish an accidental wobble from a deliberate swipe. The example below shows helper functions for tap, swipe, and long-press built on exactly that principle.
// tests/utils/touch-helpers.js: Reusable touch gesture helpers for Playwright
const { expect } = require('@playwright/test');
/**
* Perform a native tap using Playwright's built-in touchscreen API.
* Requires a browser context created with hasTouch: true.
*/
async function tap(page, x, y) {
await page.touchscreen.tap(x, y);
}
/**
* Simulate a swipe gesture with multiple intermediate touchmove steps,
* so velocity-based gesture handlers can detect a real swipe.
*/
async function swipe(page, locator, { fromX, fromY, toX, toY, steps = 8 }) {
const box = await locator.boundingBox();
const startX = box.x + fromX;
const startY = box.y + fromY;
const endX = box.x + toX;
const endY = box.y + toY;
await locator.dispatchEvent('touchstart', {
touches: [{ identifier: 1, clientX: startX, clientY: startY }],
changedTouches: [{ identifier: 1, clientX: startX, clientY: startY }],
});
for (let i = 1; i <= steps; i++) {
const progress = i / steps;
const x = startX + (endX - startX) * progress;
const y = startY + (endY - startY) * progress;
await locator.dispatchEvent('touchmove', {
touches: [{ identifier: 1, clientX: x, clientY: y }],
changedTouches: [{ identifier: 1, clientX: x, clientY: y }],
});
await page.waitForTimeout(16); // roughly one frame per step
}
await locator.dispatchEvent('touchend', {
touches: [],
changedTouches: [{ identifier: 1, clientX: endX, clientY: endY }],
});
}
/**
* Simulate a long-press by holding touchstart for a fixed duration
* before dispatching touchend, useful for context menus on product cards.
*/
async function longPress(page, locator, durationMs = 600) {
const box = await locator.boundingBox();
const centerX = box.x + box.width / 2;
const centerY = box.y + box.height / 2;
await locator.dispatchEvent('touchstart', {
touches: [{ identifier: 1, clientX: centerX, clientY: centerY }],
changedTouches: [{ identifier: 1, clientX: centerX, clientY: centerY }],
});
await page.waitForTimeout(durationMs);
await locator.dispatchEvent('touchend', {
touches: [],
changedTouches: [{ identifier: 1, clientX: centerX, clientY: centerY }],
});
}
module.exports = { tap, swipe, longPress };
6. Automating touch gestures in Cypress
Unlike Playwright, Cypress does not ship with a native touch API. The community plugin cypress-real-events can fire genuine OS-level events via the Chrome DevTools Protocol, including realTouch and realSwipe. If the plugin isn't available, or if the simulation needs to avoid an extra dependency, the same event chain can be rebuilt manually via cy.wrap(element).trigger('touchstart', { touches: [...] }), since trigger() dispatches a raw DOM event with arbitrary properties.
A clean custom command wraps this logic once and makes it reusable across the entire test suite, similar to existing login or fixture commands. It's important to set the mobile viewport ahead of time with cy.viewport('iphone-x'), since some applications only register their touch handlers above a specific breakpoint width, and a test running in a desktop viewport would never attach the handlers at all, regardless of which events are sent afterward.
// cypress/support/commands.js: Custom command for a swipe gesture via raw touch events
Cypress.Commands.add('swipe', { prevSubject: 'element' }, (subject, direction = 'left') => {
const el = subject[0];
const rect = el.getBoundingClientRect();
const startX = direction === 'left' ? rect.right - 20 : rect.left + 20;
const endX = direction === 'left' ? rect.left + 20 : rect.right - 20;
const y = rect.top + rect.height / 2;
const touchObj = (x, id) => ({
identifier: id,
target: el,
clientX: x,
clientY: y,
pageX: x,
pageY: y,
});
cy.wrap(el)
.trigger('touchstart', {
touches: [touchObj(startX, 1)],
changedTouches: [touchObj(startX, 1)],
})
// Two intermediate steps so velocity-based swipe detection can fire
.trigger('touchmove', {
touches: [touchObj(startX + (endX - startX) * 0.5, 1)],
changedTouches: [touchObj(startX + (endX - startX) * 0.5, 1)],
})
.trigger('touchmove', {
touches: [touchObj(endX, 1)],
changedTouches: [touchObj(endX, 1)],
})
.trigger('touchend', {
touches: [],
changedTouches: [touchObj(endX, 1)],
});
});
// Usage in a spec: cy.viewport('iphone-x'); cy.get('.gallery').swipe('left');
7. Testing swipeable image galleries and carousels
For a product image carousel, it's not enough to check that clicking an arrow button changes the slide. The actual mobile interaction path runs through a horizontal swipe gesture, whose handler is bound exclusively to touchstart/touchmove/touchend. A complete test simulates the gesture with several intermediate steps, then checks whether the gallery's aria-current or index state increments correctly, and verifies the slide container's CSS transform property for the expected offset.
It's also worth adding an edge-case test for behavior at the start and end of the gallery: many implementations show a bounce effect there, or block further swiping entirely, rather than wrapping back to the first image. Equally important is a test for swipes that are too short or too slow, falling below a velocity or distance threshold and therefore not supposed to trigger a slide change, but instead snap back to the starting position. A pure click test cannot cover any of these states, because it never produces a continuous movement with variable velocity.
// tests/gallery.spec.js: Playwright test for a swipeable product image gallery
const { test, expect } = require('@playwright/test');
const { swipe } = require('./utils/touch-helpers');
test.use({ hasTouch: true, viewport: { width: 390, height: 844 } });
test('swiping left advances the product gallery to the next slide', async ({ page }) => {
await page.goto('/catalog/product/view/id/42');
const gallery = page.locator('.product-gallery');
const track = page.locator('.product-gallery__track');
await expect(gallery).toHaveAttribute('data-active-index', '0');
await swipe(page, gallery, { fromX: 300, fromY: 100, toX: 40, toY: 100 });
await expect(gallery).toHaveAttribute('data-active-index', '1');
const transform = await track.evaluate((el) => getComputedStyle(el).transform);
expect(transform).not.toBe('none');
});
test('swiping past the last slide does not wrap around', async ({ page }) => {
await page.goto('/catalog/product/view/id/42');
const gallery = page.locator('.product-gallery');
// Swipe left repeatedly to reach the last slide, then once more
for (let i = 0; i < 5; i++) {
await swipe(page, gallery, { fromX: 300, fromY: 100, toX: 40, toY: 100 });
}
const lastIndex = await gallery.getAttribute('data-active-index');
await swipe(page, gallery, { fromX: 300, fromY: 100, toX: 40, toY: 100 });
await expect(gallery).toHaveAttribute('data-active-index', lastIndex);
});
8. Testing mobile filter drawers and off-canvas panels
A mobile filter drawer is usually opened by a tap on a button, which also works with a classic click test, since the opening button typically carries a normal click handler. It gets more interesting on the closing side: many implementations additionally allow the panel to be swiped shut, and tapping the semi-transparent backdrop outside the panel, where the latter is often bound to touchstart rather than click to avoid delays from the historical click delay.
A thorough test checks three things: first, that body scroll gets locked as soon as the panel is open, usually via an overflow: hidden class on the body element. Second, that a tap on the backdrop closes the panel again and correctly sets aria-hidden="true". Third, that a swipe-left gesture on the panel itself has the same effect as tapping the close button, if the application supports that gesture. That third point in particular can only be reproduced with real touch events, a simulated click on the panel edge would have no effect whatsoever.
// cypress/e2e/filter-drawer.cy.js: Testing a mobile off-canvas filter drawer
describe('Mobile filter drawer', () => {
beforeEach(() => {
cy.viewport('iphone-x');
cy.visit('/catalog/category/view/id/15');
});
it('opens on tap and locks body scroll', () => {
cy.get('[data-testid="filter-toggle"]').click();
cy.get('[data-testid="filter-drawer"]').should('have.attr', 'aria-hidden', 'false');
cy.get('body').should('have.class', 'overflow-hidden');
});
it('closes when the backdrop is tapped', () => {
cy.get('[data-testid="filter-toggle"]').click();
cy.get('[data-testid="filter-backdrop"]').trigger('touchstart', {
touches: [{ identifier: 1, clientX: 350, clientY: 400 }],
});
cy.get('[data-testid="filter-backdrop"]').trigger('touchend', { touches: [] });
cy.get('[data-testid="filter-drawer"]').should('have.attr', 'aria-hidden', 'true');
});
it('closes on a left swipe gesture on the panel itself', () => {
cy.get('[data-testid="filter-toggle"]').click();
cy.get('[data-testid="filter-drawer"]').swipe('left');
cy.get('[data-testid="filter-drawer"]').should('have.attr', 'aria-hidden', 'true');
});
});
9. Click simulation vs. real touch event simulation
Choosing between a simple click simulation and a real touch event sequence isn't a question of test effort, it directly decides which bugs become visible at all. The table below summarizes the key differences.
| Criterion | Click simulation (click()) | Real touch event simulation |
|---|---|---|
| Fires touchstart/touchend | No | Yes, full sequence |
| Surfaces ghost-click bugs | No, bug stays invisible | Yes, double trigger measurable |
| Swipe/drag gestures | Cannot be represented | Simulated via touchmove steps |
| Hover-fallback behavior | Test passes despite missing hover | Surfaces missing tap fallback |
| touch-action conflicts | Cannot be verified | Scroll-vs-gesture conflict visible |
| Realism for mobile users | Low | High |
The table's last row points to one of the most stubborn testing problems: hover-dependent UI. Dropdown menus, tooltips, and mega-menus that open via CSS :hover simply have no hover state to open on a touchscreen. Some mobile browsers interpret the first tap on such an element as a hover simulation and only treat the second tap as the actual click, a behavior that differs between iOS Safari, Chrome for Android, and various WebViews and can't be reliably tested, let alone recommended as a UX pattern. The robust fix rarely lives in the test, it lives in the UI code: a mega-menu should distinguish between real hover on desktop devices and an explicit tap-toggle with aria-expanded on touch devices via @media (hover: hover) and (pointer: fine). The test then checks both paths separately, with two Playwright projects for different pointerType emulation.
/* styles/mega-menu.css: differentiate hover-capable pointers from touch pointers */
.mega-menu__panel {
display: none;
touch-action: pan-y; /* allow vertical page scroll inside the open panel */
}
/* Desktop with a real mouse: open on hover, no JS needed */
@media (hover: hover) and (pointer: fine) {
.mega-menu__trigger:hover + .mega-menu__panel {
display: block;
}
}
/* Touch devices: panel is opened only via the aria-expanded JS toggle below */
@media (hover: none), (pointer: coarse) {
.mega-menu__panel[data-open="true"] {
display: block;
}
}
/*
Corresponding Playwright test: assert the menu is reachable via tap
on a touch viewport, since :hover never applies there.
test.use({ hasTouch: true, viewport: { width: 390, height: 844 } });
test('mega menu opens via tap on touch viewports', async ({ page }) => {
await page.goto('/');
const trigger = page.locator('.mega-menu__trigger');
const panel = page.locator('.mega-menu__panel');
await expect(panel).toBeHidden();
await trigger.dispatchEvent('touchstart', { touches: [{ identifier: 1, clientX: 40, clientY: 20 }] });
await trigger.dispatchEvent('touchend', { touches: [] });
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
await expect(panel).toBeVisible();
});
*/
Mironsoft
Touch testing, mobile UX assurance, and E2E test automation for Magento and Hyvä stores
Are your mobile interaction paths actually covered?
We build real touch gesture tests for your image galleries, filter drawers, and mega-menus, surface ghost-click bugs, and make sure hover-dependent UI stays usable on touch devices too.
Touch test audit
Check existing click-based tests for real touch coverage
Gesture test setup
Build swipe, tap, and long-press helpers for Cypress and Playwright
Hover-fallback fixes
Move mega-menus and tooltips to tap-friendly interaction on touch devices
10. Summary
Automating touch interaction testing mainly means leaving the convenience of click simulation behind and reconstructing real event chains. TouchEvents with touches, targetTouches, and changedTouches trigger a different code path than synthetic MouseEvents, and only that sequence reliably surfaces ghost-click bugs, broken touch-action configurations, and hover menus that are genuinely unreachable. Playwright's touchscreen API and manually assembled touchstart/touchmove/touchend sequences in Cypress both provide the technical foundation for this.
Swipeable galleries, off-canvas filter drawers, and mega-menus are the three UI patterns where the difference shows up most clearly: all three appear to pass a click-based test without issue, yet fail for real mobile users when the underlying touch logic is never exercised. Consistently using @media (hover: hover) to separate desktop and touch behavior, and testing both paths separately, closes exactly the gap that pure click tests systematically miss.
Automating Touch Interaction Testing - The Essentials at a Glance
Real touch events, not clicks
Dispatch touchstart/touchmove/touchend instead of click() to test the actual mobile code path.
Surface ghost clicks
Send the full touch sequence and count triggered actions to detect double triggers.
Multi-step swipes
Multiple intermediate touchmove positions instead of a single jump, for realistic velocity detection.
Test the hover fallback
Use @media (hover: hover) in the UI code, and verify the tap-toggle path and the hover path in separate test projects.