Tap, Swipe und Pinch-Zoom zuverlässig prüfen
Mobile Shops leben von Touch-Gesten: Wischen durch Bildergalerien, Antippen von Filterschaltern, Ziehen von Off-Canvas-Menüs. Wer diese Interaktionen nur mit simulierten Mausklicks testet, übersieht Ghost-Clicks, blockierte Scrollrichtungen und Hover-Menüs, die auf einem Touchscreen nie erreichbar sind. Dieser Artikel zeigt, wie echte Touch-Events in Cypress und Playwright zuverlässig simuliert werden.
Inhaltsverzeichnis
- 1. Warum Touch-Interaktionen eigene Teststrategien brauchen
- 2. Touch Events, Pointer Events und MouseEvents im Vergleich
- 3. Der Klick-Delay und das Ghost-Click-Problem
- 4. CSS touch-action und Scroll-vs-Gesture-Konflikte
- 5. Touch-Gesten in Playwright automatisieren
- 6. Touch-Gesten in Cypress automatisieren
- 7. Swipeable Bildergalerien und Karussells testen
- 8. Mobile Filter-Drawer und Off-Canvas-Panels testen
- 9. Klick-Simulation vs. echte Touch-Event-Simulation
- 10. Zusammenfassung
- 11. FAQ
1. Warum Touch-Interaktionen eigene Teststrategien brauchen
Die meisten E2E-Frameworks simulieren standardmäßig einen Mausklick, selbst wenn der Test einen mobilen Viewport emuliert. Ein cy.click() oder page.click() dispatcht intern eine Kette von MouseEvents, unabhängig davon, ob viewport('iphone-x') gesetzt ist. Das Problem: Der überwiegende Teil der Besucher eines Magento- oder Hyvä-Shops navigiert real über einen Touchscreen, nicht über eine Maus. Ein Test, der nur MouseEvents auslöst, prüft also ein Interaktionsmodell, das die eigentliche Zielgruppe gar nicht verwendet.
In der Praxis führt das zu einer trügerischen grünen Testsuite: Ein Dropdown-Menü, das per CSS :hover geöffnet wird, besteht jeden Klick-basierten Test anstandslos, weil Selenium, Cypress und Playwright per Default auch einen Hover-Zustand simulieren können. Auf einem echten Smartphone gibt es diesen Hover-Zustand aber nicht, das Menü bleibt für reale Nutzer unerreichbar. Genauso verhält es sich mit Swipe-Karussells und Off-Canvas-Filtern, deren JavaScript-Logik ausschließlich auf touchstart, touchmove und touchend lauscht. Wer solche Muster testen will, muss die Ebene der synthetischen Events verlassen und echte Touch-Sequenzen nachbilden.
2. Touch Events, Pointer Events und MouseEvents im Vergleich
Ein TouchEvent trägt drei Listen mit Kontaktpunkten: touches (alle aktuellen Kontakte auf dem Bildschirm), targetTouches (Kontakte auf dem aktuellen Zielelement) und changedTouches (die Kontakte, die das aktuelle Event ausgelöst haben). Jeder Eintrag besitzt eigene clientX/clientY-Koordinaten und eine identifier-ID, über die sich mehrere gleichzeitige Finger auseinanderhalten lassen, etwa für Pinch-Zoom-Gesten mit zwei Kontaktpunkten.
Das PointerEvent-Interface vereinheitlicht Maus, Touch und Stift unter einer gemeinsamen API und trägt zusätzlich ein pointerType-Attribut (mouse, touch oder pen). Moderne Browser feuern bei einer echten Berührung eine feste Reihenfolge: pointerdown, gefolgt von touchstart, und erst danach, zur Abwärtskompatibilität mit altem Code, synthetische mousedown/click-Events. Genau diese Kompatibilitätsschicht ist der Grund, warum ein simpler click()-Aufruf in Tests häufig funktioniert, obwohl er den touchbasierten Code-Pfad der Anwendung nie durchläuft: Der Test löst direkt das synthetische Ende der Kette aus und überspringt touchstart/touchmove vollständig.
3. Der Klick-Delay und das Ghost-Click-Problem
Mobile Browser warteten historisch rund 300 Millisekunden nach touchend, bevor sie das click-Event feuerten, um einen möglichen Doppel-Tap zum Zoomen von einem einzelnen Tap zu unterscheiden. Mit <meta name="viewport" content="width=device-width"> entfällt dieser Delay in modernen Browsern zwar meist, das Grundproblem der Event-Verdopplung bleibt aber bestehen: Auf manchen Geräten und in Hybrid-WebViews feuert eine einzelne Berührung sowohl einen echten Touch-Handler als auch ein nachgelagertes synthetisches click, wodurch Aktionen wie „In den Warenkorb" versehentlich zweimal ausgelöst werden.
Dieses als Ghost-Click bekannte Phänomen entsteht typischerweise, wenn ein touchend-Handler bereits eine Aktion auslöst und preventDefault() vergessen wird, sodass der Browser zusätzlich das nachfolgende click feuert. Ein Test, der ausschließlich click() aufruft, kann diesen Bug grundsätzlich nicht aufdecken, weil er nie die vollständige Touch-Sequenz mit touchstart und touchend dispatcht, aus der der Doppel-Trigger überhaupt erst entsteht. Nur ein Test, der echte Touch-Events sendet und danach die Anzahl der ausgelösten Netzwerk-Requests oder Store-Updates zählt, deckt Ghost-Clicks zuverlässig auf.
4. CSS touch-action und Scroll-vs-Gesture-Konflikte
Die CSS-Eigenschaft touch-action legt fest, welche nativen Gesten der Browser für ein Element noch selbst verarbeitet und welche er an JavaScript abgibt. touch-action: pan-y erlaubt weiterhin vertikales Scrollen der Seite, deaktiviert aber horizontales Panning, sodass ein Karussell die horizontale Wischgeste per touchmove-Handler selbst auswerten kann, ohne mit dem nativen Scrollverhalten zu kollidieren. touch-action: none entzieht dem Browser die komplette Gestenverarbeitung für ein Element, etwa für ein per Drag verschiebbares Off-Canvas-Panel.
Ein klassischer Bug: Fehlt touch-action auf einem Karussell-Container, interpretiert der Browser jede horizontale Wischgeste zunächst als Versuch, die Seite zu scrollen, und das Karussell reagiert erst nach einer spürbaren Verzögerung oder gar nicht. Für Tests bedeutet das, dass ein Browser-Kontext mit aktivierter Touch-Emulation (hasTouch: true) nötig ist, weil touch-action ausschließlich bei echten oder emulierten Touch-Pointern greift. CI-Runner unterstützen das über das Chrome-DevTools-Protokoll (Input.dispatchTouchEvent), auf das sowohl Cypress-Plugins als auch Playwrights native Touch-API intern zurückgreifen.
5. Touch-Gesten in Playwright automatisieren
Playwright bringt mit page.touchscreen.tap(x, y) eine native Touch-API mit, die aber nur einzelne Taps abdeckt, keine Swipe- oder Pinch-Gesten. Voraussetzung ist ein Browser-Kontext mit hasTouch: true, was bei den mitgelieferten Geräteprofilen wie devices['iPhone 13'] bereits automatisch gesetzt ist. Für Swipe-Gesten muss die rohe Ereigniskette selbst zusammengesetzt werden, über locator.dispatchEvent() mit einem konstruierten Touch-Objekt oder direkt über das Chrome-DevTools-Protokoll für eine realistischere, mehrstufige Bewegung mit Zwischenpositionen.
Wichtig ist, dass eine simulierte Swipe-Geste aus mehreren touchmove-Schritten besteht und nicht nur aus einem einzigen Sprung von Start- zu Endposition, da viele Karussell-Implementierungen die Geschwindigkeit aus der Differenz mehrerer Zwischenpunkte berechnen, um zwischen einem versehentlichen Wackeln und einer bewussten Wischgeste zu unterscheiden. Das folgende Beispiel zeigt Hilfsfunktionen für Tap, Swipe und Long-Press, die auf genau diesem Prinzip aufbauen.
// 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. Touch-Gesten in Cypress automatisieren
Cypress bringt anders als Playwright keine native Touch-API mit. Mit dem Community-Plugin cypress-real-events lassen sich echte Betriebssystem-Events über das Chrome-DevTools-Protokoll auslösen, inklusive realTouch und realSwipe. Steht das Plugin nicht zur Verfügung oder soll die Simulation ohne zusätzliche Abhängigkeit auskommen, lässt sich dieselbe Ereigniskette manuell über cy.wrap(element).trigger('touchstart', { touches: [...] }) nachbauen, wobei trigger() ein rohes DOM-Event mit beliebigen Eigenschaften dispatcht.
Ein sauberes Custom Command kapselt diese Logik einmalig und macht sie im gesamten Test-Suite wiederverwendbar, ähnlich wie bei bestehenden Login- oder Fixture-Commands. Wichtig ist, den mobilen Viewport vorab mit cy.viewport('iphone-x') zu setzen, da manche Anwendungen ihre Touch-Handler erst ab einer bestimmten Breakpoint-Breite registrieren und ein Test im Desktop-Viewport die Handler sonst nie anhängt, unabhängig davon, welche Events anschließend gesendet werden.
// 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. Swipeable Bildergalerien und Karussells testen
Für ein Produktbild-Karussell reicht es nicht, nur zu prüfen, dass ein Klick auf einen Pfeil-Button den Slide wechselt. Der eigentliche mobile Bedienweg läuft über eine horizontale Wischgeste, deren Handler ausschließlich an touchstart/touchmove/touchend gebunden ist. Ein vollständiger Test simuliert die Geste mit mehreren Zwischenschritten, prüft anschließend, ob sich der aria-current- oder Index-Zustand der Galerie korrekt erhöht, und kontrolliert die CSS-transform-Eigenschaft des Slide-Containers auf den erwarteten Versatz.
Zusätzlich lohnt sich ein Edge-Case-Test für das Verhalten am Anfang und Ende der Galerie: Viele Implementierungen zeigen dort einen Bounce-Effekt oder verhindern das Weiterwischen komplett, statt zum ersten Bild zurückzuspringen. Ebenso wichtig ist ein Test für zu kurze oder zu langsame Wischgesten, die unterhalb eines Geschwindigkeits- oder Distanz-Schwellenwerts liegen und deshalb keinen Slide-Wechsel auslösen dürfen, sondern zur Ausgangsposition zurückschnappen sollten. Ein reiner Klick-Test kann all diese Zustände nicht abdecken, weil er nie eine kontinuierliche Bewegung mit variabler Geschwindigkeit erzeugt.
// 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. Mobile Filter-Drawer und Off-Canvas-Panels testen
Ein mobiler Filter-Drawer wird meist über einen Tap auf einen Button geöffnet, was auch mit einem klassischen Klick-Test funktioniert, da der öffnende Button in der Regel einen normalen click-Handler besitzt. Interessant wird es beim Schließen: Viele Implementierungen erlauben zusätzlich ein Zuwischen des Panels per Drag-Geste sowie ein Antippen des halbtransparenten Backdrops außerhalb des Panels, wobei Letzteres oft an touchstart statt an click gebunden ist, um Verzögerungen durch den historischen Klick-Delay zu vermeiden.
Ein vollständiger Test prüft drei Dinge: erstens, dass der Body-Scroll gesperrt wird, sobald das Panel offen ist, meist über eine overflow: hidden-Klasse am body-Element. Zweitens, dass ein Tap auf den Backdrop das Panel wieder schließt und aria-hidden="true" korrekt gesetzt wird. Drittens, dass eine Wisch-nach-links-Geste auf dem Panel selbst denselben Effekt hat wie ein Tap auf den Schließen-Button, sofern die Anwendung diese Geste unterstützt. Gerade der dritte Punkt lässt sich ausschließlich mit echten Touch-Events reproduzieren, ein simulierter Klick auf den Panel-Rand hätte keinerlei Wirkung.
// 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. Klick-Simulation vs. echte Touch-Event-Simulation
Die Wahl zwischen einer einfachen Klick-Simulation und einer echten Touch-Event-Sequenz ist keine Frage des Testaufwands, sondern entscheidet direkt darüber, welche Bugs überhaupt sichtbar werden. Die folgende Übersicht fasst die wichtigsten Unterschiede zusammen.
| Kriterium | Klick-Simulation (click()) | Echte Touch-Event-Simulation |
|---|---|---|
| Feuert touchstart/touchend | Nein | Ja, vollständige Sequenz |
| Deckt Ghost-Click-Bugs auf | Nein, Bug bleibt unsichtbar | Ja, Doppel-Trigger messbar |
| Swipe-/Drag-Gesten | Nicht abbildbar | Mit touchmove-Schritten simulierbar |
| Hover-Fallback-Verhalten | Besteht Test trotz fehlendem Hover | Deckt fehlenden Tap-Fallback auf |
| touch-action-Konflikte | Nicht prüfbar | Scroll-vs-Gesture-Konflikt sichtbar |
| Realitätsnähe für mobile Nutzer | Gering | Hoch |
Der letzte Punkt der Tabelle betrifft eines der hartnäckigsten Testprobleme: hover-abhängige UI. Dropdown-Menüs, Tooltips und Mega-Menüs, die per CSS :hover geöffnet werden, haben auf einem Touchscreen schlicht keinen Hover-Zustand, den sie öffnen könnten. Manche mobilen Browser interpretieren den ersten Tap auf ein solches Element als Hover-Simulation und den zweiten Tap erst als eigentlichen Klick, ein Verhalten, das sich zwischen iOS Safari, Chrome für Android und verschiedenen WebViews unterscheidet und sich nicht verlässlich testen oder gar als UX-Pattern empfehlen lässt. Der robuste Fix liegt selten im Test, sondern im UI-Code: Ein Mega-Menü sollte über @media (hover: hover) and (pointer: fine) zwischen echtem Hover auf Desktop-Geräten und einem expliziten Tap-Toggle mit aria-expanded auf Touch-Geräten unterscheiden. Der Test prüft dann beide Pfade getrennt, mit zwei Playwright-Projekten für unterschiedliche 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-Absicherung und E2E-Testautomatisierung für Magento- und Hyvä-Shops
Mobile Bedienpfade wirklich abgesichert?
Wir bauen echte Touch-Gesten-Tests für eure Bildergalerien, Filter-Drawer und Mega-Menüs, decken Ghost-Click-Bugs auf und stellen sicher, dass hover-abhängige UI auch auf Touch-Geräten bedienbar bleibt.
Touch-Test-Audit
Bestehende Klick-basierte Tests auf echte Touch-Abdeckung prüfen
Gesture-Test-Setup
Swipe-, Tap- und Long-Press-Helfer für Cypress und Playwright aufbauen
Hover-Fallback-Fixes
Mega-Menüs und Tooltips für Touch-Geräte auf Tap-Bedienung umstellen
10. Zusammenfassung
Touch-Interaktionen automatisiert testen heißt vor allem, die Bequemlichkeit der Klick-Simulation zu verlassen und echte Ereignisketten nachzubilden. TouchEvents mit touches, targetTouches und changedTouches lösen einen anderen Code-Pfad aus als synthetische MouseEvents, und nur diese Sequenz deckt Ghost-Click-Bugs, fehlerhafte touch-action-Konfigurationen und tatsächlich nicht erreichbare Hover-Menüs zuverlässig auf. Playwrights touchscreen-API und manuell zusammengesetzte touchstart/touchmove/touchend-Sequenzen in Cypress liefern beide die technische Grundlage dafür.
Swipeable Galerien, Off-Canvas-Filter-Drawer und Mega-Menüs sind die drei UI-Muster, an denen sich der Unterschied am deutlichsten zeigt: Alle drei bestehen einen klick-basierten Test scheinbar problemlos, versagen aber für reale mobile Nutzer, wenn die zugrunde liegende Touch-Logik nie ausgeführt wird. Wer zusätzlich @media (hover: hover) konsequent zur Trennung von Desktop- und Touch-Verhalten nutzt und beide Pfade getrennt testet, schließt genau die Lücke, die reine Klick-Tests systematisch übersehen.
Touch-Interaktionen automatisiert testen - Das Wichtigste auf einen Blick
Echte Touch-Events statt Klicks
touchstart/touchmove/touchend statt click() dispatchen, um den echten mobilen Code-Pfad zu testen.
Ghost-Clicks aufdecken
Vollständige Touch-Sequenz senden und Anzahl ausgelöster Aktionen zählen, um Doppel-Trigger zu erkennen.
Swipe mit mehreren Schritten
Mehrere touchmove-Zwischenpositionen statt eines einzelnen Sprungs, für realistische Geschwindigkeitserkennung.
Hover-Fallback testen
@media (hover: hover) im UI-Code nutzen, Tap-Toggle-Pfad und Hover-Pfad getrennt in eigenen Testprojekten prüfen.