A standard expect() stops the test on the first failure. A form with four broken validation messages takes four test runs to fully diagnose. expect.soft() collects all failures and reports them together at the end, but the test still fails if any soft assertion failed: it continues running, not passing. This article covers the API, the decision criteria for hard versus soft assertions, how to inspect test.info().errors to stop early when a precondition fails, and the pattern that mixes hard assertions for critical page state with soft assertions for independent properties.
How soft assertions work
import { test, expect } from '@playwright/test';
test('user profile page has all required elements', async ({ page }) => {
await page.goto('/profile');
// Soft assertions — all run even if some fail
await expect.soft(page.getByLabel('Full name')).toBeVisible();
await expect.soft(page.getByLabel('Email')).toBeVisible();
await expect.soft(page.getByLabel('Phone number')).toBeVisible();
await expect.soft(page.getByRole('button', { name: 'Save changes' })).toBeEnabled();
await expect.soft(page.getByRole('img', { name: 'Profile photo' })).toBeVisible();
// This is where the test actually fails — if any soft assertion failed above
expect(test.info().errors).toHaveLength(0);
});Each expect.soft() runs. Failures are collected. The test continues. At the end (or whenever you check test.info().errors), all failures are reported at once.
Hard vs. soft assertions
Use hard assertions (expect) when:
- A failure makes the rest of the test meaningless (e.g., login failed, nothing else works)
- You're asserting a precondition, not an outcome
- One failure logically implies others (navigation failed → nothing on the page matters)
Use soft assertions (expect.soft) when:
- You're checking multiple independent properties of the same page
- Each failure is independently useful debugging information
- You're verifying a form, table row, or card has all its fields
test('order confirmation displays all order details', async ({ page }) => {
await page.goto('/orders/12345');
// Hard assertion — if this fails, nothing else matters
await expect(page.getByRole('heading', { name: 'Order #12345' })).toBeVisible();
// Soft assertions — independent checks, all useful
await expect.soft(page.getByText('Status: Confirmed')).toBeVisible();
await expect.soft(page.getByText('Delivery: 2–5 business days')).toBeVisible();
await expect.soft(page.getByText('Total: $149.99')).toBeVisible();
await expect.soft(page.getByRole('button', { name: 'Track order' })).toBeEnabled();
await expect.soft(page.getByRole('button', { name: 'Cancel order' })).toBeEnabled();
});Reading soft assertion failures
When a soft assertion fails, Playwright reports all failures together:
Error: 2 soft assertion(s) failed.
1) Soft assertion failed: expect(locator).toBeVisible()
Call log:
- waiting for page.getByText('Status: Confirmed')
Error: expect(locator).toBeVisible() with timeout 5000ms
Received: hidden
2) Soft assertion failed: expect(locator).toBeEnabled()
Call log:
- waiting for page.getByRole('button', { name: 'Cancel order' })
Error: expect(locator).toBeEnabled() with timeout 5000ms
Received: disabledTwo problems in one run, not one problem per run.
Soft assertions in form validation tests
Form validation is a classic use case: many error states to verify, all independent.
test('form shows all validation errors on empty submit', async ({ page }) => {
await page.goto('/register');
await page.getByRole('button', { name: 'Create account' }).click();
// Check all validation messages appear
await expect.soft(page.getByText('Email is required')).toBeVisible();
await expect.soft(page.getByText('Password is required')).toBeVisible();
await expect.soft(page.getByText('First name is required')).toBeVisible();
await expect.soft(page.getByText('Last name is required')).toBeVisible();
// Check the submit button is still available (not disabled after failed submit)
await expect.soft(page.getByRole('button', { name: 'Create account' })).toBeEnabled();
});Without soft assertions, you'd know about the first missing message and have to run the test three more times to find all four issues.
Checking errors explicitly
You can check soft assertion failures at any point in the test:
test('product page completeness check', async ({ page }) => {
await page.goto('/products/laptop-pro');
// Section 1: Hero section
await expect.soft(page.getByRole('heading', { level: 1 })).toBeVisible();
await expect.soft(page.getByRole('img', { name: /product/i })).toBeVisible();
await expect.soft(page.getByText(/\$\d+\.\d{2}/)).toBeVisible(); // Price
// If hero section has failures, stop here — no point checking the rest
if (test.info().errors.length > 0) {
test.fail();
return;
}
// Section 2: Only run if hero passed
await expect.soft(page.getByRole('button', { name: 'Add to cart' })).toBeEnabled();
await expect.soft(page.getByRole('tab', { name: 'Reviews' })).toBeVisible();
await expect.soft(page.getByRole('tab', { name: 'Specifications' })).toBeVisible();
});Mixing hard and soft assertions
The most effective pattern: hard assertions for preconditions and critical state, soft assertions for individual properties:
test('dashboard loads all widgets', async ({ page }) => {
await page.goto('/dashboard');
// Hard — if this fails, the page isn't loaded at all
await expect(page).toHaveTitle(/Dashboard/);
await expect(page.getByRole('main')).toBeVisible();
// Soft — each widget is independent
await expect.soft(page.getByTestId('revenue-widget')).toBeVisible();
await expect.soft(page.getByTestId('orders-widget')).toBeVisible();
await expect.soft(page.getByTestId('users-widget')).toBeVisible();
await expect.soft(page.getByTestId('activity-feed')).toBeVisible();
// Soft — each metric is independent
await expect.soft(page.getByTestId('revenue-amount')).toContainText('$');
await expect.soft(page.getByTestId('orders-count')).toContainText(/\d+/);
});When not to use soft assertions
Soft assertions collect failures but let the test continue. This can mask state problems:
// Dangerous — if the click didn't work, assertions below test the wrong state
await expect.soft(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await page.getByRole('button', { name: 'Submit' }).click();
await expect.soft(page.getByText('Order confirmed')).toBeVisible(); // might pass accidentallyIf actions depend on previous assertions passing, use hard assertions. Soft assertions are for observation (checking what's on the page), not for flow control.
→ See also: Playwright Assertions: The Complete Guide | Playwright Test Structure: describe, beforeEach, afterEach, and Hooks | How to Read Playwright Error Messages