How to fix Playwright assertions that ignore disabled/loading button states?

Playwright

Clicking a button that is disabled or in a loading state produces no action — the click goes through Playwright's actionability checks, but an aria-disabled button or a button replaced by a spinner may either accept the click silently or reject it with a timeout. The result is a test that either does nothing useful or fails with a confusing error. Testing the button's readiness before acting is both more robust and more semantically correct.

Common mistake

test('submits checkout form', async ({ page }) => {
  await page.goto('https://app.example.com/checkout');

  // Address form may take time to validate — button disabled until valid
  await page.getByRole('button', { name: 'Place order' }).click();
  // Clicks immediately — button is disabled, nothing happens
  // Test proceeds and next assertion fails confusingly
});

Also common when a form spinner overlay replaces the button during submit:

await page.getByRole('button', { name: 'Save' }).click();
// Button changes to "Saving..." and becomes disabled during async operation
// A second click attempt in a loop-style pattern does nothing

The fix

Assert the button is enabled before clicking, and assert completion indicators after:

import { test, expect } from '@playwright/test';

test('submits checkout with valid form', async ({ page }) => {
  await page.goto('https://app.example.com/checkout');

  // Fill required fields
  await page.getByLabel('Full name').fill('Ada Lovelace');
  await page.getByLabel('Card number').fill('4242 4242 4242 4242');
  await page.getByLabel('Expiry').fill('12/28');
  await page.getByLabel('CVC').fill('123');

  const submitButton = page.getByRole('button', { name: 'Place order' });

  // Wait until form validation enables the button
  await expect(submitButton).toBeEnabled({ timeout: 5000 });
  await submitButton.click();

  // Wait for loading state to clear — button may be disabled during processing
  await expect(submitButton).not.toHaveText(/processing|saving/i, { timeout: 15000 });

  // Assert success state
  await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible({
    timeout: 15000,
  });
});

For spinners that replace button content:

test('saves profile settings', async ({ page }) => {
  await page.goto('https://app.example.com/profile');

  await page.getByLabel('Display name').fill('Ada Byron');

  const saveButton = page.getByRole('button', { name: 'Save changes' });
  await expect(saveButton).toBeEnabled();
  await saveButton.click();

  // Wait for loading spinner inside button to disappear
  await expect(page.getByTestId('save-spinner')).toBeHidden({ timeout: 10000 });

  // Now assert the actual outcome
  await expect(page.getByRole('alert')).toContainText('Profile updated');
});

Why it works

Playwright's toBeEnabled() assertion polls until the button's disabled attribute is absent and aria-disabled is not true. This absorbs the time the application spends validating form state before enabling submission. Waiting for the loading indicator to disappear before asserting success state avoids the race condition where the assertion fires during the async operation. These two waits bracket the action correctly: enabled before, finished after.

Tips

  • toBeEnabled() checks both the HTML disabled attribute and the aria-disabled ARIA attribute — both are handled without extra work.
  • If the button stays enabled during loading but shows a spinner inside it, use expect(button).not.toContainText('...') or wait for the spinner element to be hidden.
  • For multi-step forms where the "Next" button is only enabled when required fields are complete, toBeEnabled() before each step click is a reliable pattern and also implicitly tests form validation behavior.
  • A button that is visually loading but still enabled in the DOM may receive double-click if code retries the click action — check for this pattern by looking at the network requests in the Playwright trace.