Playwright architecture, selector reliability, and advanced interaction patterns.

Soft Assertions for Multi-Check Tests

An order confirmation screen states a dozen independent facts at once: an order number, a line-item table, a subtotal, tax, shipping, a delivery estimate, a payment method, a status badge. A test that verifies all of them with ordinary expect() calls reports exactly one problem per run, because the first mismatch throws and the remaining checks never execute. When a pricing change breaks four of those fields, you find out about them one CI run at a time. expect.soft() records a failed check without unwinding the test body, so a single run returns the full list of what is wrong.

Hard assertion versus soft assertion execution A hard assertion stops the test body at the first mismatch, while soft assertions run every check and report all failures together. Hard expect(): one failure per run assert total assert tax assert badge assert email Throw unwinds the test body — the last two checks never execute. Soft expect.soft(): every failure in one run assert total assert tax assert badge assert email Each mismatch is recorded and execution continues to the next check. Pink outline = failing check. Dashed outline = never reached.
The same four checks under a hard assertion and under soft assertions: one reported defect versus two.

Root cause: a rejected assertion unwinds the whole test body

Every Playwright web-first assertion is asynchronous and retries the underlying query until it passes or the expect timeout (5000 ms by default) elapses. On timeout it rejects with an Error whose message reads Timed out 5000ms waiting for expect(locator).toHaveText(expected), followed by the locator, the expected value, and the received value. Because the test body is an async function and you await the assertion, that rejection propagates out of the function immediately — the remaining statements are dead code for that run.

expect.soft() produces the same retry behaviour and the same error object, but instead of rejecting it pushes the error onto test.info().errors and resolves. The runner reads that array when the test body finishes and marks the test failed if it is non-empty, so all recorded errors reach the reporter together. This is the accumulation model that makes multi-check verification practical, and it is a core part of Web-First Assertions within Advanced Interactions & Test Assertions.

Minimal reproducible example

The test below verifies five independent facts about an order confirmation page with ordinary hard assertions. Only the first mismatch is ever reported, no matter how many of the five are broken.

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

test('order confirmation shows correct summary', async ({ page }) => {
  await page.goto('/orders/A-10427');

  // Each of these is an independent fact about the same rendered page.
  // They do not depend on one another, and none of them changes page state.
  await expect(page.getByTestId('order-id')).toHaveText('A-10427');
  await expect(page.getByTestId('subtotal')).toHaveText('$118.00');

  // Suppose tax is wrong. This assertion rejects after the 5s expect timeout
  // with: Timed out 5000ms waiting for expect(locator).toHaveText(expected)
  await expect(page.getByTestId('tax')).toHaveText('$10.40');

  // Everything below is unreachable for this run. If shipping and the status
  // badge are also wrong, you learn that only on the NEXT CI run, after the
  // tax bug is fixed — one defect per pipeline execution.
  await expect(page.getByTestId('shipping')).toHaveText('$0.00');
  await expect(page.getByRole('status')).toHaveText('Paid');
});

The pathology is not the assertion library — it is the mismatch between the control flow of exceptions and the shape of the verification. Five sibling facts are being checked with a mechanism designed for sequential dependencies. Reserve hard assertions for the places where the dependency is real, such as confirming the page navigated before you read anything off it, and cover sibling facts with soft assertions.

The cost of getting this wrong compounds in continuous integration. A suite that surfaces one defect per run turns a single broken release into a sequence of red pipelines, each consuming a full browser matrix to reveal one more line of the same regression. Engineers respond by re-running locally and stepping through checks by hand, which is precisely the work the suite was supposed to do. Reporting every mismatch from the first run collapses that loop into one triage pass.

How soft assertion failures accumulate Three soft assertions push their errors into the test.info().errors array, which the runner reads after the test body finishes to set the final status. Test body soft check: subtotal soft check: tax soft check: badge test.info().errors [0] tax mismatch [1] badge mismatch read after the body has finished status: failed 2 errors listed A passing soft check adds nothing; only recorded errors change the outcome.
Soft failures append to the error array rather than throwing, and the runner converts a non-empty array into a failed status once the body completes.

Step-by-step fix

  1. Convert independent checks to expect.soft(). Any assertion whose failure does not invalidate the statements after it becomes await expect.soft(locator).toHaveText(...). The matcher list, auto-retry behaviour, and timeout semantics are identical; only the failure handling changes. Keep the await — a soft assertion is still asynchronous.
  2. Keep hard assertions on the preconditions. Navigation, authentication, and "the page rendered at all" stay as expect(). If the confirmation view never loaded, twelve soft checks produce twelve near-identical timeouts that say nothing useful, so gate the batch with one hard assertion first.
  3. Attach a message to each soft assertion. Pass a description as the second argument — expect.soft(locator, 'tax line should include state tax') — because the report shows several errors side by side and generic locator text is hard to tell apart at a glance.
  4. Create a pre-configured soft expect for large batches. expect.configure({ soft: true, timeout: 2000 }) returns a new expect instance, which keeps the batch terse and shrinks the per-assertion timeout so a broken page cannot exhaust the whole test budget.
  5. Guard the next action with test.info().errors. Before any step that depends on the batch being clean, check test.info().errors.length and return early. Soft assertions do not stop the test, so without a guard you drive a page that is already known to be in the wrong state.
  6. Group batches inside test.step(). A step containing a soft failure is marked failed in the HTML report while execution continues, which gives the error list a readable hierarchy instead of a flat run of anonymous entries.
  7. Confirm all failures surface in one run. Break two fields deliberately and run the test; the report must list both errors under the same test. If it lists one, an unawaited assertion or a missing guard is swallowing the rest.

The rewritten test applies steps 1 through 6 together:

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

// A soft expect with a shorter budget: 12 broken checks at 5s each would blow
// the 30s test timeout, but at 1.5s each the whole batch still fits.
const check = expect.configure({ soft: true, timeout: 1500 });

test('order confirmation shows correct summary', async ({ page }) => {
  await page.goto('/orders/A-10427');

  // Precondition stays HARD: if the view never rendered, every soft check
  // below would time out and bury the real cause under identical noise.
  await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();

  await test.step('summary figures', async () => {
    // Independent facts about the same DOM — no ordering relationship.
    await check(page.getByTestId('order-id'), 'order id').toHaveText('A-10427');
    await check(page.getByTestId('subtotal'), 'subtotal').toHaveText('$118.00');
    await check(page.getByTestId('tax'), 'state tax line').toHaveText('$10.40');
    await check(page.getByTestId('shipping'), 'shipping').toHaveText('$0.00');
    await check(page.getByTestId('total'), 'grand total').toHaveText('$128.40');
  });

  await test.step('status and contact', async () => {
    await check(page.getByRole('status'), 'payment badge').toHaveText('Paid');
    await check(page.getByTestId('email')).toContainText('@');
  });

  // Guard: the summary is wrong, so exercising the reorder flow on top of it
  // would only generate derivative failures. Stop while the report is clean.
  if (test.info().errors.length > 0) return;

  await page.getByRole('button', { name: 'Reorder' }).click();
  await expect(page).toHaveURL(/\/cart/);
});

Two details are worth pausing on. First, expect.configure() returns a callable that behaves exactly like expect — you can pass soft, timeout, and message to it, and the resulting instance still exposes the full web-first matcher set including any matchers you added yourself, as described in Writing Custom expect Matchers. Second, the guard is a plain return, not test.fail(); the test is already destined to fail because errors is non-empty, and test.fail() would instead declare the failure expected.

Choosing between a hard and a soft assertion A decision tree that routes a check to a hard assertion when its failure blocks later steps and to a soft assertion otherwise. Failure blocks later steps? yes no use hard expect() use expect.soft() body stops here batch the checks guard next action
Route each assertion by asking whether its failure makes the following statements meaningless; only then does a hard stop earn its cost.

It is worth being precise about what soft mode does not change. It does not weaken the assertion: the same auto-retrying query runs, the same matcher logic applies, and a passing soft check is indistinguishable from a passing hard one. It does not make a test optional either — a recorded error fails the test as surely as a thrown one, so nothing is being suppressed. And it does not suspend timeouts: the assertion still polls until it succeeds or its budget expires, which is why a batch of failing soft checks is the slowest thing a test can do. The single behavioural difference is where the error goes, and every practice above follows from that one fact.

Troubleshooting variants

The test times out instead of listing the failures

The report shows Test timeout of 30000ms exceeded and no assertion detail, which means the soft batch consumed the whole test budget. Each failing soft assertion still retries for the full expect timeout before recording its error, so six broken checks at the 5000 ms default spend 30 seconds inside assertions alone. Lower the per-assertion budget for the batch with expect.configure({ soft: true, timeout: 1500 }), or set a project-wide floor through the expect.timeout key in playwright.config.ts and raise timeout for the tests that genuinely need longer. The interaction between assertion timeouts and test timeouts is covered in depth in Configuring Retries and Timeouts for Stable CI.

A soft failure never appears and the test passes

Almost always a missing await. Because expect.soft() resolves rather than rejects, forgetting to await it produces no unhandled rejection warning — the promise settles quietly, sometimes after the test body has already returned, so its error is appended too late to count or is attributed to nothing at all. Add eslint-plugin-playwright and enable its missing-playwright-await rule, together with @typescript-eslint/no-floating-promises, so the compiler catches it. The same trap hides inside array helpers: items.forEach(async (i) => { await expect.soft(...) }) never awaits its callbacks, so use a for...of loop instead.

Every soft failure says the same thing

Ten errors that all read Timed out 1500ms waiting for expect(locator).toBeVisible() usually mean the page never reached the expected state, not that ten things regressed. This is the cascade that step 2 exists to prevent: add one hard assertion on the container or heading before the batch, so a broken render fails once with a clear cause. A related variant is strict mode violation: locator resolved to 2 elements, repeated across the batch because a shared parent locator became ambiguous — narrow it with a role query as described in getByRole & Accessibility Selectors before treating the failures as separate defects.

Verification

Break two independent fields in a fixture or a mock response and run the test with npx playwright test --reporter=list. A correct soft batch prints both errors under the single test entry, each with its own message, locator, expected value, and received value; a batch that still reports one error has a hard assertion left in the middle of it. Next, open the HTML report and confirm the errors are nested under the test.step() that produced them, which is the fastest way to see whether the grouping in step 6 is doing its job — reporter output and attachment handling are explored further in Reporters & Test Artifacts.

Also verify the negative case, because it is the one that quietly rots. Run the test against a healthy build and confirm it passes with an empty error list; a soft batch that never fails is usually querying elements that no longer exist under a locator that matches nothing but is only ever asserted on for absence. Then re-run with --repeat-each=3 to confirm the shortened per-assertion timeout from step 4 is still generous enough for a cold cache, since a budget tuned on a warm local machine is the most common source of new instability after this refactor.

For a programmatic check, add an afterEach hook that logs test.info().errors.length alongside test.info().status, and assert during code review that the count matches the number of fields you deliberately broke. Finally, open the recording in the Playwright Trace Viewer: every soft assertion appears in the action timeline with its own duration, which makes an over-long batch obvious and shows exactly where the guard stopped execution.

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

test.afterEach(async () => {
  const info = test.info();
  // Non-zero on any run where a soft assertion recorded a failure.
  // Compare it against the number of defects you injected on purpose.
  console.log(`${info.title}: status=${info.status} softErrors=${info.errors.length}`);
  for (const error of info.errors) {
    // TestInfoError carries message, stack and the source location.
    console.log(`  - ${error.message?.split('\n')[0]}`);
  }
});

Frequently Asked Questions

Does a test that fails only through soft assertions still get retried?

Yes. Retries operate on the final status of the test, and a non-empty test.info().errors array produces a failed status exactly like a thrown assertion. The retry re-runs the whole test body from the start with a fresh fixture set, so the accumulated errors from the previous attempt are discarded rather than merged. If the soft failures are real regressions the retry simply reproduces them, which is the usual signal that the failure is deterministic rather than flaky.

Can I use soft assertions in fixtures, hooks, or a page object?

You can call expect.soft() anywhere test.info() is available, which includes fixtures, beforeEach, afterEach, and helper classes invoked from a test. Errors recorded in a beforeEach hook fail the test they precede, and errors recorded in afterEach fail the test that just ran. Recording soft failures inside a shared page object is generally a poor arrangement, though, because the helper then decides failure policy on behalf of every caller; return the values and let each test choose between a hard and a soft check.

How is expect.soft different from wrapping assertions in try/catch?

A try/catch around a hard assertion swallows the error object entirely unless you re-throw it, so you must collect and re-emit the messages yourself, and the reporter loses the structured expected and received values along with the step attribution. expect.soft() hands the original error to the runner intact, which keeps the diff formatting, the source location, and the trace-viewer entry. It also avoids the classic mistake of catching a timeout that was actually caused by a crashed page and reporting it as a content mismatch.

Should every assertion in a suite become soft?

No. Soft assertions are for verifying several independent facts about one settled state. If a check gates the next interaction — a login succeeded, a dialog opened, a row exists before you click it — a hard assertion is correct, because continuing past it produces derived failures that obscure the real cause. A reasonable rule is one hard gate per interaction boundary, then a soft batch for everything you want to observe about the resulting screen, and the same split applies when you compare UI state against backend data as in Asserting on API Responses Alongside UI.

Back to overview