| name | webapp-testing |
| description | 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. |
| license | Apache-2.0 |
| category | development-tools |
Web Application Testing
Test local web applications using Python and Playwright with battle-tested helper scripts.
Quick Start
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:3000')
page.wait_for_load_state('networkidle')
page.click('button:has-text("Submit")')
assert page.locator('h1').text_content() == 'Success'
browser.close()
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 → Use scripts/with_server.py to manage lifecycle
│ 1. Run: python scripts/with_server.py --help
│ 2. Write Playwright script (server managed automatically)
│
└─ Yes → Reconnaissance-then-action pattern:
1. Navigate and wait for networkidle
2. Take screenshot or inspect DOM
3. Identify selectors from rendered state
4. Execute actions with discovered selectors
Helper Scripts
with_server.py - Server Lifecycle Management
Purpose: Start servers, run tests, automatically clean up
Always run with --help first to see current usage. These scripts are black boxes - use them without reading the source.
Single server:
python scripts/with_server.py --server "npm run dev" --port 5173 -- python test.py
Multiple servers (backend + frontend):
python scripts/with_server.py \
--server "cd backend && python server.py" --port 3000 \
--server "cd frontend && npm run dev" --port 5173 \
-- python test.py
Your test script only contains Playwright logic:
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')
browser.close()
Reconnaissance-Then-Action Pattern
Critical for dynamic apps: Discover selectors from rendered state, then act.
Step 1: Inspect Rendered DOM
page.goto('http://localhost:3000')
page.wait_for_load_state('networkidle')
page.screenshot(path='/tmp/inspect.png', full_page=True)
content = page.content()
buttons = page.locator('button').all()
for btn in buttons:
print(f"Button: {btn.text_content()}")
Step 2: Identify Selectors
From inspection, find reliable selectors:
- Text content:
text="Login"
- Role:
role=button[name="Submit"]
- CSS:
button.primary, #login-form
- Data attributes:
[data-testid="submit-btn"]
Step 3: Execute Actions
page.click('button:has-text("Login")')
page.fill('input[name="username"]', 'admin')
page.select_option('select#country', 'US')
Common Patterns
Pattern 1: Form Submission
page.fill('input[name="email"]', 'test@example.com')
page.fill('input[name="password"]', 'secret123')
page.click('button[type="submit"]')
page.wait_for_load_state('networkidle')
success_msg = page.locator('.success-message').text_content()
assert 'Welcome' in success_msg
Pattern 2: Waiting for Dynamic Content
page.wait_for_selector('.loading-spinner', state='hidden')
page.wait_for_selector('.data-loaded')
page.wait_for_load_state('networkidle')
page.wait_for_selector('div.results', timeout=10000)
Pattern 3: Capturing Console Logs
logs = []
page.on('console', lambda msg: logs.append(f"{msg.type}: {msg.text}"))
page.goto('http://localhost:3000')
page.wait_for_load_state('networkidle')
errors = [log for log in logs if 'error' in log.lower()]
print(f"Found {len(errors)} errors:", errors)
Pattern 4: Taking Screenshots
page.screenshot(path='full-page.png', full_page=True)
element = page.locator('.dashboard')
element.screenshot(path='dashboard.png')
try:
page.click('button.does-not-exist')
except:
page.screenshot(path='/tmp/error.png')
raise
Selector Strategies
Priority Order (Best to Worst)
- Text content:
text="Exact Text" or :has-text("Partial")
- Role + Name:
role=button[name="Submit"]
- Data attributes:
[data-testid="login-form"]
- IDs:
#unique-element-id
- CSS classes:
.specific-class (avoid generic names)
- XPath: Only as last resort
Examples
page.click('text="Log In"')
page.click('role=button[name="Save Changes"]')
page.click('[data-testid="checkout-button"]')
page.click('div > div:nth-child(3) > button')
page.click('.btn-primary')
Common Pitfalls
❌ DON'T
- Inspect DOM before
networkidle - Dynamic content not loaded yet
- Use overly specific selectors -
div > div > div > button breaks easily
- Forget to close browser - Resource leaks
- Skip waits - Race conditions cause flaky tests
- Read script source files - Use
--help instead, avoid context pollution
✅ DO
- Always wait for
networkidle before inspecting
- Use semantic selectors - text, role, data-testid
- Close browsers in
finally blocks
- Add explicit waits for dynamic content
- Use helper scripts as black boxes
Troubleshooting
Issue: Element Not Found
page.click('button')
page.wait_for_selector('button.submit')
page.click('button.submit')
Issue: Test Passes Locally, Fails in CI
- Add longer timeouts for slower CI environments
- Use
page.wait_for_load_state('networkidle')
- Take screenshots on failure for debugging
Issue: Flaky Tests
page.goto('http://localhost:3000')
page.click('button')
page.goto('http://localhost:3000')
page.wait_for_load_state('networkidle')
page.wait_for_selector('button:has-text("Start")')
page.click('button:has-text("Start")')
Issue: Server Not Starting
- Check if port is already in use:
lsof -i :3000
- Verify server command is correct
- Check server logs for errors
- Increase timeout in
with_server.py
Examples
The examples/ directory contains working demonstrations:
- element_discovery.py - How to find buttons, links, inputs
- static_html_automation.py - Testing local HTML files
- console_logging.py - Capturing browser console output
Best Practices
- Use helper scripts -
with_server.py handles lifecycle management
- Headless mode - Always use
headless=True for CI/automation
- Explicit waits - Better than
time.sleep()
- Semantic selectors - More resilient to UI changes
- Screenshots on failure - Essential for debugging
- Close resources - Use context managers or
finally blocks
Quick Reference
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto('http://localhost:3000')
page.wait_for_load_state('networkidle')
page.locator('button')
page.locator('text="Submit"')
page.locator('role=button[name="OK"]')
page.click('button')
page.fill('input', 'value')
page.select_option('select', 'option1')
page.check('input[type="checkbox"]')
page.wait_for_selector('.element')
page.wait_for_load_state('networkidle')
page.wait_for_timeout(1000)
assert page.locator('h1').text_content() == 'Welcome'
assert page.locator('.error').count() == 0
browser.close()
Resources