7.2 KiB
name, description, license
| name | description | license |
|---|---|---|
| Testing Webapps | Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs. | Complete terms in LICENSE.txt |
Web Application Testing
Write native Python Playwright scripts to test local webapps.
Helper: scripts/with_server.py manages server lifecycle. Run with --help first.
Approach
Static HTML: Read file → identify selectors → write script
Dynamic webapp:
- Server not running: Use
with_server.py - Server running: Navigate → wait networkidle → inspect → act
Server Management
# Single server
python scripts/with_server.py --server "npm run dev" --port 5173 -- python automation.py
# Multiple servers
python scripts/with_server.py \
--server "cd backend && python server.py" --port 3000 \
--server "cd frontend && npm run dev" --port 5173 \
-- python automation.py
Script Patterns
Automation:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto('http://localhost:5173')
page.wait_for_load_state('networkidle') # CRITICAL for dynamic apps
# automation logic here
browser.close()
Reconnaissance:
page.screenshot(path='/tmp/inspect.png', full_page=True)
page.content() # Get HTML
page.locator('button').all() # Find elements
Headless Mode + Trace Viewer (Recommended for macOS)
Problem: Headed mode steals window focus on macOS, disrupting workflow.
Solution: Run headless with trace recording:
import os
headless = os.getenv('HEADED') != '1' # Default headless, override with HEADED=1
browser = p.chromium.launch(headless=headless)
context = browser.new_context()
context.tracing.start(screenshots=True, snapshots=True, sources=True)
page = context.new_page()
# ... test code ...
# Save trace on completion
context.tracing.stop(path="/tmp/trace_testname_SUCCESS.zip")
Debug traces:
playwright show-trace /tmp/trace_testname_SUCCESS.zip
Why better than headed: Step through at your own pace, inspect DOM at any point, see network requests, no window disruption.
Selector Best Practices
Emoji-safe text matching:
# ❌ Fails with emoji
page.locator('text="Mission Control"')
# ✅ Works with "Mission Control 🚀"
page.locator('text=/Mission Control/')
Button-specific selectors:
# ❌ Too generic, matches any text
page.locator('text="Create World"')
# ✅ Specific to buttons
page.locator('button:has-text("Create World")')
Form field specificity:
# ❌ Fragile, matches wrong element
page.locator('textarea').first
# ✅ Specific placeholder
page.locator('textarea[placeholder*="description"]')
page.locator('input[type="text"]').nth(2) # If index matters
Wait for both visible AND enabled:
button = page.locator('button:has-text("Submit")')
expect(button).to_be_visible(timeout=5000)
expect(button).to_be_enabled(timeout=5000) # Critical for form buttons!
button.click()
Form Testing Pattern
Rule: Fill → Wait for enabled → Click
Wrong order (causes timeouts):
# ❌ Button is disabled, causes "element not enabled" timeout
button.click()
textarea.fill("content")
Correct order:
# ✅ Button becomes enabled after fill
textarea = page.locator('textarea[placeholder="description"]')
expect(textarea).to_be_visible(timeout=5000)
textarea.fill("content")
button = page.locator('button:has-text("Submit")')
expect(button).to_be_enabled(timeout=5000) # Now enabled
button.click()
Why: Most forms disable submit buttons until validation passes. Always fill first.
Test Setup: Database State
Pattern for clean test runs:
# Reset database before tests
rm -f backend/database.db
cd backend && python -c "from src.database import init_db; import asyncio; asyncio.run(init_db())"
In test runner:
from pathlib import Path
import subprocess
def setup_clean_database():
"""Reset database to clean state."""
db_path = Path("backend/database.db")
if db_path.exists():
db_path.unlink()
subprocess.run([
"python", "-c",
"from src.database import init_db; import asyncio; asyncio.run(init_db())"
], cwd="backend")
Why: Prevents UUID conflicts, UNIQUE constraint violations, and flaky tests from stale data.
Debugging Triad: Screenshot + Trace + Console
Always capture all three:
# Setup
context.tracing.start(screenshots=True, snapshots=True, sources=True)
logs = []
page.on("console", lambda msg: logs.append(f"[{msg.type}] {msg.text}"))
# During test - take screenshots at key steps
page.screenshot(path='/tmp/test_step1.png')
# On failure
context.tracing.stop(path="/tmp/trace_FAILED.zip")
print(f"Console logs (last 20):")
for log in logs[-20:]:
print(f" {log}")
Why each matters:
- Screenshots: Visual state at failure point
- Trace: Full interaction timeline, DOM snapshots, network activity
- Console: React errors, API failures, JavaScript warnings
Debugging workflow:
- Check console logs for errors first (fastest)
- View screenshot to understand visual state
- Open trace with
playwright show-traceto step through and inspect DOM
Troubleshooting
"Fix doesn't work" - Tests still fail after code change
Symptom: Fixed a bug but tests still fail with same error.
Causes & Solutions:
-
Frontend hot reload hasn't applied changes
- Verify file:
grep "new code" file.jsx - Check dev server console for reload confirmation
- Hard restart: Kill dev server,
npm run dev
- Verify file:
-
Browser cache
- Use
page.goto(..., wait_until='networkidle') - Or clear:
context.clear_cookies()
- Use
Generic selectors match wrong elements
Symptom: textarea.first or button.last fails unexpectedly or matches wrong element.
Cause: DOM structure changed or multiple matching elements exist.
Solution: Use attribute selectors:
# ❌ Fragile - depends on DOM order
page.locator('textarea').first
# ✅ Robust - matches specific element
page.locator('textarea[placeholder="World description"]')
page.locator('button:has-text("Create")').first # If multiple, be specific
"Element not enabled" timeouts
Symptom: page.click() times out with "element is not enabled".
Cause: Trying to click button before form validation passes.
Solution: Fill form first, then wait for enabled:
# Fill all required fields first
input1.fill("value1")
input2.fill("value2")
# Then wait for button to enable
button = page.locator('button:has-text("Submit")')
expect(button).to_be_enabled(timeout=5000)
button.click()
Critical Rules
- Always
page.wait_for_load_state('networkidle')before DOM inspection - Default to headless with trace recording for debugging without window disruption
- Fill forms before clicking submit buttons (they're usually disabled)
- Use specific selectors with attributes, not generic
.first/.last - Capture triad: screenshots + trace + console logs for debugging
- Close browser when done
- See
examples/for more patterns