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'), nottoBeVisible()on the total. Money, counts, statuses and messages are compared exactly. - Patterns only for parts that really vary: an order number such as
ORD-418305is checked with/^ORD-\d{6}$/, not withtoBeTruthy()and not with the number from one run. - Counts, not "at least one":
toHaveCount(2), not a check thatcount()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
201with 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.
Weak assertionCaution
Checks something that is also true when the feature is broken
- Order total:
isVisible()istrue: some total is shown- Order number:
toBeTruthy(): any text, evenORD-- 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: trueReceived: 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
201and 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.
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 offersSoftAssertions. - TestNG:
SoftAssertreports only whenassertAll()is called at the end; forget that call and the failures are lost. - pytest: a plain
assertis 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,toHaveValueandtoHaveCounton a locator;toHaveURLandtoHaveTitleon 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) totoBe(true)andtoBeTruthy(). - Add a custom message where the matcher cannot know the context: the second argument of
expectin Playwright, the last argument ofassertEqualsin 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,
toEqualprints a diff of the fields that differ;toMatchObjectcompares 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"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');
});Common mistakes
- Asserting existence or truthiness, such as
toBeVisible(),toBeTruthy()orresponse.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
awaitinsideexpect(...)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
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.
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
});- Write the expected results from the requirement as a list: this is your oracle.
- For each numbered line, name a defect that would leave it green, and say whether it reads once or retries.
- Rewrite each assertion and decide whether it should be hard or soft.
- Add one custom failure message and rename the test after the behaviour.
Show the 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 DocumentationOfficial documentation for Playwright, an end-to-end test framework that drives Chromium, WebKit and Firefox.
- Best Practices — PlaywrightTest user-visible behaviour, keep tests isolated, prefer resilient locators and web-first assertions, and debug with traces.
- ISTQB GlossaryThe standard definitions of testing terms — test case, regression testing, severity, priority, boundary value analysis and the rest.
- Cypress DocumentationOfficial 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.
Compare two JSON documents semantically and highlight added, removed and changed values.
Compare two texts line by line or word by word and highlight differences.
Evaluate JSONPath expressions ($.store.book[*].author) against JSON and see matches live.
Test REST endpoints with full control over method, headers and JSON body, then assert on status, headers and JSON paths.
Go deeper
Your progress on this topic
Not started
Mark the topic complete when you have studied it and done the exercise.
Sign in to track your progress
Mark topics complete, continue where you left off and unlock specialisation exams.