Drag and drop is the interaction most likely to make an experienced automation engineer sigh. The test runs, nothing throws, the report is green, and the item did not move.
That is worse than a failure. A test that cannot fail is not a test, and this is the classic place people ship one by accident.
Before the code, you need one piece of background, because it decides everything else.
Why drag and drop is hard
There are two completely different ways a web page implements dragging, and they need different test code.
HTML5 drag and drop. The element has draggable="true", and the browser fires dragstart, dragover, drop and dragend. These are special. In most browsers they are only fired properly by a real user gesture, and synthetic mouse events do not reliably produce them. This is the one that silently does nothing.
Mouse-based dragging. The component listens to mousedown, mousemove and mouseup and moves the element itself. Most modern libraries work this way. This one automates cleanly, because you are sending exactly the events it listens for.
Find out which you have before writing anything. Inspect the draggable element:
draggable="true"in the markup means HTML5.- No
draggableattribute usually means mouse-based.
Five seconds in devtools saves an afternoon.
Playwright
The built-in method is locator.dragTo():
await page.locator('#item-to-be-dragged').dragTo(page.locator('#item-to-drop-at'));
The documentation describes exactly what it does: hover the source, press the left mouse button, move to the target, release. That covers mouse-based dragging and many HTML5 implementations.
When it does not work, drive the mouse yourself:
await page.locator('#item-to-be-dragged').hover();
await page.mouse.down();
await page.locator('#item-to-drop-at').hover();
await page.mouse.up();
There is a detail here worth knowing, because it explains most "it just does nothing" reports. If the page relies on the dragover event, at least two mouse moves are required in all browsers to trigger it reliably. So hover the target twice:
await page.locator('#item-to-be-dragged').hover();
await page.mouse.down();
await page.locator('#item-to-drop-at').hover();
await page.locator('#item-to-drop-at').hover(); // second move, for dragover
await page.mouse.up();
That one extra line fixes a surprising number of cases.
Cypress
Cypress has no built-in drag command. You have two options.
For mouse-based dragging, send the events yourself:
cy.get('#item-to-be-dragged').trigger('mousedown', { button: 0 });
cy.get('#item-to-drop-at')
.trigger('mousemove')
.trigger('mousemove') // second move, same reason as above
.trigger('mouseup', { force: true });
For HTML5 dragging, you have to build a DataTransfer object and dispatch the real drag events:
const dataTransfer = new DataTransfer();
cy.get('#item-to-be-dragged').trigger('dragstart', { dataTransfer });
cy.get('#item-to-drop-at')
.trigger('dragover', { dataTransfer })
.trigger('drop', { dataTransfer });
cy.get('#item-to-be-dragged').trigger('dragend', { dataTransfer });
The dataTransfer object must be the same instance across all four calls. That is how the browser carries the payload from the drag to the drop, and passing a fresh object each time is the most common reason this silently fails.
There is a community plugin that wraps this. It works, and it is worth understanding the four events above first, because when the plugin does not work you will need to know what it was doing.
Selenium
Selenium has a built-in method on the Actions class:
Actions actions = new Actions(driver);
WebElement source = driver.findElement(By.id("item-to-be-dragged"));
WebElement target = driver.findElement(By.id("item-to-drop-at"));
actions.dragAndDrop(source, target).perform();
It often does nothing on HTML5 drag and drop. This is the single most reported drag and drop problem in Selenium, and it is not a bug in your code. Selenium sends synthetic mouse events, and HTML5 drag events frequently do not fire in response.
The step-by-step version works more often, because it produces more intermediate movement:
actions.clickAndHold(source)
.moveToElement(target)
.moveToElement(target) // second move
.release()
.perform();
Adding a pause helps too, especially with animated lists:
actions.clickAndHold(source)
.moveByOffset(10, 0)
.pause(Duration.ofMillis(200))
.moveToElement(target)
.pause(Duration.ofMillis(200))
.release()
.perform();
If none of that works, the honest answer is that this is one of the places where Selenium's age shows, and the workaround is usually to dispatch the events with JavaScript directly.
The special case worth knowing: file drop zones
A "drag your file here" area is a different problem, and it has a much better answer.
If the drop zone is backed by an <input type="file">, do not simulate a drag at all. Set the file on the input, even when the input is hidden. That bypasses the synthetic drag events entirely and is far more reliable:
// Playwright
await page.locator('input[type="file"]').setInputFiles('invoice.pdf');
// Cypress
cy.get('input[type="file"]').selectFile('invoice.pdf', { force: true });
force: true is appropriate here, because the input is deliberately hidden behind a styled drop zone.
Only synthesise a real file drop when there is no input behind the zone. Testing file uploads covers this in full.
What to assert, and why it matters more than the drag
This is the part that decides whether your test is real.
Getting the drag to run is not the same as proving the drop worked. A test that drags and then asserts nothing will pass forever, including after someone breaks the feature.
Weak:
cy.get('#item').trigger('dragstart', { dataTransfer });
cy.get('#target').trigger('drop', { dataTransfer });
// no assertion. This test can never fail.
Better, assert the outcome the user cares about:
// The item is now inside the target column
await expect(page.locator('#done-column').getByText('Write the report')).toBeVisible();
// The order changed
await expect(page.locator('[data-testid="task"]')).toHaveText([
'Write the report',
'Send the invoice',
]);
// And it persisted
await page.reload();
await expect(page.locator('#done-column').getByText('Write the report')).toBeVisible();
That last one is the assertion people skip. Plenty of drag and drop bugs are not visual at all. The card moves on screen and the new position is never saved. Reload, and it is back where it started.
I have seen a board feature ship with exactly that bug, past a suite of drag tests that all passed. Every one of them checked the position on screen and none reloaded the page.
Quick reference
| Playwright | Cypress | Selenium | |
|---|---|---|---|
| Built-in method | dragTo() |
none | Actions.dragAndDrop() |
| Mouse-based drag | dragTo() |
trigger() mouse events |
clickAndHold chain |
| HTML5 drag | dragTo(), else manual |
DataTransfer + events |
Often fails, use JS |
| File drop zone | setInputFiles() |
selectFile() |
sendKeys on the input |
| Needs two moves | Often | Often | Often |
Common mistakes
- Not checking which kind of drag it is. HTML5 and mouse-based need different code.
- One mouse move. Most implementations need at least two before the drop registers.
- A fresh
DataTransferper event in Cypress. It has to be the same object throughout. - Simulating a drag onto a file drop zone. Set the file input instead.
- Asserting nothing. The most common failure mode is a green test that proves nothing.
- Never reloading. Position on screen is not the same as position saved.
Frequently asked questions
Why does my Selenium drag and drop do nothing?
Almost certainly HTML5 drag and drop. Synthetic mouse events often do not trigger the native drag events. Try the clickAndHold chain with two moves, then fall back to dispatching the events in JavaScript.
Why does my drag work when I watch it and fail headless? Usually timing, or the element being outside the viewport. Add the second mouse move, and set an explicit window size so the target is on screen.
Do I need a plugin for Cypress? No. The plugin wraps the four HTML5 events shown above. Understanding them first means you can debug it when it does not work.
How do I test dragging a file into a drop zone?
If there is an input type="file" behind it, set the file on the input directly. That is more reliable than any simulated drag.
Should I test drag and drop at all? Test the outcome, always. Whether you test the gesture depends on how central it is. For a kanban board it is the product. For a nice-to-have reorder, asserting that the API saves the new order may be enough.
Remember this
Find out which kind of drag you have before writing a line. HTML5 needs drag events with a shared DataTransfer. Mouse-based needs mouse events, usually with two moves.
Playwright has the best built-in support. Cypress needs you to send the events. Selenium's built-in method frequently does nothing on HTML5.
And the assertion matters more than the drag. Check the outcome, then reload the page and check it again. A drag test that never reloads is the easiest way to ship a test that cannot fail.
That is the whole idea behind the practice problems: we run your test against the working app, then against one where we broke the behaviour on purpose, so a test that proves nothing gets caught.