Assertions

An assertion is the line in an automated test that compares the actual result with the expected result and fails the test when they differ. await expect(page.getByTestId('order-total')).toHaveText('172.50 SAR') is one: if the page shows 150.00 SAR, the test fails and the report shows both values. The steps before it only drive the application; assertions are what turn a script into a test.

This lesson covers what to check, hard and soft assertions, retrying (web-first) assertions and failure messages. The examples use Playwright Test with TypeScript, as in the previous lessons; the ideas carry over to Cypress, JUnit, TestNG and pytest, and the differences are named where they matter.

Why it matters

A test finds only the defects its assertions look for. A checkout test that asserts only that "a total is visible" stays green when the VAT is not added, and the green run tells the team the feature works. ISTQB calls a result that misses a defect that is present a false-negative result; weak assertions produce them silently.

The opposite error is expensive too. Assertions that read the page too early, or check details nobody specified, fail when nothing is wrong: false-positive results. Each false alarm costs an investigation, and a team that sees many of them starts re-running red builds instead of reading them. Precise, retrying assertions with clear messages keep a suite trustworthy: when it fails, the message tells you whether to open a defect.

What to check: the outcome, exactly

Every assertion needs an expected result from a test oracle: the requirement, the acceptance criteria, the API documentation or the test case. Never take it from what the application shows today; copying the current output into the assertion turns an existing defect into "expected" behaviour.

A strong assertion checks the outcome the oracle describes, as precisely as the oracle fixes it. A weak assertion checks something that is also true when the feature is broken: an element exists, a value is not empty, the response is "OK". The running example in this lesson: two items worth 150.00 SAR plus 15% VAT must give a total of 172.50 SAR.

  • Exact values where the requirement fixes them: toHaveText('172.50 SAR'), not toBeVisible() on the total. Money, counts, statuses and messages are compared exactly.
  • Patterns only for parts that really vary: an order number such as ORD-418305 is checked with /^ORD-\d{6}$/, not with toBeTruthy() and not with the number from one run.
  • Counts, not "at least one": toHaveCount(2), not a check that count() is greater than 0.
  • The effect, not only the absence of an error: after the order the cart badge shows 0; if the API documentation says that creating an order returns 201 with the new order in the body, check that status and those values, not just any 2xx status.
  • Absence with an anchor: "no error banner" is also true on a blank page, so assert a positive signal first, then the absence.
  • Not too much: CSS classes, page structure, timestamps and generated ids make a test fail on harmless changes. Assert one behaviour per test, with as many assertions as it takes to prove it.
Order confirmation with a 172.50 SAR total (150.00 SAR plus 15% VAT): weak assertions stay green when the VAT is missing; strong ones compare exact values, retry, and fail with both values.

Weak assertionCaution

Checks something that is also true when the feature is broken

Order total:
isVisible() is true: some total is shown
Order number:
toBeTruthy(): any text, even ORD-
Ordered items:
count() greater than 0
API that creates the order:
res.ok(): any 2xx status
How it reads the page:
Once: may read it before it updates
Failure message:
Expected: true Received: false
Defect: VAT not added:
Stays green: the defect is missedPass

Strong assertion

Checks the outcome the requirement specifies, exactly

Order total:
toHaveText('172.50 SAR'): the right total
Order number:
/^ORD-\d{6}$/: ORD and six digits
Ordered items:
toHaveCount(2)
API that creates the order:
Status 201 and the new values in the body
How it reads the page:
Retries until it passes or the timeout ends
Failure message:
Expected: "172.50 SAR" Received: "150.00 SAR"
Defect: VAT not added:
Turns red with both valuesFail

The checks are written with Playwright Test; Cypress, JUnit, TestNG and pytest have equivalents.

Try it: REST API Tester Try it: JSON Path Tester

Hard and soft assertions

A hard assertion stops the test at the first failure: expect throws, the runner records the error, and the lines after it never run. This is the default in every framework, and it is right when later steps depend on the check: if the confirmation page did not open, asserting on its total only adds noise.

A soft assertion records the failure and lets the test continue; at the end the test is still marked failed, with every failed check listed. Use it for independent facts about one result, such as the fields of one page or one response, so a single run shows every mismatch instead of one per fix-and-rerun cycle. Keep preconditions hard.

  • JUnit 5 and later: assertAll(...) runs a group of assertions and reports every failure in the group; AssertJ offers SoftAssertions.
  • TestNG: SoftAssert reports only when assertAll() is called at the end; forget that call and the failures are lost.
  • pytest: a plain assert is hard; soft checks come from plugins.
// Hard: the precondition. If it fails, the checks below would mean nothing.
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();

// Soft: independent facts about the same page, all reported together.
await expect.soft(page.getByTestId('order-status')).toHaveText('Paid');
await expect.soft(page.getByTestId('order-item')).toHaveCount(2);
await expect.soft(page.getByTestId('order-total')).toHaveText('172.50 SAR');

Retrying (web-first) assertions

A page changes after every action: the click returns, the request reaches the server, and only then does the text update. An assertion that reads the page once can read it too early. Retrying assertions, which the Playwright documentation calls auto-retrying or web-first assertions, take a locator instead of a value and check again until the condition is met or a timeout ends (5 seconds by default in Playwright, configurable). Cypress retries the query before .should() in the same way. Selenium has no retrying assertions: wait for the condition with WebDriverWait, then assert.

The difference is where the await sits. await expect(locator).toHaveText('Paid') hands the locator to the assertion, which keeps asking the page. expect(await locator.textContent()).toBe('Paid') reads once and compares a string that will never change, so the result depends on timing, a classic cause of flaky tests. Generic matchers such as toBe and toEqual do not retry on their own; use them for values that are already complete, such as a parsed response body. For values outside the page, expect.poll() calls a function again and again and applies the matcher to each new result.

  • Retrying in Playwright: toBeVisible, toHaveText, toContainText, toHaveValue and toHaveCount on a locator; toHaveURL and toHaveTitle on a page.
  • Negative checks retry too: not.toBeVisible() waits for an element to disappear, and passes at once if it never appeared, which is why it needs an anchor.
  • Retrying waits for the right answer; it does not fix a wrong expectation. Raise the timeout only for a known slow step, on that one assertion. The lesson on waits and synchronization goes deeper.
// Reads once: may compare the text from before the server answered
expect(await page.getByTestId('order-status').textContent()).toBe('Paid');

// Retries until the text is "Paid" or the timeout ends (5 s by default)
await expect(page.getByTestId('order-status')).toHaveText('Paid');

// A value outside the page: poll the API until the order is paid
await expect.poll(async () => {
  const res = await request.get('/api/orders/' + orderId);
  return (await res.json()).status;
}).toBe('PAID');

Failure messages that explain themselves

When a test fails in a night run, its failure message is often the first thing anyone reads. A useful message answers three questions without opening the code: what was checked, what was expected and what was received. Matchers that compare real values give you the last two for free; a boolean throws them away. The outputs below are shortened; the exact layout depends on the runner version.

  • Prefer matchers that carry the values (toHaveText, toEqual, toHaveCount) to toBe(true) and toBeTruthy().
  • Add a custom message where the matcher cannot know the context: the second argument of expect in Playwright, the last argument of assertEquals in JUnit 5 and later, the text after the comma in pytest: assert total == 172.5, "order total with VAT".
  • Write it as the business fact, like a bug title: "order status after card payment", not "check 3". When a test loops over data, include the case in the message.
  • For objects, toEqual prints a diff of the fields that differ; toMatchObject compares only the fields you list, which keeps timestamps out of the comparison.
Weak:    expect(await status.isVisible()).toBe(true);

         Error: expect(received).toBe(expected)
         Expected: true
         Received: false

Strong:  await expect(status, 'order status after card payment').toHaveText('Paid');

         Error: order status after card payment
         Expected string: "Paid"
         Received string: "Pending"

Try it: Text Diff Try it: JSON Diff

Worked example: strengthening a checkout test

Requirement: "After payment, the confirmation page shows the order number (ORD- and six digits), the status Paid, the ordered items and the total including 15% VAT. The cart is emptied." The test cart holds two items worth 150.00 SAR, so the expected total is 172.50 SAR.

  • Before, the first two checks read once and accept anything: a total of 150.00 SAR, or an order number of ORD- with no digits, stays green. After, the exact total and the number pattern are compared, with retries.
  • The "Order confirmed" heading is hard: without the confirmation page nothing else means anything. The five facts from the requirement are soft, so one run reports every one that is wrong.
  • The check on "Error" is gone: it passed on a blank page, and once the heading and the values are proven it adds nothing.
  • The new test title states the behaviour, so the report reads like the requirement. The order time and the CSS classes are not asserted: the requirement does not mention them.
// Before: stays green as long as the page opens
test('place order', async ({ page }) => {
  const checkout = new CheckoutPage(page);
  await checkout.payWithTestCard();
  expect(await page.locator('.total').isVisible()).toBeTruthy();
  expect(await page.locator('.order-no').textContent()).toBeTruthy();
  await expect(page.getByText('Error')).not.toBeVisible();
});

// After: each assertion proves one part of the requirement
test('card payment confirms order with VAT and empties cart', async ({ page }) => {
  const checkout = new CheckoutPage(page);
  await checkout.payWithTestCard();
  await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
  await expect.soft(page.getByTestId('order-number')).toHaveText(/^ORD-\d{6}$/);
  await expect.soft(page.getByTestId('order-status')).toHaveText('Paid');
  await expect.soft(page.getByTestId('order-item')).toHaveCount(2);
  await expect.soft(page.getByTestId('order-total'), 'total = 150.00 + 15% VAT')
    .toHaveText('172.50 SAR');
  await expect.soft(page.getByTestId('cart-count')).toHaveText('0');
});

Try it: Regex Tester

Common mistakes

  • Asserting existence or truthiness, such as toBeVisible(), toBeTruthy() or response.ok(), where the requirement fixes an exact value.
  • Copying the current output of the application into the expected value, so an existing defect becomes the expected result.
  • Reading a value once with await inside expect(...) and comparing a plain value, instead of passing the locator to a retrying assertion; the test becomes flaky.
  • Negative assertions with no positive anchor, which pass on a blank or still-loading page.
  • Using soft assertions for preconditions, so one failed sign-in produces a list of meaningless failures; or, in TestNG, forgetting assertAll(), so soft failures are never reported.
  • Over-asserting CSS classes, page structure, timestamps or generated ids, which fail on harmless changes and teach the team to ignore red builds.

The QA perspective

When you review automated tests, read the assertions first. For each one ask: which expected result in the test case or acceptance criteria does it prove, and would it fail if the feature were broken? A simple check is to make the test fail on purpose, by changing the expected value or running it against a build with a known defect: a test that has never been seen to fail has not shown that it can.

Before automating a check, make sure the oracle is precise. "The page shows the total" cannot become a strong assertion; "the total is the subtotal plus 15% VAT, rounded to two decimals" can, so ask the product owner for the exact rule. When a test fails, copy the assertion message with the expected and received values into the bug report, together with the test name and the trace or screenshot of the run. Agree conventions as a team: retrying assertions for the UI, soft assertions only for grouped facts, messages written as business facts.

Practice exercise

Strengthen the assertions of a profile test

Your task

Requirement: when a signed-in user saves a mobile number in the format 05 followed by eight digits, PATCH /api/profile returns 200 with the new number in phone, the page shows the message "Phone number updated" and the field shows the saved number.

The test below passes today. page.waitForResponse(...) starts listening for the profile response before the save, and await saved returns that response once it arrives; the lesson on waits and synchronization covers this pattern. For each numbered assertion, explain why it is weak or unreliable, then rewrite the test so that it proves the requirement.

Material
test('profile', async ({ page }) => {
  const profile = new ProfilePage(page);
  await profile.open();
  const saved = page.waitForResponse('**/api/profile');
  await profile.savePhone('0551234567');
  const response = await saved;
  expect(response.ok()).toBeTruthy();                                        // 1
  expect(await page.getByRole('status').isVisible()).toBe(true);             // 2
  expect(await page.getByLabel('Mobile number').inputValue()).toBeTruthy();  // 3
  await expect(page.getByText('Something went wrong')).not.toBeVisible();    // 4
});
Steps
  1. Write the expected results from the requirement as a list: this is your oracle.
  2. For each numbered line, name a defect that would leave it green, and say whether it reads once or retries.
  3. Rewrite each assertion and decide whether it should be hard or soft.
  4. Add one custom failure message and rename the test after the behaviour.
Show the model answer
Model answer

Oracle: status 200; phone in the body is 0551234567; the message reads "Phone number updated"; the field value is 0551234567.

Line 1 checks a response that is already complete, so it needs no retry, but ok() is true for any 2xx status: a 204, or a 200 whose body still holds the old number, passes. Line 2 reads once, so it can run before the message appears, and it also passes when the status region shows an error. Line 3 reads once and accepts any non-empty value, including the old number. Line 4 retries, but not.toBeVisible() passes at once on a blank or still-loading page.

Rewrite: expect(response.status()).toBe(200) as a hard assertion, because if the save failed the page checks only add noise. Then soft: expect.soft((await response.json()).phone).toBe('0551234567'), await expect.soft(page.getByRole('status')).toHaveText('Phone number updated') and await expect.soft(page.getByLabel('Mobile number'), 'mobile number after save').toHaveValue('0551234567'). The two response checks use generic matchers, which is correct because the response is already complete; the two page checks retry.

Line 4 is removed, or moved after the message check, where the message anchors it. A good test title is saving a valid mobile number updates the profile.

Frequently asked questions

How many assertions should one test have?

As many as it takes to prove one behaviour. The advice "one assertion per test" is about one behaviour, not one expect line: checking the status, the total and the item count of the same order belongs together. If a test checks two unrelated behaviours, split it, so that its name and its failure point to one cause.

Why not make every assertion soft, to see all failures at once?

Because later checks often depend on earlier ones. If sign-in fails, twenty soft failures on the dashboard hide the one that matters. Make preconditions hard, and group only independent facts about one result as soft assertions.

Do retrying assertions make tests slower?

Not when the application is correct: a retrying assertion passes as soon as the condition holds, and spends the whole timeout only when the check is going to fail. A fixed sleep costs its full duration on every run.

References

  • Playwright DocumentationMicrosoft (Playwright)Official documentation for Playwright, an end-to-end test framework that drives Chromium, WebKit and Firefox.
  • Best Practices — PlaywrightMicrosoft (Playwright)Test user-visible behaviour, keep tests isolated, prefer resilient locators and web-first assertions, and debug with traces.
  • ISTQB GlossaryISTQBThe standard definitions of testing terms — test case, regression testing, severity, priority, boundary value analysis and the rest.
  • Cypress DocumentationCypress.ioOfficial documentation for end-to-end and component testing in the browser with Cypress.

Practise with Mutqan tools

Test regular expressions live with match highlighting, capture groups and replace preview.

Utilities

Compare two JSON documents semantically and highlight added, removed and changed values.

JSON & Data

Compare two texts line by line or word by word and highlight differences.

Formatting

Evaluate JSONPath expressions ($.store.book[*].author) against JSON and see matches live.

JSON & Data

Test REST endpoints with full control over method, headers and JSON body, then assert on status, headers and JSON paths.

API Testing

Go deeper

Your progress on this topic

Part of the QA Roadmap