Playwright Soft Assertions Deep Dive: Collect Every Failure
Every QA engineer has felt the frustration: a single failed assertion halts your Playwright test, hides three other real bugs behind it, and forces you to fix-rerun-fix-rerun in a slow loop. Playwright soft assertions expect.soft solve exactly this by letting a test continue running after a check fails, then collecting every failure at the end. In this deep dive you will learn how expect.soft works under the hood, when to reach for it, how to read multiple failures cleanly, and the patterns that keep soft assertions from quietly masking broken tests.
🎠Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.
Contents
What Are Playwright Soft Assertions?
By default, every Playwright assertion is a hard assertion. The moment expect(locator).toHaveText('Welcome') fails, Playwright throws an error and the test function stops immediately. Nothing after that line executes. A soft assertion behaves differently: when expect.soft(locator).toHaveText('Welcome') fails, Playwright records the failure but lets the test keep running. At the end of the test, if any soft assertion failed, the test is marked as failed and every collected error is reported together.
This is the same idea as "collect all errors" verification in TestNG's SoftAssert or AssertJ's soft assertions, but baked directly into Playwright's auto-waiting expect API. You get the full power of web-first assertions (retries, auto-waiting, actionability) while still validating multiple independent properties of a page in a single pass.
Hard vs Soft Assertions: The Core Difference
Consider validating a checkout summary page with four independent fields. With hard assertions, a wrong subtotal stops the test before it ever checks the tax, shipping, or total. You fix the subtotal, rerun, and only then discover the tax is also wrong. Soft assertions check all four in one run.
| Aspect | Hard assertion (expect) | Soft assertion (expect.soft) |
|---|---|---|
| On failure | Throws immediately, stops the test | Records error, test continues |
| Failures reported | Only the first one | All collected failures |
| Auto-waiting / retries | Yes | Yes (identical engine) |
| Best for | Preconditions, blocking checks | Independent field validation |
| Risk | Hides later bugs | Can continue on an unusable page |
Your First expect.soft Example
Using soft assertions requires no special import or configuration. You simply call .soft on the standard expect object. Here is a checkout summary validated field by field. If the subtotal is wrong, Playwright still checks tax, shipping, and total before the test ends.
import { test, expect } from '@playwright/test';
test('checkout summary shows correct totals', async ({ page }) => {
await page.goto('https://shop.example.com/checkout');
// None of these stop the test on failure.
await expect.soft(page.getByTestId('subtotal')).toHaveText('$120.00');
await expect.soft(page.getByTestId('tax')).toHaveText('$9.60');
await expect.soft(page.getByTestId('shipping')).toHaveText('$5.00');
await expect.soft(page.getByTestId('total')).toHaveText('$134.60');
// The test is marked failed here if any soft assertion above failed,
// and the report lists every mismatch at once.
});
When this test runs against a buggy page, the Playwright HTML report does not show one cryptic error. It shows every field that did not match, each with its own expected-versus-received diff. That single run gives your developers a complete punch list instead of one symptom.
Mixing Soft and Hard Assertions Intelligently
The smartest tests combine both. Use a hard assertion as a gate to confirm you are on the right page or that a critical element exists, then switch to soft assertions to validate the details. If the gate fails, there is no point checking fields on a page that never loaded.
import { test, expect } from '@playwright/test';
test('profile page renders user details', async ({ page }) => {
await page.goto('https://app.example.com/profile');
// HARD gate: if the profile header is missing, stop now.
await expect(page.getByRole('heading', { name: 'My Profile' })).toBeVisible();
// SOFT checks: validate every field, collect all mismatches.
await expect.soft(page.getByLabel('Full name')).toHaveValue('Pramod Dutta');
await expect.soft(page.getByLabel('Email')).toHaveValue('pramod@example.com');
await expect.soft(page.getByLabel('Country')).toHaveValue('India');
await expect.soft(page.getByRole('img', { name: 'avatar' })).toBeVisible();
});
This gate-then-collect pattern prevents the most common soft-assertion smell: a test that produces twenty meaningless failures simply because a page redirected to a login screen. The hard assertion fails fast and clearly when the precondition is broken.
Stopping the Test on Demand With test.info().errors
Sometimes you want to run a block of soft assertions, then decide whether to bail out before doing expensive follow-up work. Playwright records every soft failure in test.info().errors. You can inspect that array mid-test and stop manually if anything has already failed.
import { test, expect } from '@playwright/test';
test('validate form then submit only if clean', async ({ page }) => {
await page.goto('https://app.example.com/signup');
await expect.soft(page.getByLabel('Username')).toHaveValue('pramod');
await expect.soft(page.getByLabel('Email')).toHaveValue('pramod@example.com');
await expect.soft(page.getByLabel('Plan')).toHaveText('Pro');
// Bail out before submitting if any field check already failed.
expect(test.info().errors.length < 1).toBeTruthy();
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByText('Welcome aboard')).toBeVisible();
});
Here the final hard expect(...).toBeTruthy() acts as a checkpoint. If the soft block collected any errors, the checkpoint throws and the submit step never runs. This gives you the best of both worlds: collect all field-level mismatches, but still avoid acting on bad data.
Soft Assertions in a Page Object Model
Soft assertions shine inside reusable verification methods on a Page Object. A single verifyDashboard() method can assert a dozen widgets softly, and any test that calls it gets a complete report of what is broken on the dashboard.
import { Page, expect, Locator } from '@playwright/test';
export class DashboardPage {
readonly page: Page;
readonly greeting: Locator;
readonly revenueCard: Locator;
readonly usersCard: Locator;
readonly chart: Locator;
constructor(page: Page) {
this.page = page;
this.greeting = page.getByRole('heading', { name: /Good morning/ });
this.revenueCard = page.getByTestId('card-revenue');
this.usersCard = page.getByTestId('card-users');
this.chart = page.getByTestId('trend-chart');
}
async verifyLayout(): Promise<void> {
await expect.soft(this.greeting).toBeVisible();
await expect.soft(this.revenueCard).toContainText('$');
await expect.soft(this.usersCard).toContainText(/\d+/);
await expect.soft(this.chart).toBeVisible();
}
}
Because each check is soft, a missing chart will not hide a broken revenue card. The test author calls await dashboard.verifyLayout() and reads one consolidated failure list. Keep soft assertions for verification helpers and reserve hard assertions for navigation guards inside action methods.
Custom Soft Assertions With expect.extend
Custom matchers created with expect.extend automatically gain a .soft variant. You do not write soft logic yourself; once the matcher is registered, expect.soft(value).toBeWithinBudget(...) just works. This lets domain-specific checks join the collect-all-failures flow.
import { expect as baseExpect } from '@playwright/test';
export const expect = baseExpect.extend({
async toHaveStatusBadge(locator, expected: string) {
const text = (await locator.innerText()).trim();
const pass = text === expected;
return {
pass,
message: () =>
`Expected status badge to be "${expected}" but got "${text}"`,
name: 'toHaveStatusBadge',
expected,
actual: text,
};
},
});
// In a test, the .soft variant exists automatically:
// await expect.soft(row.getByTestId('status')).toHaveStatusBadge('Active');
This is powerful for tables and dashboards: build one matcher that encodes your business rule, then apply it softly across every row so a single run surfaces every offending record.
🚀 Level Up Your Playwright
From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.
Validating Many Rows With a Soft Assertion Loop
One of the most practical uses of soft assertions is data validation across a list or grid. Imagine an orders table where every row should show a positive amount and a known status. With hard assertions, the first bad row aborts the test and you never learn how many other rows are broken. A soft loop walks the entire table and reports every offending row in one pass.
import { test, expect } from '@playwright/test';
test('every order row is well formed', async ({ page }) => {
await page.goto('https://app.example.com/orders');
const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
const count = await rows.count();
expect(count).toBeGreaterThan(0); // hard gate: table is not empty
const allowed = ['Paid', 'Pending', 'Refunded'];
for (let i = 0; i < count; i++) {
const row = rows.nth(i);
const amount = (await row.getByTestId('amount').innerText()).replace(/[^0-9.]/g, '');
const status = (await row.getByTestId('status').innerText()).trim();
// Each row checked softly so one bad row never hides the others.
expect.soft(Number(amount), `row ${i} amount should be positive`).toBeGreaterThan(0);
expect.soft(allowed, `row ${i} status "${status}" is unknown`).toContain(status);
}
});
Notice the second string argument passed to expect.soft. That is the assertion message, and it is invaluable in a loop: it tells you exactly which row index failed and why, so the consolidated report reads like a defect list rather than a wall of identical errors. Combining a hard gate (the table is not empty) with a soft loop is the canonical pattern for data-driven verification.
How Soft Failures Appear in Reports and CI
Understanding where soft failures surface changes how you triage them. When a test with soft assertions fails, Playwright attaches every collected error to that test result. In the html reporter you see a numbered list of failures, each with its own expected-versus-received snippet and a link into the trace at the moment of failure. In the list or line reporters used in CI, the same errors print sequentially under the failing test name.
- Exit code: a test with any failed soft assertion exits non-zero, so CI correctly marks the run red. Soft does not mean optional.
- Trace and screenshots: with
trace: 'on-first-retry'andscreenshot: 'only-on-failure'in your config, every soft failure is captured in the trace timeline, not just the first. - Retries: if you enable test
retries, a test that failed only on soft assertions is retried like any other failure, which helps you distinguish flaky checks from real regressions.
The takeaway is that soft assertions are a reporting and ergonomics feature, not a way to weaken your suite. A failed soft assertion is a real failure that turns CI red, blocks merges, and demands a fix, exactly like a hard one.
Best Practices and Common Pitfalls
- Never soft-assert a precondition. If a page must load before checks make sense, gate it with a hard assertion so you fail fast and clearly.
- Do not soft-assert then immediately act on the result. Reading a value you just failed to validate softly can throw a confusing secondary error. Use the
test.info().errorscheckpoint instead. - Keep soft blocks independent. Soft assertions are ideal for unrelated fields. If check B only makes sense when check A passed, B should not be soft.
- Watch the retry interaction. Each soft assertion still auto-waits up to the configured timeout, so twenty failing soft assertions on a slow page can add twenty timeouts. Tune timeouts for verification-heavy tests.
- Read the full report. Soft failures are most valuable in the HTML reporter, where every collected error appears with its own diff and trace.
Conclusion
Playwright soft assertions expect.soft turn the slow fix-rerun-fix loop into a single decisive run that reports every defect at once. Use hard assertions as gates for preconditions, switch to expect.soft for independent field validation, lean on test.info().errors to bail out before risky steps, and let expect.extend bring your custom matchers into the same collect-all-failures flow. Applied with discipline, soft assertions make your suites faster to triage and far more honest about the true state of the application under test.
FAQ
Does expect.soft still auto-wait and retry like a normal Playwright assertion?
Yes. expect.soft uses the exact same web-first assertion engine as expect, including auto-waiting and polling up to the configured timeout. The only difference is that on final failure it records the error and lets the test continue instead of throwing immediately. This means a flaky element still gets retried before a soft assertion is counted as failed.
How do I stop a test early when a soft assertion has already failed?
Inspect test.info().errors, which holds every soft failure collected so far. Add a hard checkpoint such as expect(test.info().errors.length < 1).toBeTruthy() after your soft block. If any soft assertion failed, the checkpoint throws and prevents the remaining steps from running, which is useful before submitting forms or performing destructive actions.
When should I use hard assertions instead of soft ones?
Use hard assertions for preconditions and blocking checks: confirming the correct page loaded, a required element exists, or a critical navigation succeeded. If continuing past a failure would only produce a cascade of meaningless errors, that check should be hard. Reserve soft assertions for validating multiple independent properties where you genuinely want to see every mismatch in one run.
🎓 Master Playwright End to End
Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.
