项目文件夹

文件
2026-07-13 21:36:47 +08:00

28 KiB

拖放操作食谱

适用场景:你需要测试拖放交互——可排序列表、看板、文件放置区域,或任何可通过拖拽重新定位的元素。


食谱一:原生 HTML5 拖放

完整示例

TypeScript

import { test, expect } from "@playwright/test"

test("将一个项目从一个容器拖到另一个容器", async ({ page }) => {
  await page.goto("/drag-demo")

  const sourceItem = page.getByText("Draggable Item")
  const dropZone = page.locator("#drop-zone")

  // 验证初始状态
  await expect(sourceItem).toBeVisible()
  await expect(dropZone).not.toContainText("Draggable Item")

  // 执行拖放操作
  await sourceItem.dragTo(dropZone)

  // 验证项目已移动到放置区域
  await expect(dropZone).toContainText("Draggable Item")
})

test("使用定位器在两个放置区域之间拖拽", async ({ page }) => {
  await page.goto("/drag-demo")

  const item = page.locator('[data-testid="item-1"]')
  const zoneA = page.locator('[data-testid="zone-a"]')
  const zoneB = page.locator('[data-testid="zone-b"]')

  // 项目起始在 A 区域
  await expect(zoneA).toContainText("Item 1")

  // 拖拽到 B 区域
  await item.dragTo(zoneB)

  // 项目现在在 B 区域,不在 A 区域
  await expect(zoneB).toContainText("Item 1")
  await expect(zoneA).not.toContainText("Item 1")

  // 拖回 A 区域
  await zoneB.getByText("Item 1").dragTo(zoneA)

  await expect(zoneA).toContainText("Item 1")
  await expect(zoneB).not.toContainText("Item 1")
})

test("验证拖拽的视觉反馈", async ({ page }) => {
  await page.goto("/drag-demo")

  const sourceItem = page.getByText("Draggable Item")
  const dropZone = page.locator("#drop-zone")

  // 手动开始拖拽以检查中间状态
  await sourceItem.hover()
  await page.mouse.down()

  // 移向放置区域
  const dropBox = await dropZone.boundingBox()
  await page.mouse.move(dropBox!.x + dropBox!.width / 2, dropBox!.y + dropBox!.height / 2)

  // 验证悬停时放置区域显示视觉指示器
  await expect(dropZone).toHaveClass(/drag-over|highlight/)

  // 完成放置
  await page.mouse.up()

  // 验证放置区域恢复为正常样式
  await expect(dropZone).not.toHaveClass(/drag-over|highlight/)
  await expect(dropZone).toContainText("Draggable Item")
})

JavaScript

const { test, expect } = require("@playwright/test")

test("将一个项目从一个容器拖到另一个容器", async ({ page }) => {
  await page.goto("/drag-demo")

  const sourceItem = page.getByText("Draggable Item")
  const dropZone = page.locator("#drop-zone")

  await expect(sourceItem).toBeVisible()
  await expect(dropZone).not.toContainText("Draggable Item")

  await sourceItem.dragTo(dropZone)

  await expect(dropZone).toContainText("Draggable Item")
})

test("在两个放置区域之间拖拽", async ({ page }) => {
  await page.goto("/drag-demo")

  const item = page.locator('[data-testid="item-1"]')
  const zoneA = page.locator('[data-testid="zone-a"]')
  const zoneB = page.locator('[data-testid="zone-b"]')

  await expect(zoneA).toContainText("Item 1")

  await item.dragTo(zoneB)

  await expect(zoneB).toContainText("Item 1")
  await expect(zoneA).not.toContainText("Item 1")
})

食谱二:可排序列表(重新排序项目)

完整示例

TypeScript

import { test, expect } from "@playwright/test"

test("对可排序列表中的项目重新排序", async ({ page }) => {
  await page.goto("/tasks")

  const list = page.getByRole("list", { name: "Task list" })

  // 验证初始顺序
  const initialItems = await list.getByRole("listitem").allTextContents()
  expect(initialItems[0]).toContain("Task A")
  expect(initialItems[1]).toContain("Task B")
  expect(initialItems[2]).toContain("Task C")

  // 将 Task C 拖到顶部(Task A 之上)
  const taskC = list.getByRole("listitem").filter({ hasText: "Task C" })
  const taskA = list.getByRole("listitem").filter({ hasText: "Task A" })

  await taskC.dragTo(taskA)

  // 验证新顺序
  const reorderedItems = await list.getByRole("listitem").allTextContents()
  expect(reorderedItems[0]).toContain("Task C")
  expect(reorderedItems[1]).toContain("Task A")
  expect(reorderedItems[2]).toContain("Task B")
})

test("使用拖拽手柄重新排序", async ({ page }) => {
  await page.goto("/tasks")

  const list = page.getByRole("list", { name: "Task list" })

  // 使用拖拽手柄(握把图标)而非整个项目
  const dragHandle = list
    .getByRole("listitem")
    .filter({ hasText: "Task C" })
    .getByRole("button", { name: /drag|reorder|grip/i })

  const targetItem = list.getByRole("listitem").filter({ hasText: "Task A" })

  await dragHandle.dragTo(targetItem)

  const reorderedItems = await list.getByRole("listitem").allTextContents()
  expect(reorderedItems[0]).toContain("Task C")
})

test("重新排序在页面刷新后仍保持不变", async ({ page }) => {
  await page.goto("/tasks")

  const list = page.getByRole("list", { name: "Task list" })

  const taskC = list.getByRole("listitem").filter({ hasText: "Task C" })
  const taskA = list.getByRole("listitem").filter({ hasText: "Task A" })

  await taskC.dragTo(taskA)

  // 等待保存 API 调用完成
  await page.waitForResponse(
    (response) => response.url().includes("/api/tasks/reorder") && response.status() === 200
  )

  // 重新加载并验证持久化
  await page.reload()

  const items = await list.getByRole("listitem").allTextContents()
  expect(items[0]).toContain("Task C")
  expect(items[1]).toContain("Task A")
  expect(items[2]).toContain("Task B")
})

test("对于需要精细鼠标移动的库,使用精确鼠标移动重新排序", async ({ page }) => {
  await page.goto("/tasks")

  const list = page.getByRole("list", { name: "Task list" })
  const sourceItem = list.getByRole("listitem").filter({ hasText: "Task C" })
  const targetItem = list.getByRole("listitem").filter({ hasText: "Task A" })

  const sourceBox = await sourceItem.boundingBox()
  const targetBox = await targetItem.boundingBox()

  // 某些拖拽库(react-beautiful-dnd, dnd-kit)需要特定的事件序列
  await sourceItem.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("Task C")
})

JavaScript

const { test, expect } = require("@playwright/test")

test("对可排序列表中的项目重新排序", async ({ page }) => {
  await page.goto("/tasks")

  const list = page.getByRole("list", { name: "Task list" })

  const initialItems = await list.getByRole("listitem").allTextContents()
  expect(initialItems[0]).toContain("Task A")
  expect(initialItems[1]).toContain("Task B")
  expect(initialItems[2]).toContain("Task C")

  const taskC = list.getByRole("listitem").filter({ hasText: "Task C" })
  const taskA = list.getByRole("listitem").filter({ hasText: "Task A" })

  await taskC.dragTo(taskA)

  const reorderedItems = await list.getByRole("listitem").allTextContents()
  expect(reorderedItems[0]).toContain("Task C")
  expect(reorderedItems[1]).toContain("Task A")
  expect(reorderedItems[2]).toContain("Task B")
})

test("使用精确鼠标移动重新排序", async ({ page }) => {
  await page.goto("/tasks")

  const list = page.getByRole("list", { name: "Task list" })
  const sourceItem = list.getByRole("listitem").filter({ hasText: "Task C" })
  const targetItem = list.getByRole("listitem").filter({ hasText: "Task A" })

  const sourceBox = await sourceItem.boundingBox()
  const targetBox = await targetItem.boundingBox()

  await sourceItem.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("Task C")
})

食谱三:看板(在列间移动)

完整示例

TypeScript

import { test, expect } from "@playwright/test"

test("将卡片从「待办」移到「进行中」", async ({ page }) => {
  await page.goto("/board")

  const todoColumn = page.locator('[data-column="todo"]')
  const inProgressColumn = page.locator('[data-column="in-progress"]')

  // 验证初始状态
  const card = todoColumn.getByText("Fix login bug")
  await expect(card).toBeVisible()

  const todoCountBefore = await todoColumn.getByRole("article").count()
  const inProgressCountBefore = await inProgressColumn.getByRole("article").count()

  // 将卡片拖到「进行中」
  await card.dragTo(inProgressColumn)

  // 验证卡片已移动
  await expect(inProgressColumn.getByText("Fix login bug")).toBeVisible()
  await expect(todoColumn.getByText("Fix login bug")).not.toBeVisible()

  // 验证计数已更新
  await expect(todoColumn.getByRole("article")).toHaveCount(todoCountBefore - 1)
  await expect(inProgressColumn.getByRole("article")).toHaveCount(inProgressCountBefore + 1)

  // 验证列标题计数徽章已更新
  await expect(todoColumn.getByText(`(${todoCountBefore - 1})`)).toBeVisible()
  await expect(inProgressColumn.getByText(`(${inProgressCountBefore + 1})`)).toBeVisible()
})

test("将卡片移过所有阶段", async ({ page }) => {
  await page.goto("/board")

  const columns = {
    todo: page.locator('[data-column="todo"]'),
    inProgress: page.locator('[data-column="in-progress"]'),
    review: page.locator('[data-column="review"]'),
    done: page.locator('[data-column="done"]'),
  }

  // 待办 -> 进行中
  await columns.todo.getByText("Fix login bug").dragTo(columns.inProgress)
  await expect(columns.inProgress.getByText("Fix login bug")).toBeVisible()

  // 进行中 -> 评审
  await columns.inProgress.getByText("Fix login bug").dragTo(columns.review)
  await expect(columns.review.getByText("Fix login bug")).toBeVisible()

  // 评审 -> 已完成
  await columns.review.getByText("Fix login bug").dragTo(columns.done)
  await expect(columns.done.getByText("Fix login bug")).toBeVisible()

  // 验证卡片仅在「已完成」列中
  await expect(columns.todo.getByText("Fix login bug")).not.toBeVisible()
  await expect(columns.inProgress.getByText("Fix login bug")).not.toBeVisible()
  await expect(columns.review.getByText("Fix login bug")).not.toBeVisible()
})

test("在同一列内对卡片重新排序", async ({ page }) => {
  await page.goto("/board")

  const todoColumn = page.locator('[data-column="todo"]')

  const cardA = todoColumn.getByRole("article").filter({ hasText: "Task A" })
  const cardC = todoColumn.getByRole("article").filter({ hasText: "Task C" })

  // 在同一列内将 Task C 拖到 Task A 上方
  await cardC.dragTo(cardA)

  // 验证列内新顺序
  const cards = await todoColumn.getByRole("article").allTextContents()
  expect(cards.indexOf("Task C")).toBeLessThan(cards.indexOf("Task A"))
})

test("看板状态通过 API 持久化", async ({ page }) => {
  await page.goto("/board")

  const todoColumn = page.locator('[data-column="todo"]')
  const inProgressColumn = page.locator('[data-column="in-progress"]')

  // 等待拖拽后的 PATCH/PUT 完成
  const responsePromise = page.waitForResponse(
    (r) => r.url().includes("/api/cards") && r.request().method() === "PATCH"
  )

  await todoColumn.getByText("Fix login bug").dragTo(inProgressColumn)

  const response = await responsePromise
  expect(response.status()).toBe(200)

  const body = await response.json()
  expect(body.column).toBe("in-progress")

  // 重新加载以确认持久化
  await page.reload()
  await expect(inProgressColumn.getByText("Fix login bug")).toBeVisible()
})

JavaScript

const { test, expect } = require("@playwright/test")

test("将卡片从「待办」移到「进行中」", async ({ page }) => {
  await page.goto("/board")

  const todoColumn = page.locator('[data-column="todo"]')
  const inProgressColumn = page.locator('[data-column="in-progress"]')

  const card = todoColumn.getByText("Fix login bug")
  await expect(card).toBeVisible()

  await card.dragTo(inProgressColumn)

  await expect(inProgressColumn.getByText("Fix login bug")).toBeVisible()
  await expect(todoColumn.getByText("Fix login bug")).not.toBeVisible()
})

test("将卡片移过所有阶段", async ({ page }) => {
  await page.goto("/board")

  const columns = {
    todo: page.locator('[data-column="todo"]'),
    inProgress: page.locator('[data-column="in-progress"]'),
    review: page.locator('[data-column="review"]'),
    done: page.locator('[data-column="done"]'),
  }

  await columns.todo.getByText("Fix login bug").dragTo(columns.inProgress)
  await expect(columns.inProgress.getByText("Fix login bug")).toBeVisible()

  await columns.inProgress.getByText("Fix login bug").dragTo(columns.review)
  await expect(columns.review.getByText("Fix login bug")).toBeVisible()

  await columns.review.getByText("Fix login bug").dragTo(columns.done)
  await expect(columns.done.getByText("Fix login bug")).toBeVisible()
})

食谱四:文件放置区域

完整示例

TypeScript

import { test, expect } from "@playwright/test"
import path from "path"

test("通过拖拽文件到放置区域来上传文件", async ({ page }) => {
  await page.goto("/upload")

  const dropZone = page.locator('[data-testid="file-drop-zone"]')

  // 验证初始状态
  await expect(dropZone).toContainText("Drag files here")

  // 使用文件选择器方法创建类似 DataTransfer 的事件
  // 由于 Playwright 无法模拟来自操作系统的原生 DnD 文件事件,
  // 我们使用放置区域依赖的底层 input[type=file]
  const fileInput = page.locator('input[type="file"]')

  await fileInput.setInputFiles(path.resolve(__dirname, "../fixtures/sample-document.pdf"))

  // 验证文件出现在上传列表中
  await expect(page.getByText("sample-document.pdf")).toBeVisible()
  await expect(page.getByText(/\d+ KB/)).toBeVisible()
})

test("通过 JavaScript 分发模拟拖入视觉反馈", async ({ page }) => {
  await page.goto("/upload")

  const dropZone = page.locator('[data-testid="file-drop-zone"]')

  // 分发 dragenter/dragover 事件以测试视觉反馈
  await dropZone.dispatchEvent("dragenter", {
    dataTransfer: { types: ["Files"] },
  })

  // 验证放置区域显示激活/悬停状态
  await expect(dropZone).toHaveClass(/drag-active|drop-highlight/)
  await expect(dropZone).toContainText(/drop.*here|release.*upload/i)

  // 分发 dragleave 以重置
  await dropZone.dispatchEvent("dragleave")

  await expect(dropZone).not.toHaveClass(/drag-active|drop-highlight/)
})

test("拒绝放置区域中的无效文件类型", async ({ page }) => {
  await page.goto("/upload")

  const fileInput = page.locator('input[type="file"]')

  // 尝试上传不允许的文件类型
  await fileInput.setInputFiles({
    name: "malware.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("malware.exe")).not.toBeVisible()
})

JavaScript

const { test, expect } = require("@playwright/test")
const path = require("path")

test("通过拖拽文件到放置区域来上传文件", 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/sample-document.pdf"))

  await expect(page.getByText("sample-document.pdf")).toBeVisible()
})

test("拒绝放置区域中的无效文件类型", async ({ page }) => {
  await page.goto("/upload")

  const fileInput = page.locator('input[type="file"]')

  await fileInput.setInputFiles({
    name: "malware.exe",
    mimeType: "application/x-msdownload",
    buffer: Buffer.from("fake-content"),
  })

  await expect(page.getByRole("alert")).toContainText(/not allowed|invalid file type/i)
})

食谱五:自定义预览/幽灵图像拖拽

完整示例

TypeScript

import { test, expect } from "@playwright/test"

test("拖拽操作期间显示自定义拖拽预览", async ({ page }) => {
  await page.goto("/board")

  const card = page.locator('[data-testid="card-1"]')
  const targetColumn = page.locator('[data-column="in-progress"]')

  const cardBox = await card.boundingBox()
  const targetBox = await targetColumn.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("多选拖拽时预览显示项目数量", async ({ page }) => {
  await page.goto("/board")

  // 选择多张卡片
  await page.locator('[data-testid="card-1"]').click()
  await page.locator('[data-testid="card-2"]').click({ modifiers: ["Shift"] })
  await page.locator('[data-testid="card-3"]').click({ modifiers: ["Shift"] })

  // 开始拖拽其中一张选中的卡片
  const card = page.locator('[data-testid="card-1"]')
  const targetColumn = page.locator('[data-column="done"]')

  await card.hover()
  await page.mouse.down()

  const targetBox = await targetColumn.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(targetColumn.locator('[data-testid="card-1"]')).toBeVisible()
  await expect(targetColumn.locator('[data-testid="card-2"]')).toBeVisible()
  await expect(targetColumn.locator('[data-testid="card-3"]')).toBeVisible()
})

JavaScript

const { test, expect } = require("@playwright/test")

test("拖拽操作期间显示自定义拖拽预览", async ({ page }) => {
  await page.goto("/board")

  const card = page.locator('[data-testid="card-1"]')
  const targetColumn = page.locator('[data-column="in-progress"]')

  const cardBox = await card.boundingBox()
  const targetBox = await targetColumn.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()
})

食谱六:测试拖拽位置和坐标

完整示例

TypeScript

import { test, expect } from "@playwright/test"

test("将元素拖拽到画布上的特定坐标", async ({ page }) => {
  await page.goto("/canvas-editor")

  const canvas = page.locator("#design-canvas")
  const element = page.locator('[data-testid="shape-1"]')

  // 获取初始位置
  const initialBox = await element.boundingBox()
  expect(initialBox).toBeTruthy()

  // 定义目标位置(画布内的绝对坐标)
  const canvasBox = await canvas.boundingBox()
  const targetX = canvasBox!.x + 300
  const targetY = canvasBox!.y + 200

  // 拖拽到特定坐标
  await element.hover()
  await page.mouse.down()
  await page.mouse.move(targetX, targetY, { steps: 10 })
  await page.mouse.up()

  // 验证元素已移动到大致正确的位置
  const newBox = await element.boundingBox()
  expect(newBox!.x).toBeCloseTo(targetX - newBox!.width / 2, -1)
  expect(newBox!.y).toBeCloseTo(targetY - newBox!.height / 2, -1)
})

test("元素放置时吸附到网格", async ({ page }) => {
  await page.goto("/canvas-editor")

  const element = page.locator('[data-testid="shape-1"]')
  const canvas = page.locator("#design-canvas")

  const canvasBox = await canvas.boundingBox()

  // 拖拽到不在网格上的位置
  await element.hover()
  await page.mouse.down()
  await page.mouse.move(canvasBox!.x + 147, canvasBox!.y + 83, { steps: 10 })
  await page.mouse.up()

  // 使用 20px 网格,位置应吸附到最近的网格点
  const snappedBox = await element.boundingBox()
  expect(snappedBox!.x % 20).toBeCloseTo(0, 0)
  expect(snappedBox!.y % 20).toBeCloseTo(0, 0)
})

test("在边界内约束拖拽", async ({ page }) => {
  await page.goto("/canvas-editor")

  const element = page.locator('[data-testid="constrained-element"]')
  const container = page.locator("#constraint-box")

  const containerBox = await container.boundingBox()

  // 尝试拖拽到容器外很远的地方
  await element.hover()
  await page.mouse.down()
  await page.mouse.move(containerBox!.x + containerBox!.width + 500, containerBox!.y - 200, {
    steps: 10,
  })
  await page.mouse.up()

  // 元素应被限制在容器内
  const elementBox = await element.boundingBox()

  expect(elementBox!.x).toBeGreaterThanOrEqual(containerBox!.x)
  expect(elementBox!.y).toBeGreaterThanOrEqual(containerBox!.y)
  expect(elementBox!.x + elementBox!.width).toBeLessThanOrEqual(
    containerBox!.x + containerBox!.width
  )
  expect(elementBox!.y + elementBox!.height).toBeLessThanOrEqual(
    containerBox!.y + containerBox!.height
  )
})

test("通过拖拽手柄调整元素大小", async ({ page }) => {
  await page.goto("/canvas-editor")

  const element = page.locator('[data-testid="shape-1"]')
  await element.click() // 选中以显示调整大小手柄

  const resizeHandle = element.locator(".resize-handle-se") // 东南角
  const handleBox = await resizeHandle.boundingBox()

  const initialBox = await element.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 element.boundingBox()
  expect(newBox!.width).toBeCloseTo(initialBox!.width + 100, -1)
  expect(newBox!.height).toBeCloseTo(initialBox!.height + 80, -1)
})

JavaScript

const { test, expect } = require("@playwright/test")

test("将元素拖拽到画布上的特定坐标", async ({ page }) => {
  await page.goto("/canvas-editor")

  const canvas = page.locator("#design-canvas")
  const element = page.locator('[data-testid="shape-1"]')

  const canvasBox = await canvas.boundingBox()
  const targetX = canvasBox.x + 300
  const targetY = canvasBox.y + 200

  await element.hover()
  await page.mouse.down()
  await page.mouse.move(targetX, targetY, { steps: 10 })
  await page.mouse.up()

  const newBox = await element.boundingBox()
  expect(newBox.x).toBeCloseTo(targetX - newBox.width / 2, -1)
  expect(newBox.y).toBeCloseTo(targetY - newBox.height / 2, -1)
})

test("在边界内约束拖拽", async ({ page }) => {
  await page.goto("/canvas-editor")

  const element = page.locator('[data-testid="constrained-element"]')
  const container = page.locator("#constraint-box")

  const containerBox = await container.boundingBox()

  await element.hover()
  await page.mouse.down()
  await page.mouse.move(containerBox.x + containerBox.width + 500, containerBox.y - 200, {
    steps: 10,
  })
  await page.mouse.up()

  const elementBox = await element.boundingBox()
  expect(elementBox.x).toBeGreaterThanOrEqual(containerBox.x)
  expect(elementBox.y).toBeGreaterThanOrEqual(containerBox.y)
})

变体

通过键盘辅助功能的拖放

test("使用键盘对项目重新排序", async ({ page }) => {
  await page.goto("/tasks")

  const list = page.getByRole("list", { name: "Task list" })
  const taskC = list.getByRole("listitem").filter({ hasText: "Task C" })

  // 聚焦该项目
  await taskC.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("Task C")
})

在 iframe 之间拖放

test("在主页面和 iframe 之间拖拽", async ({ page }) => {
  await page.goto("/editor")

  const sourceItem = page.getByText("Widget A")
  const iframe = page.frameLocator("#preview-frame")
  const dropTarget = iframe.locator("#content-area")

  // 跨框架拖拽需要坐标,因为 dragTo 不能跨框架工作
  const sourceBox = await sourceItem.boundingBox()
  const iframeElement = page.locator("#preview-frame")
  const iframeBox = await iframeElement.boundingBox()

  // 计算 iframe 内的目标位置
  const targetX = iframeBox!.x + 100
  const targetY = iframeBox!.y + 100

  await sourceItem.hover()
  await page.mouse.down()
  await page.mouse.move(targetX, targetY, { steps: 20 })
  await page.mouse.up()

  await expect(iframe.getByText("Widget A")).toBeVisible()
})

移动端基于触摸的拖拽

test("在移动设备上通过触摸事件拖拽项目", async ({ page }) => {
  await page.goto("/tasks")

  const list = page.getByRole("list", { name: "Task list" })
  const sourceItem = list.getByRole("listitem").filter({ hasText: "Task C" })
  const targetItem = list.getByRole("listitem").filter({ hasText: "Task A" })

  const sourceBox = await sourceItem.boundingBox()
  const targetBox = await targetItem.boundingBox()

  // 模拟长按后拖拽的触摸操作
  await page.touchscreen.tap(
    sourceBox!.x + sourceBox!.width / 2,
    sourceBox!.y + sourceBox!.height / 2
  )

  // 为使用触摸事件的库分发 touchstart、touchmove、touchend
  await sourceItem.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 sourceItem.dispatchEvent("touchmove", {
      touches: [{ clientX: sourceBox!.x + 10, clientY: y }],
    })
  }

  await sourceItem.dispatchEvent("touchend")

  const items = await list.getByRole("listitem").allTextContents()
  expect(items[0]).toContain("Task C")
})

技巧

  1. 优先使用 dragTo(),再回退到手动鼠标事件。Playwright 内置的 dragTo() 能处理大多数原生 HTML5 拖放。仅对需要特定事件序列的自定义拖拽库(react-beautiful-dnd、dnd-kit、SortableJS)使用 page.mouse.down() / move() / up() 序列。

  2. 为拖拽库添加中间鼠标移动步长。像 react-beautiful-dnd 这样的库需要多个小增量的 mousemove 事件来检测拖拽。使用 { steps: 10 } 或手动循环。从源到目标的一次性跳跃常常静默失败。

  3. 始终断言最终状态,而不仅仅是放置事件。拖放操作后,验证 DOM 确实反映了变更——项目顺序、列内容、位置坐标。拖拽期间的视觉反馈值得测试,但最终持久化状态最为重要。

  4. 对基于坐标的断言使用 boundingBox()。测试画布编辑器、网格布局或对位置敏感的放置时,在操作后捕获边界框并与预期坐标比较。使用 toBeCloseTo() 以允许容差。

  5. 测试拖放操作后的撤销功能。如果你的应用支持 Ctrl+Z 撤销重新排序或移动,测试拖放操作是否可逆。这能捕获仅当撤销时才会暴露的状态管理错误。


相关链接

  • Playwright 拖放文档
  • recipes/file-upload-download.md —— 专门针对文件放置区域
  • foundations/actions.md —— 鼠标和键盘交互基础
  • patterns/page-objects.md —— 封装复杂的拖拽流程