18 KiB
Drag and Drop Testing
Table of Contents
- Kanban Board (Cross-Column Movement)
- Sortable Lists (Reordering)
- Native HTML5 Drag and Drop
- File Drop Zone
- Canvas Coordinate-Based Dragging
- Custom Drag Preview
- Variations
- Tips
When to use: Testing drag-and-drop interactions — sortable lists, kanban boards, file drop zones, or repositionable elements.
Kanban Board (Cross-Column Movement)
import { test, expect } from '@playwright/test';
test('moves card between columns', async ({ page }) => {
await page.goto('/board');
const backlog = page.locator('[data-column="backlog"]');
const active = page.locator('[data-column="active"]');
const ticket = backlog.getByText('Update API docs');
await expect(ticket).toBeVisible();
const backlogCountBefore = await backlog.getByRole('article').count();
const activeCountBefore = await active.getByRole('article').count();
await ticket.dragTo(active);
await expect(active.getByText('Update API docs')).toBeVisible();
await expect(backlog.getByText('Update API docs')).not.toBeVisible();
await expect(backlog.getByRole('article')).toHaveCount(backlogCountBefore - 1);
await expect(active.getByRole('article')).toHaveCount(activeCountBefore + 1);
});
test('progresses card through workflow stages', async ({ page }) => {
await page.goto('/board');
const cols = {
backlog: page.locator('[data-column="backlog"]'),
active: page.locator('[data-column="active"]'),
review: page.locator('[data-column="review"]'),
complete: page.locator('[data-column="complete"]'),
};
await cols.backlog.getByText('Update API docs').dragTo(cols.active);
await expect(cols.active.getByText('Update API docs')).toBeVisible();
await cols.active.getByText('Update API docs').dragTo(cols.review);
await expect(cols.review.getByText('Update API docs')).toBeVisible();
await cols.review.getByText('Update API docs').dragTo(cols.complete);
await expect(cols.complete.getByText('Update API docs')).toBeVisible();
await expect(cols.backlog.getByText('Update API docs')).not.toBeVisible();
await expect(cols.active.getByText('Update API docs')).not.toBeVisible();
await expect(cols.review.getByText('Update API docs')).not.toBeVisible();
});
test('reorders cards within same column', async ({ page }) => {
await page.goto('/board');
const backlog = page.locator('[data-column="backlog"]');
const itemX = backlog.getByRole('article').filter({ hasText: 'Item X' });
const itemZ = backlog.getByRole('article').filter({ hasText: 'Item Z' });
await itemZ.dragTo(itemX);
const cards = await backlog.getByRole('article').allTextContents();
expect(cards.indexOf('Item Z')).toBeLessThan(cards.indexOf('Item X'));
});
test('verifies drag persists via API', async ({ page }) => {
await page.goto('/board');
const backlog = page.locator('[data-column="backlog"]');
const active = page.locator('[data-column="active"]');
const responsePromise = page.waitForResponse(
(r) => r.url().includes('/api/tickets') && r.request().method() === 'PATCH'
);
await backlog.getByText('Update API docs').dragTo(active);
const response = await responsePromise;
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.column).toBe('active');
await page.reload();
await expect(active.getByText('Update API docs')).toBeVisible();
});
Sortable Lists (Reordering)
import { test, expect } from '@playwright/test';
test('reorders list items', async ({ page }) => {
await page.goto('/priorities');
const list = page.getByRole('list', { name: 'Priority list' });
const initial = await list.getByRole('listitem').allTextContents();
expect(initial[0]).toContain('Priority A');
expect(initial[1]).toContain('Priority B');
expect(initial[2]).toContain('Priority C');
const priorityC = list.getByRole('listitem').filter({ hasText: 'Priority C' });
const priorityA = list.getByRole('listitem').filter({ hasText: 'Priority A' });
await priorityC.dragTo(priorityA);
const reordered = await list.getByRole('listitem').allTextContents();
expect(reordered[0]).toContain('Priority C');
expect(reordered[1]).toContain('Priority A');
expect(reordered[2]).toContain('Priority B');
});
test('reorders via drag handle', async ({ page }) => {
await page.goto('/priorities');
const list = page.getByRole('list', { name: 'Priority list' });
const handle = list
.getByRole('listitem')
.filter({ hasText: 'Priority C' })
.getByRole('button', { name: /drag|reorder|grip/i });
const target = list.getByRole('listitem').filter({ hasText: 'Priority A' });
await handle.dragTo(target);
const items = await list.getByRole('listitem').allTextContents();
expect(items[0]).toContain('Priority C');
});
test('reorder persists after reload', async ({ page }) => {
await page.goto('/priorities');
const list = page.getByRole('list', { name: 'Priority list' });
const priorityC = list.getByRole('listitem').filter({ hasText: 'Priority C' });
const priorityA = list.getByRole('listitem').filter({ hasText: 'Priority A' });
await priorityC.dragTo(priorityA);
await page.waitForResponse((response) =>
response.url().includes('/api/priorities/reorder') && response.status() === 200
);
await page.reload();
const items = await list.getByRole('listitem').allTextContents();
expect(items[0]).toContain('Priority C');
expect(items[1]).toContain('Priority A');
expect(items[2]).toContain('Priority B');
});
Incremental Mouse Movement for Custom Libraries
Some drag libraries (react-beautiful-dnd, dnd-kit) require incremental mouse movements:
test('reorders with incremental mouse movements', async ({ page }) => {
await page.goto('/priorities');
const list = page.getByRole('list', { name: 'Priority list' });
const source = list.getByRole('listitem').filter({ hasText: 'Priority C' });
const target = list.getByRole('listitem').filter({ hasText: 'Priority A' });
const sourceBox = await source.boundingBox();
const targetBox = await target.boundingBox();
await source.hover();
await page.mouse.down();
const steps = 10;
for (let i = 1; i <= steps; i++) {
await page.mouse.move(
sourceBox!.x + sourceBox!.width / 2,
sourceBox!.y + (targetBox!.y - sourceBox!.y) * (i / steps),
{ steps: 1 }
);
}
await page.mouse.up();
const items = await list.getByRole('listitem').allTextContents();
expect(items[0]).toContain('Priority C');
});
Native HTML5 Drag and Drop
import { test, expect } from '@playwright/test';
test('drags item to drop zone', async ({ page }) => {
await page.goto('/drag-example');
const source = page.getByText('Movable Element');
const dropArea = page.locator('#target-zone');
await expect(source).toBeVisible();
await expect(dropArea).not.toContainText('Movable Element');
await source.dragTo(dropArea);
await expect(dropArea).toContainText('Movable Element');
});
test('drags between zones', async ({ page }) => {
await page.goto('/drag-example');
const item = page.locator('[data-testid="element-1"]');
const areaA = page.locator('[data-testid="area-a"]');
const areaB = page.locator('[data-testid="area-b"]');
await expect(areaA).toContainText('Element 1');
await item.dragTo(areaB);
await expect(areaB).toContainText('Element 1');
await expect(areaA).not.toContainText('Element 1');
await areaB.getByText('Element 1').dragTo(areaA);
await expect(areaA).toContainText('Element 1');
await expect(areaB).not.toContainText('Element 1');
});
test('verifies drag visual feedback', async ({ page }) => {
await page.goto('/drag-example');
const source = page.getByText('Movable Element');
const dropArea = page.locator('#target-zone');
await source.hover();
await page.mouse.down();
const dropBox = await dropArea.boundingBox();
await page.mouse.move(dropBox!.x + dropBox!.width / 2, dropBox!.y + dropBox!.height / 2);
await expect(dropArea).toHaveClass(/drag-over|highlight/);
await page.mouse.up();
await expect(dropArea).not.toHaveClass(/drag-over|highlight/);
await expect(dropArea).toContainText('Movable Element');
});
File Drop Zone
import { test, expect } from '@playwright/test';
import path from 'path';
test('uploads file via drop zone', async ({ page }) => {
await page.goto('/upload');
const dropZone = page.locator('[data-testid="file-drop-zone"]');
await expect(dropZone).toContainText('Drag files here');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/report.pdf'));
await expect(page.getByText('report.pdf')).toBeVisible();
await expect(page.getByText(/\d+ KB/)).toBeVisible();
});
test('simulates drag-over visual feedback', async ({ page }) => {
await page.goto('/upload');
const dropZone = page.locator('[data-testid="file-drop-zone"]');
await dropZone.dispatchEvent('dragenter', {
dataTransfer: { types: ['Files'] },
});
await expect(dropZone).toHaveClass(/drag-active|drop-highlight/);
await expect(dropZone).toContainText(/drop.*here|release.*upload/i);
await dropZone.dispatchEvent('dragleave');
await expect(dropZone).not.toHaveClass(/drag-active|drop-highlight/);
});
test('rejects invalid file types', async ({ page }) => {
await page.goto('/upload');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'script.exe',
mimeType: 'application/x-msdownload',
buffer: Buffer.from('fake-content'),
});
await expect(page.getByRole('alert')).toContainText(/not allowed|invalid file type/i);
await expect(page.getByText('script.exe')).not.toBeVisible();
});
Canvas Coordinate-Based Dragging
import { test, expect } from '@playwright/test';
test('drags element to specific coordinates', async ({ page }) => {
await page.goto('/design-tool');
const canvas = page.locator('#editor-canvas');
const shape = page.locator('[data-testid="shape-1"]');
const canvasBox = await canvas.boundingBox();
const targetX = canvasBox!.x + 300;
const targetY = canvasBox!.y + 200;
await shape.hover();
await page.mouse.down();
await page.mouse.move(targetX, targetY, { steps: 10 });
await page.mouse.up();
const newBox = await shape.boundingBox();
expect(newBox!.x).toBeCloseTo(targetX - newBox!.width / 2, -1);
expect(newBox!.y).toBeCloseTo(targetY - newBox!.height / 2, -1);
});
test('snaps element to grid', async ({ page }) => {
await page.goto('/design-tool');
const shape = page.locator('[data-testid="shape-1"]');
const canvas = page.locator('#editor-canvas');
const canvasBox = await canvas.boundingBox();
await shape.hover();
await page.mouse.down();
await page.mouse.move(canvasBox!.x + 147, canvasBox!.y + 83, { steps: 10 });
await page.mouse.up();
const snappedBox = await shape.boundingBox();
expect(snappedBox!.x % 20).toBeCloseTo(0, 0);
expect(snappedBox!.y % 20).toBeCloseTo(0, 0);
});
test('constrains drag within boundaries', async ({ page }) => {
await page.goto('/design-tool');
const shape = page.locator('[data-testid="bounded-shape"]');
const container = page.locator('#bounds-container');
const containerBox = await container.boundingBox();
await shape.hover();
await page.mouse.down();
await page.mouse.move(containerBox!.x + containerBox!.width + 500, containerBox!.y - 200, {
steps: 10,
});
await page.mouse.up();
const shapeBox = await shape.boundingBox();
expect(shapeBox!.x).toBeGreaterThanOrEqual(containerBox!.x);
expect(shapeBox!.y).toBeGreaterThanOrEqual(containerBox!.y);
expect(shapeBox!.x + shapeBox!.width).toBeLessThanOrEqual(
containerBox!.x + containerBox!.width
);
expect(shapeBox!.y + shapeBox!.height).toBeLessThanOrEqual(
containerBox!.y + containerBox!.height
);
});
test('resizes element via handle', async ({ page }) => {
await page.goto('/design-tool');
const shape = page.locator('[data-testid="shape-1"]');
await shape.click();
const resizeHandle = shape.locator('.resize-handle-se');
const handleBox = await resizeHandle.boundingBox();
const initialBox = await shape.boundingBox();
await resizeHandle.hover();
await page.mouse.down();
await page.mouse.move(handleBox!.x + 100, handleBox!.y + 80, { steps: 5 });
await page.mouse.up();
const newBox = await shape.boundingBox();
expect(newBox!.width).toBeCloseTo(initialBox!.width + 100, -1);
expect(newBox!.height).toBeCloseTo(initialBox!.height + 80, -1);
});
Custom Drag Preview
import { test, expect } from '@playwright/test';
test('shows custom drag preview', async ({ page }) => {
await page.goto('/board');
const card = page.locator('[data-testid="ticket-1"]');
const targetCol = page.locator('[data-column="active"]');
const cardBox = await card.boundingBox();
const targetBox = await targetCol.boundingBox();
await card.hover();
await page.mouse.down();
const midX = (cardBox!.x + targetBox!.x) / 2;
const midY = (cardBox!.y + targetBox!.y) / 2;
await page.mouse.move(midX, midY, { steps: 5 });
await expect(page.locator('.drag-preview')).toBeVisible();
await expect(card).toHaveClass(/dragging|placeholder/);
await page.mouse.move(
targetBox!.x + targetBox!.width / 2,
targetBox!.y + targetBox!.height / 2,
{ steps: 5 }
);
await page.mouse.up();
await expect(page.locator('.drag-preview')).not.toBeVisible();
});
test('multi-select drag shows item count', async ({ page }) => {
await page.goto('/board');
await page.locator('[data-testid="ticket-1"]').click();
await page.locator('[data-testid="ticket-2"]').click({ modifiers: ['Shift'] });
await page.locator('[data-testid="ticket-3"]').click({ modifiers: ['Shift'] });
const card = page.locator('[data-testid="ticket-1"]');
const targetCol = page.locator('[data-column="complete"]');
await card.hover();
await page.mouse.down();
const targetBox = await targetCol.boundingBox();
await page.mouse.move(targetBox!.x + 50, targetBox!.y + 50, { steps: 5 });
await expect(page.locator('.drag-preview')).toContainText('3 items');
await page.mouse.up();
await expect(targetCol.locator('[data-testid="ticket-1"]')).toBeVisible();
await expect(targetCol.locator('[data-testid="ticket-2"]')).toBeVisible();
await expect(targetCol.locator('[data-testid="ticket-3"]')).toBeVisible();
});
Variations
Keyboard-Based Reordering
test('reorders using keyboard', async ({ page }) => {
await page.goto('/priorities');
const list = page.getByRole('list', { name: 'Priority list' });
const priorityC = list.getByRole('listitem').filter({ hasText: 'Priority C' });
await priorityC.focus();
await page.keyboard.press('Space');
await page.keyboard.press('ArrowUp');
await page.keyboard.press('ArrowUp');
await page.keyboard.press('Space');
const items = await list.getByRole('listitem').allTextContents();
expect(items[0]).toContain('Priority C');
});
Cross-Frame Dragging
test('drags between main page and iframe', async ({ page }) => {
await page.goto('/composer');
const sourceWidget = page.getByText('Component A');
const iframe = page.frameLocator('#preview-frame');
const iframeElement = page.locator('#preview-frame');
const sourceBox = await sourceWidget.boundingBox();
const iframeBox = await iframeElement.boundingBox();
const targetX = iframeBox!.x + 100;
const targetY = iframeBox!.y + 100;
await sourceWidget.hover();
await page.mouse.down();
await page.mouse.move(targetX, targetY, { steps: 20 });
await page.mouse.up();
await expect(iframe.getByText('Component A')).toBeVisible();
});
Touch-Based Drag on Mobile
test('drags via touch events', async ({ page }) => {
await page.goto('/priorities');
const list = page.getByRole('list', { name: 'Priority list' });
const source = list.getByRole('listitem').filter({ hasText: 'Priority C' });
const target = list.getByRole('listitem').filter({ hasText: 'Priority A' });
const sourceBox = await source.boundingBox();
const targetBox = await target.boundingBox();
await source.dispatchEvent('touchstart', {
touches: [{ clientX: sourceBox!.x + 10, clientY: sourceBox!.y + 10 }],
});
for (let i = 1; i <= 5; i++) {
const y = sourceBox!.y + (targetBox!.y - sourceBox!.y) * (i / 5);
await source.dispatchEvent('touchmove', {
touches: [{ clientX: sourceBox!.x + 10, clientY: y }],
});
}
await source.dispatchEvent('touchend');
const items = await list.getByRole('listitem').allTextContents();
expect(items[0]).toContain('Priority C');
});
Tips
-
Start with
dragTo(), fall back to manual mouse events. Playwright'sdragTo()handles most HTML5 drag-and-drop. Usepage.mouse.down()/move()/up()only for custom libraries (react-beautiful-dnd, dnd-kit, SortableJS) that need specific event sequences. -
Add intermediate mouse steps for drag libraries. Libraries like
react-beautiful-dndrequire multiplemousemoveevents. Use{ steps: 10 }or a manual loop — a single jump often fails silently. -
Assert final state, not just the drop event. Verify DOM reflects the change — item order, column contents, position coordinates. Visual feedback during drag is secondary to the persisted state.
-
Use
boundingBox()for coordinate assertions. For canvas editors or position-sensitive drops, capture bounding box after the operation and compare withtoBeCloseTo()for tolerance. -
Test undo after drag operations. If your app supports Ctrl+Z, verify the drag is reversible — this catches state management bugs.