📖 The Story: The Invisible Button

Maya wrote a test that clicked a "Submit" button. It worked on her machine. In CI, it failed with: "Element is not clickable — element has pointer-events: none." The button existed in the DOM, it was visible, but a CSS animation hadn't finished, and the button was temporarily covered by a loading spinner. The test clicked right through the spinner and missed the button entirely.

Maya added await page.waitForTimeout(2000). It worked—until the CI server was slow and 2 seconds wasn't enough. Then she learned about Playwright's actionability checks. Playwright automatically waits until the button is visible, stable, enabled, and not obscured. No timeouts needed. No flaky failures. Just reliable automation.

🎯 Why Actionability Matters

🛡️

Auto-Wait

Playwright waits for elements to be ready before acting—no manual waits needed.

🎯

Precision

Clicks go to the exact element you targeted, even if the page shifts.

🔍

Debugging

When an action fails, Playwright tells you exactly WHY—covered, disabled, detached, or animating.

🧒 Explain Like I'm 10

Imagine you're at a crosswalk. The sign says "Don't Walk." You could run across anyway (that's what Selenium does sometimes)—but you might get hit. Playwright is like a patient friend who watches the sign and ONLY crosses when it says "Walk." That's actionability: waiting until it's safe to act.

🎓 Professional Explanation

Before Playwright performs any action (click, fill, hover, etc.), it runs a series of actionability checks. These checks verify that the element is: (1) Attached to the DOM, (2) Visible, (3) Stable (not animating), (4) Enabled (not disabled), and (5) Receives events (not covered by another element). If any check fails, Playwright waits and retries until the timeout expires. This eliminates the vast majority of flaky test failures caused by timing issues.

👆 Click Actions

typescript
// Simple click
await page.getByRole('button', { name: 'Submit' }).click();

// Click with specific mouse button (left, right, middle)
await page.getByRole('button', { name: 'Submit' }).click({ button: 'right' });

// Click with modifier keys
await page.getByRole('link', { name: 'Open' }).click({ modifiers: ['Control'] });

// Double click
await page.getByText('Edit me').dblclick();

// Force click (skip actionability checks — use with caution!)
await page.getByRole('button', { name: 'Submit' }).click({ force: true });

When to use force: true: Only when you intentionally need to click an element that is genuinely covered by something (e.g., a chat widget overlay) and you know it's safe. Force-clicking skips ALL actionability checks, which can lead to unexpected behavior.

⌨️ Fill vs Type

MethodBehaviorSpeedUse When
.fill(value)Clears field, sets value instantly⚡ FastStandard form filling (default choice)
.type(text)Simulates individual keypresses🕐 SlowSearch autocomplete, real-time validation
.press(key)Presses a single key or key combo⚡ FastEnter, Tab, Escape, Ctrl+A, etc.
typescript
// fill() — instant, clears existing value first
await page.getByLabel('Email').fill('admin@test.com');

// type() — character by character (triggers key events)
await page.getByLabel('Search').type('Playwright');

// type() with delay between keystrokes (like a human)
await page.getByLabel('Search').type('Playwright', { delay: 100 });

// press() — single key or keyboard shortcut
await page.getByLabel('Search').press('Enter');
await page.getByLabel('Search').press('Control+a');

// Clear a field
await page.getByLabel('Email').clear();

☑️ Checkbox & Radio

typescript
// Check a checkbox
await page.getByRole('checkbox', { name: 'Accept terms' }).check();

// Uncheck a checkbox
await page.getByRole('checkbox', { name: 'Newsletter' }).uncheck();

// Assert checkbox is checked
await expect(page.getByRole('checkbox', { name: 'Accept terms' })).toBeChecked();

// Select a radio button
await page.getByRole('radio', { name: 'Credit Card' }).check();

📋 Select Dropdowns

typescript
// Select by value attribute
await page.getByLabel('Country').selectOption('us');

// Select by visible text label
await page.getByLabel('Country').selectOption({ label: 'United States' });

// Select by index
await page.getByLabel('Country').selectOption({ index: 2 });

// Multi-select
await page.getByLabel('Colors').selectOption(['red', 'blue']);

🎹 Keyboard & Mouse

typescript
// Keyboard — page-level keyboard input
await page.keyboard.press('Escape');
await page.keyboard.press('Control+Shift+I');
await page.keyboard.type('Hello World');

// Mouse — low-level mouse control
await page.mouse.click(100, 200); // x, y coordinates
await page.mouse.dblclick(100, 200);
await page.mouse.hover(100, 200);

// Hover over an element (preferred over mouse coordinates)
await page.getByRole('link', { name: 'Menu' }).hover();

// Drag and drop
await page.getByText('Item A').dragTo(page.getByText('Drop Zone'));

⚠️ Common Mistakes

Mistake 1: Using click() Before the Element Is Ready

typescript · ❌ Wrong
// ❌ Manually waiting before click — unnecessary and flaky
await page.waitForSelector('#submit');
await page.locator('#submit').click();

// ✅ Just click — Playwright auto-waits for actionability
await page.getByRole('button', { name: 'Submit' }).click();

Mistake 2: Using type() When fill() Is Better

typescript · ❌ Slow
// ❌ type() is slow for simple form filling
await page.getByLabel('Email').type('admin@test.com');

// ✅ fill() is instant and recommended
await page.getByLabel('Email').fill('admin@test.com');

✅ Best Practices

  1. Use fill() by default — it's fast and reliable. Use type() only for real-time search.
  2. Trust actionability checks — don't add manual waits before clicks.
  3. Use check()/uncheck() for checkboxes — not click().
  4. Use selectOption() for dropdowns — not click() + click().
  5. Use force: true sparingly — only when you understand the consequences.
  6. Prefer locators over coordinatesgetByRole().click() > mouse.click(x, y).

🏗️ Senior Engineer Deep Dive

The 5 Actionability Checks

Before every action, Playwright runs these checks in order:

  1. Attached: Element exists in the DOM.
  2. Visible: Element has non-zero dimensions and is not hidden (display:none, visibility:hidden, opacity:0).
  3. Stable: Element is not animating (bounding box hasn't changed in the last 2 animation frames).
  4. Enabled: Element doesn't have disabled attribute.
  5. Receives Events: Element is the topmost hit target at the click point — no overlay is blocking it.

If any check fails, Playwright re-evaluates at the next animation frame until the action timeout (default 0ms for actions, configurable). This is why Playwright rarely needs explicit waits.

fill() vs type() Under the Hood

fill() dispatches a single input and change event after clearing the field. type() dispatches keydown, keypress, keyup, and input for EACH character. This is why type() triggers autocomplete listeners but fill() may not for some frameworks.

💼 Interview Questions

Beginner
What is the difference between fill() and type()?
Show answer
fill() clears the field and sets the value instantly. type() simulates individual keypresses character by character. Use fill() for standard form filling (faster). Use type() when you need to trigger real-time events like search autocomplete.
Intermediate
What are Playwright's actionability checks?
Show answer
Before any action, Playwright verifies the element is: (1) Attached to DOM, (2) Visible, (3) Stable (not animating), (4) Enabled (not disabled), (5) Receives events (not obscured). If any check fails, Playwright retries until timeout.
Advanced
Why might fill() not trigger an autocomplete in a React/Angular app?
Show answer
fill() dispatches only input and change events at the DOM level. Some React/Angular libraries listen for keydown/keyup events specifically. Use type() instead, which dispatches the full keyboard event sequence per character, or use page.fill() combined with triggering input events manually via page.evaluate().

🏋️ Practical Exercises

🛠️ Hands-On Beginner

Form Interaction

  1. Navigate to https://demo.playwright.dev/todomvc.
  2. Use fill() to type "Learn Playwright" into the input.
  3. Press Enter using press('Enter').
  4. Verify the todo item appears.
  5. Add another item using type() with a 50ms delay to see the difference.
🔥 Challenge Intermediate

Advanced Interactions

  1. Write a test that right-clicks a link and verifies a context menu appears.
  2. Write a test that hovers over a menu item and verifies a dropdown appears.
  3. Write a test that uses keyboard shortcuts (Ctrl+A to select all text in an input).
  4. Test drag-and-drop by dragging an item in a sortable list.

🧠 Quiz

1. Which method is preferred for filling form inputs?
type()
fill()
press()
write()
2. What does click({ force: true }) do?
Clicks harder
Skips actionability checks
Clicks twice
Clicks the parent element
3. Which method should you use for a checkbox?
click()
check()
fill()
select()
4. How do you simulate pressing Enter in an input field?
.fill('Enter')
.press('Enter')
.type('Enter')
.key('Enter')
5. How many actionability checks does Playwright perform?
3
4
5
2
6. When should you use type() instead of fill()?
When you need speed
When testing autocomplete or real-time search
When the field is empty
When the field has validation
7. How do you select an option from a dropdown by its label text?
.fill('Option')
.selectOption({ label: 'Option' })
.click('Option')
.type('Option')
8. What does "Receives Events" actionability check verify?
Element has event listeners
No other element is covering/obscuring the target
Element is an interactive element
Element has been clicked before
9. How do you perform a double-click?
.click({ clickCount: 2 })
.dblclick()
.doubleClick()
.click().click()
10. What is the correct way to drag and drop?
mouse.down() + mouse.up()
.dragTo(targetLocator)
.moveTo(target)
.drop(target)

❓ FAQ

What if my click seems to work but nothing happens?
Show answer
The element you're clicking may not be the one you think. Playwright clicks the center of the element. If the center is covered by an overlay, Playwright throws an error. If there's no error but nothing happens, you might be clicking the wrong element. Use --debug mode to see exactly what's happening.
Can I click on coordinates instead of an element?
Show answer
Yes. Use page.mouse.click(x, y) for coordinate-based clicks. However, this is fragile—any layout change shifts coordinates. Always prefer element-based clicks via locators.
How do I upload a file?
Show answer
Use page.setInputFiles(): await page.getByLabel('Upload').setInputFiles('path/to/file.pdf'). For multiple files, pass an array. To remove files, pass an empty array.

📌 Summary

🎯 Key Takeaways

  • fill() is the default for text input — fast, clears first, sets value instantly.
  • type() simulates keypresses — use for autocomplete and real-time search.
  • Actionability checks auto-wait for elements to be ready — no manual waits needed.
  • 5 checks: Attached → Visible → Stable → Enabled → Receives Events.
  • check()/uncheck() for checkboxes — not click().
  • selectOption() for dropdowns — not click() chains.
  • force: true skips checks — use only when you know what you're doing.
  • Prefer locators over mouse coordinates for maintainable tests.