testing-webapps

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese


Testing Web Applications

测试Web应用

To test local web applications, write native Python Playwright scripts.
要测试本地Web应用,请编写原生Python Playwright脚本。

Prerequisites

前置条件

Before running any Python scripts, activate the opencode virtual environment:
bash
source ~/.local/opencode-venv/bin/activate
Then use
python3 scripts/...
normally. If Playwright browser binaries aren't installed yet:
bash
playwright install chromium
Helper Scripts Available:
  • scripts/with_server.py
    - Manages server lifecycle (supports multiple servers)
Always run scripts with
--help
first
to see usage. DO NOT read the source until you try running the script first and find that a customized solution is abslutely necessary. These scripts can be very large and thus pollute your context window. They exist to be called directly as black-box scripts rather than ingested into your context window.
运行任何Python脚本之前,请激活opencode虚拟环境:
bash
source ~/.local/opencode-venv/bin/activate
之后即可正常使用
python3 scripts/...
命令。如果尚未安装Playwright浏览器二进制文件:
bash
playwright install chromium
可用的辅助脚本:
  • scripts/with_server.py
    - 管理服务器生命周期(支持多服务器)
请务必先使用
--help
参数运行脚本
查看用法。在尝试运行脚本并确认确实需要自定义解决方案之前,请勿阅读源码。这些脚本可能非常庞大,会占用你的上下文窗口。它们的定位是可直接调用的黑盒脚本,而非需要加载到上下文窗口中的内容。

Decision Tree: Choosing Your Approach

决策树:选择你的实现方式

User task → Is it static HTML?
    ├─ Yes → Read HTML file directly to identify selectors
    │         ├─ Success → Write Playwright script using selectors
    │         └─ Fails/Incomplete → Treat as dynamic (below)
    │
    └─ No (dynamic webapp) → Is the server already running?
        ├─ No → Run: python scripts/with_server.py --help
        │        Then use the helper + write simplified Playwright script
        │
        └─ Yes → Reconnaissance-then-action:
            1. Navigate and wait for networkidle
            2. Take screenshot or inspect DOM
            3. Identify selectors from rendered state
            4. Execute actions with discovered selectors
User task → Is it static HTML?
    ├─ Yes → Read HTML file directly to identify selectors
    │         ├─ Success → Write Playwright script using selectors
    │         └─ Fails/Incomplete → Treat as dynamic (below)
    │
    └─ No (dynamic webapp) → Is the server already running?
        ├─ No → Run: python scripts/with_server.py --help
        │        Then use the helper + write simplified Playwright script
        │
        └─ Yes → Reconnaissance-then-action:
            1. Navigate and wait for networkidle
            2. Take screenshot or inspect DOM
            3. Identify selectors from rendered state
            4. Execute actions with discovered selectors

Example: Using with_server.py

示例:使用 with_server.py

To start a server, run
--help
first, then use the helper:
Single server:
bash
python scripts/with_server.py --server "npm run dev" --port 5173 -- python your_automation.py
Multiple servers (e.g., backend + frontend):
bash
python scripts/with_server.py \
  --server "cd backend && python server.py" --port 3000 \
  --server "cd frontend && npm run dev" --port 5173 \
  -- python your_automation.py
To create an automation script, include only Playwright logic (servers are managed automatically):
python
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True) # Always launch chromium in headless mode
    page = browser.new_page()
    page.goto('http://localhost:5173') # Server already running and ready
    page.wait_for_load_state('networkidle') # CRITICAL: Wait for JS to execute
    # ... your automation logic
    browser.close()
要启动服务器,请先运行
--help
查看用法,再使用该辅助脚本:
单服务器:
bash
python scripts/with_server.py --server "npm run dev" --port 5173 -- python your_automation.py
多服务器(例如:后端 + 前端):
bash
python scripts/with_server.py \
  --server "cd backend && python server.py" --port 3000 \
  --server "cd frontend && npm run dev" --port 5173 \
  -- python your_automation.py
编写自动化脚本时,只需包含Playwright逻辑即可(服务器会被自动管理):
python
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True) # Always launch chromium in headless mode
    page = browser.new_page()
    page.goto('http://localhost:5173') # Server already running and ready
    page.wait_for_load_state('networkidle') # CRITICAL: Wait for JS to execute
    # ... your automation logic
    browser.close()

Reconnaissance-Then-Action Pattern

先侦察后执行模式

  1. Inspect rendered DOM:
    python
    page.screenshot(path='/tmp/inspect.png', full_page=True)
    content = page.content()
    page.locator('button').all()
  2. Identify selectors from inspection results
  3. Execute actions using discovered selectors
  1. 检查渲染后的DOM:
    python
    page.screenshot(path='/tmp/inspect.png', full_page=True)
    content = page.content()
    page.locator('button').all()
  2. 根据检查结果识别选择器
  3. 使用找到的选择器执行操作

Common Pitfall

常见陷阱

❌ Don't inspect the DOM before waiting for
networkidle
on dynamic apps ✅ Do wait for
page.wait_for_load_state('networkidle')
before inspection
❌ 不要在动态应用中等待
networkidle
之前检查DOM ✅ 请在检查前先执行
page.wait_for_load_state('networkidle')

Best Practices

最佳实践

  • Use bundled scripts as black boxes - To accomplish a task, consider whether one of the scripts available in
    scripts/
    can help. These scripts handle common, complex workflows reliably without cluttering the context window. Use
    --help
    to see usage, then invoke directly.
  • Use
    sync_playwright()
    for synchronous scripts
  • Always close the browser when done
  • Use descriptive selectors:
    text=
    ,
    role=
    , CSS selectors, or IDs
  • Add appropriate waits:
    page.wait_for_selector()
    or
    page.wait_for_timeout()
  • 将内置脚本视为黑盒 - 完成任务时,请先考虑
    scripts/
    目录下的可用脚本是否能提供帮助。这些脚本能够可靠地处理常见的复杂工作流,且不会占用上下文窗口。使用
    --help
    查看用法后直接调用即可。
  • 同步脚本请使用
    sync_playwright()
  • 任务完成后务必关闭浏览器
  • 使用描述性选择器:
    text=
    、
    role=
    、CSS选择器或ID
  • 添加合适的等待逻辑:
    page.wait_for_selector()
    或
    page.wait_for_timeout()

Waiting Correctly

正确的等待方式

networkidle
is the right default for first load, but it is the wrong tool for asserting that a specific thing happened. Prefer waiting on the condition you actually care about.
python
page.wait_for_selector("[data-testid=results]", state="visible")
page.wait_for_function("() => document.querySelectorAll('.row').length > 0")
page.get_by_role("button", name="Save").click()
expect(page.get_by_text("Saved")).to_be_visible()      # auto-retries
Never
wait_for_timeout()
as a substitute for a condition.
A fixed sleep is either too short (flaky) or too long (slow), and it always hides the real race. The one legitimate use is waiting out a known animation duration before a screenshot.
Prefer role-based locators over CSS — they survive restyling and they double as an accessibility assertion:
python
page.get_by_role("button", name="Delete account")
page.get_by_label("Email address")
page.get_by_role("heading", level=1)
If
get_by_role
cannot find a control, that is usually a genuine accessibility defect, not a test problem. See
building-accessible-interfaces
.
networkidle
适合作为首次加载的默认等待方式,但不适合用于断言特定事件是否发生。优先等待你真正关心的条件。
python
page.wait_for_selector("[data-testid=results]", state="visible")
page.wait_for_function("() => document.querySelectorAll('.row').length > 0")
page.get_by_role("button", name="Save").click()
expect(page.get_by_text("Saved")).to_be_visible()      # auto-retries
绝对不要用
wait_for_timeout()
替代条件等待。
固定时长的等待要么太短(导致测试不稳定),要么太长(拖慢速度),而且总会掩盖真正的竞态问题。唯一合理的使用场景是截图前等待已知的动画时长结束。
优先使用基于角色的定位器而非CSS定位器——它们不会因为样式调整而失效,同时还能作为可访问性断言:
python
page.get_by_role("button", name="Delete account")
page.get_by_label("Email address")
page.get_by_role("heading", level=1)
如果
get_by_role
找不到某个控件,通常这是一个真实的可访问性缺陷,而非测试本身的问题。请参考
building-accessible-interfaces
。

Asserting Console Cleanliness

断言控制台无错误

A page that renders correctly while throwing errors is broken. Capture and assert.
python
errors, warnings = [], []
page.on("console", lambda m: (errors if m.type == "error" else warnings).append(m.text)
        if m.type in ("error", "warning") else None)
page.on("pageerror", lambda e: errors.append(f"uncaught: {e}"))
page.on("requestfailed", lambda r: errors.append(f"{r.method} {r.url} — {r.failure}"))

page.goto(URL); page.wait_for_load_state("networkidle")
assert not errors, errors
一个渲染正常但抛出错误的页面仍然是有问题的。请捕获并断言控制台状态。
python
errors, warnings = [], []
page.on("console", lambda m: (errors if m.type == "error" else warnings).append(m.text)
        if m.type in ("error", "warning") else None)
page.on("pageerror", lambda e: errors.append(f"uncaught: {e}"))
page.on("requestfailed", lambda r: errors.append(f"{r.method} {r.url} — {r.failure}"))

page.goto(URL); page.wait_for_load_state("networkidle")
assert not errors, errors

Accessibility Auditing

可访问性审计

Run axe against the live page. This is the automated third of the accessibility check — the keyboard walk and screen-reader pass in
building-accessible-interfaces
are still required.
python
page.add_script_tag(url="https://cdn.jsdelivr.net/npm/axe-core@4/axe.min.js")
result = page.evaluate("async () => await axe.run()")
violations = [(v["id"], v["impact"], len(v["nodes"])) for v in result["violations"]]
assert not violations, violations
Keyboard traversal — catches missing focus styles and unreachable controls:
python
seq = []
for _ in range(40):
    page.keyboard.press("Tab")
    seq.append(page.evaluate("""() => {
        const el = document.activeElement;
        if (!el || el === document.body) return null;
        const s = getComputedStyle(el);
        return {tag: el.tagName, name: el.ariaLabel || el.innerText?.slice(0,30),
                outline: s.outlineStyle, ring: s.boxShadow !== 'none'};
    }"""))
invisible = [s for s in seq if s and s["outline"] == "none" and not s["ring"]]
assert not invisible, f"focusable elements with no visible focus: {invisible}"
在运行中的页面上执行axe检测。这只是可访问性检查中自动化的三分之一——
building-accessible-interfaces
中提到的键盘遍历和屏幕阅读器检测仍然是必需的。
python
page.add_script_tag(url="https://cdn.jsdelivr.net/npm/axe-core@4/axe.min.js")
result = page.evaluate("async () => await axe.run()")
violations = [(v["id"], v["impact"], len(v["nodes"])) for v in result["violations"]]
assert not violations, violations
键盘遍历——可检测缺失的焦点样式和无法访问的控件:
python
seq = []
for _ in range(40):
    page.keyboard.press("Tab")
    seq.append(page.evaluate("""() => {
        const el = document.activeElement;
        if (!el || el === document.body) return null;
        const s = getComputedStyle(el);
        return {tag: el.tagName, name: el.ariaLabel || el.innerText?.slice(0,30),
                outline: s.outlineStyle, ring: s.boxShadow !== 'none'};
    }"""))
invisible = [s for s in seq if s and s["outline"] == "none" and not s["ring"]]
assert not invisible, f"focusable elements with no visible focus: {invisible}"

Responsive and Visual Checks

响应式与视觉检查

python
for name, w, h in [("mobile", 390, 844), ("tablet", 768, 1024), ("desktop", 1440, 900)]:
    page.set_viewport_size({"width": w, "height": h})
    page.wait_for_timeout(200)                       # settle layout/animation
    overflow = page.evaluate("""() => [...document.querySelectorAll('*')]
        .filter(e => e.getBoundingClientRect().right > document.documentElement.clientWidth + 1)
        .slice(0,5).map(e => e.tagName + '.' + e.className)""")
    assert not overflow, f"{name}: horizontal overflow from {overflow}"
    page.screenshot(path=f"/tmp/{name}.png", full_page=True)
Then read the screenshots. A passing assertion says the DOM is not overflowing; it says nothing about whether the page looks right. For a full visual audit use the
reviewing-interface-quality
skill.
Deterministic screenshots — disable animation and caret so repeat runs match:
python
page.screenshot(path="x.png", animations="disabled", caret="hide", full_page=True)
python
for name, w, h in [("mobile", 390, 844), ("tablet", 768, 1024), ("desktop", 1440, 900)]:
    page.set_viewport_size({"width": w, "height": h})
    page.wait_for_timeout(200)                       # settle layout/animation
    overflow = page.evaluate("""() => [...document.querySelectorAll('*')]
        .filter(e => e.getBoundingClientRect().right > document.documentElement.clientWidth + 1)
        .slice(0,5).map(e => e.tagName + '.' + e.className)""")
    assert not overflow, f"{name}: horizontal overflow from {overflow}"
    page.screenshot(path=f"/tmp/{name}.png", full_page=True)
之后请查看截图。 断言通过只能说明DOM没有溢出,但无法保证页面视觉效果是否正确。完整的视觉审计请使用
reviewing-interface-quality
技能。
确定性截图——禁用动画和光标,确保多次运行结果一致:
python
page.screenshot(path="x.png", animations="disabled", caret="hide", full_page=True)

Controlling the Network

网络控制

Testing error and empty states requires forcing them. This is how you exercise the state matrix from
designing-user-experience
.
python
page.route("**/api/items", lambda r: r.fulfill(status=200, json=[]))        # empty state
page.route("**/api/items", lambda r: r.fulfill(status=500))                 # error state
page.route("**/api/items", lambda r: r.abort())                             # network failure
page.context.set_offline(True)                                              # offline state
测试错误状态和空状态需要主动触发这些场景。这就是你验证
designing-user-experience
中提到的状态矩阵的方法。
python
page.route("**/api/items", lambda r: r.fulfill(status=200, json=[]))        # empty state
page.route("**/api/items", lambda r: r.fulfill(status=500))                 # error state
page.route("**/api/items", lambda r: r.abort())                             # network failure
page.context.set_offline(True)                                              # offline state

Slow response — verifies the loading state actually appears

Slow response — verifies the loading state actually appears

page.route("/api/", lambda r: (page.wait_for_timeout(3000), r.continue_())[1])

Emulate constrained conditions:

```python
ctx = browser.new_context(color_scheme="dark", reduced_motion="reduce",
                          locale="ar-EG", viewport={"width": 390, "height": 844})
reduced_motion="reduce"
verifies the
prefers-reduced-motion
block exists.
color_scheme="dark"
catches contrast that only fails in dark mode.
page.route("/api/", lambda r: (page.wait_for_timeout(3000), r.continue_())[1])

模拟受限环境:

```python
ctx = browser.new_context(color_scheme="dark", reduced_motion="reduce",
                          locale="ar-EG", viewport={"width": 390, "height": 844})
reduced_motion="reduce"
用于验证
prefers-reduced-motion
相关样式是否存在。
color_scheme="dark"
可检测仅在深色模式下才会出现的对比度问题。

Debugging a Failing Script

调试失败的脚本

python
browser = p.chromium.launch(headless=False, slow_mo=400)   # watch it run
page.pause()                                               # opens Playwright Inspector
bash
PWDEBUG=1 python3 your_script.py            # step through
python3 -m playwright codegen localhost:5173  # record interactions into code
Capture a trace when a failure is intermittent:
python
context.tracing.start(screenshots=True, snapshots=True)
python
browser = p.chromium.launch(headless=False, slow_mo=400)   # watch it run
page.pause()                                               # opens Playwright Inspector
bash
PWDEBUG=1 python3 your_script.py            # step through
python3 -m playwright codegen localhost:5173  # record interactions into code
当失败是偶发的,请捕获追踪信息:
python
context.tracing.start(screenshots=True, snapshots=True)

...

...

context.tracing.stop(path="/tmp/trace.zip") # then: playwright show-trace /tmp/trace.zip
undefined
context.tracing.stop(path="/tmp/trace.zip") # then: playwright show-trace /tmp/trace.zip
undefined

Common Pitfalls

常见陷阱

SymptomCauseFix
Element found but click does nothingOverlay intercepting the event
page.get_by_role(...).click()
reports the interceptor — dismiss it first
Passes headed, fails headlessTiming, or a font/layout differenceWait on conditions, not timeouts; set an explicit viewport
strict mode violation
Locator matched multiple elementsNarrow with
.first
, a role, or a more specific name
Screenshot is blankCaptured before paint
wait_for_load_state("networkidle")
plus a settle timeout
Works once, fails on rerunTest mutated state and did not resetUse a fresh
browser.new_context()
per run
Selector breaks after a restyleCSS-class-based locatorUse
get_by_role
/
get_by_label
/
data-testid
症状原因解决方案
找到元素但点击无反应遮罩层拦截了事件
page.get_by_role(...).click()
会提示拦截元素——先关闭该遮罩
有头模式通过,无头模式失败时序问题,或字体/布局差异使用条件等待而非超时等待;设置明确的视口
strict mode violation
错误
定位器匹配到了多个元素使用
.first
、角色或更具体的名称缩小匹配范围
截图为空白在渲染完成前就捕获了先执行
wait_for_load_state("networkidle")
,再加上一段稳定等待时间
第一次运行成功,重运行失败测试修改了状态且未重置每次运行都使用全新的
browser.new_context()
样式调整后选择器失效使用了基于CSS类的定位器使用
get_by_role
/
get_by_label
/
data-testid

Reference Files

参考文件

  • examples/ - Examples showing common patterns:
    • element_discovery.py
      - Discovering buttons, links, and inputs on a page
    • static_html_automation.py
      - Using file:// URLs for local HTML
    • console_logging.py
      - Capturing console logs during automation
  • examples/ - 包含常见模式的示例:
    • element_discovery.py
      - 发现页面上的按钮、链接和输入框
    • static_html_automation.py
      - 对本地HTML使用 file:// 协议URL
    • console_logging.py
      - 在自动化过程中捕获控制台日志

Related Skills

相关技能

  • reviewing-interface-quality — full visual/UX/a11y audit of a rendered UI
  • building-accessible-interfaces — what the axe results mean and how to fix them
  • designing-user-experience — the state matrix these network mocks exist to exercise
  • systematic-debugging — when a test failure is a real bug, not a test bug
  • reviewing-interface-quality — 对渲染后的UI进行完整的视觉/UX/可访问性审计
  • building-accessible-interfaces — 讲解axe检测结果的含义及修复方法
  • designing-user-experience — 讲解这些网络模拟所要验证的状态矩阵
  • systematic-debugging — 当测试失败是真实bug而非测试本身的问题时使用