Assertions — should, expect, and Chaining

9 min read

A test that doesn't assert is just a script. Assertions are how you turn "click this button" into "click this button, then verify the dashboard loaded with three orders and a total of £142.50." Cypress has two assertion styles — .should() (BDD chain) and expect() (Chai's assertion function inside callbacks) — and choosing between them at the right moment is the difference between a test that flakes once a week and one that catches real bugs deterministically.

.should() — the default assertion style

.should() is the assertion you'll write 90% of the time. It chains directly off whatever Cypress command yielded an element, and — critically — it retries automatically until the assertion passes or the command times out (4 seconds by default):

cy.get("h1").should("contain", "Welcome");
cy.get("[data-testid='product-list']").should("be.visible");
cy.get("input[name='email']").should("have.value", "alice@test.com");
cy.url().should("include", "/dashboard");
cy.get("[data-testid='cart-count']").should("have.text", "3");

Because .should retries, you can write tests against parts of the page that take time to appear:

cy.get("[data-testid='submit']").click();
cy.get("[data-testid='confirmation']").should("be.visible");
// Cypress keeps re-querying [data-testid='confirmation'] until it appears
// or 4 seconds pass. No manual wait needed.

This single behaviour — assertions retry, queries retry — is why Cypress tests rarely need explicit waits. The framework handles the timing.

The everyday .should assertions

The full Chai assertion library is exposed through should. The ones you'll reach for daily:

// Visibility and existence
cy.get(".alert").should("be.visible");
cy.get(".alert").should("not.be.visible");
cy.get(".alert").should("exist");
cy.get(".alert").should("not.exist");
 
// Text content
cy.get("h1").should("contain", "Welcome");          // partial match
cy.get("h1").should("have.text", "Welcome, Alice"); // exact match
cy.get("h1").should("not.contain", "Error");
 
// Form values and state
cy.get("input").should("have.value", "alice@test.com");
cy.get("input").should("be.disabled");
cy.get("[type='checkbox']").should("be.checked");
cy.get("input").should("be.focused");
 
// Count
cy.get("[data-testid='product-card']").should("have.length", 6);
cy.get("[data-testid='error']").should("have.length.greaterThan", 0);
 
// Class and attribute
cy.get("[data-testid='tab']").should("have.class", "active");
cy.get("a").should("have.attr", "href", "/products");
 
// CSS
cy.get(".error").should("have.css", "color", "rgb(220, 38, 38)");
 
// URL
cy.url().should("include", "/checkout");
cy.url().should("match", /\/orders\/[a-f0-9]{8}/);

The full reference is in the Cypress commands cheat sheet. Memorise the seven or eight you'll use repeatedly; let autocomplete or the cheat sheet handle the long tail.

Chaining assertions with .and

A single element often needs multiple assertions. .and continues the assertion chain on the same element:

cy.get("[data-testid='confirmation']")
  .should("be.visible")
  .and("contain", "Order placed")
  .and("have.class", "success");

This reads as one statement: the confirmation element must be visible, contain "Order placed", and have the success class. All three retry together — the first assertion that fails stops the chain and the runner shows you which one.

Chaining beats writing three separate cy.get(...).should(...) calls because it reuses the element lookup and reads naturally.

Negative assertions: not.exist vs not.be.visible

A subtle but important distinction:

cy.get("[data-testid='loading']").should("not.exist");
// Element is NOT in the DOM at all. The page has finished loading.
 
cy.get("[data-testid='modal']").should("not.be.visible");
// Element IS in the DOM but is hidden (display: none, visibility: hidden,
// or zero size). The modal hasn't opened yet, but it's still there.

Pick the one that matches what the app actually does. A loading spinner that gets removed from the DOM after fetch completes wants not.exist. A modal that's pre-rendered and toggles visibility wants not.be.visible. Using the wrong one produces tests that hang or pass for the wrong reason.

If the app has a tendency to render-then-remove, use not.exist. If it's render-once-and-toggle-visibility, use not.be.visible. When in doubt, inspect the DOM in the runner with the element's pre- and post-state pinned in the command log.

.should() with a callback for complex assertions

When the built-in matchers don't cover what you need, .should() accepts a callback. The callback receives the underlying jQuery wrapper and can run any number of assertions:

cy.get("[data-testid='price']").should(($el) => {
  const price = parseFloat($el.text().replace("£", ""));
  expect(price).to.be.greaterThan(0);
  expect(price).to.be.lessThan(1000);
});

The callback form retries the same way the chain form does — Cypress runs it again until every assertion inside passes or the timeout fires. This makes it safe for things that take time to settle (a price that updates after currency conversion, a count that animates up).

The variable $el is a jQuery object — .text(), .val(), .attr(...), .css(...) all work on it. For a list of elements:

cy.get("[data-testid='product-card']").should(($cards) => {
  expect($cards).to.have.length(6);
  $cards.each((index, el) => {
    expect(Cypress.$(el).text()).to.match(/£\d+/);
  });
});

Use callback-form should sparingly — when a chain of .should("...") calls would be clearer, use that instead. Callbacks are for assertions the BDD chain can't express.

expect() — the synchronous Chai assertion

Inside .then() callbacks (which run once with the yielded value), the right tool is expect():

cy.get("[data-testid='cart-count']").then(($el) => {
  expect($el.text()).to.equal("3");
});

expect() doesn't retry. It runs once, against the value handed to the callback, and either passes or fails immediately. That's appropriate when:

  • You're inside .then() reading a stable value that's already been confirmed (should ran first).
  • You're doing arithmetic or string parsing on a value: expect(parseFloat($el.text())).to.be.closeTo(99.99, 0.01).
  • You're chaining multiple unrelated assertions inside one callback.

The crucial mental model: should retries, then does not. If you put expect inside then for an element that might not be ready yet, you've written a test that will flake the moment the network is a fraction slower.

should vs then — the rule of thumb

Pick the right assertion style

.should() — retries

  • Use for: dynamic content that may take time to appear

  • Cypress retries the assertion for up to 4 seconds

  • Built-in matchers: be.visible, contain, have.value, exist, etc.

  • Default for any check on the page state

.then() with expect() — runs once

  • Use for: arithmetic, parsing, or referring to already-resolved values

  • Runs synchronously against whatever the previous chain yielded

  • No retry — pair with a preceding .should() to wait for readiness

  • Common pattern: assert with should, extract with then

The pattern that combines them safely:

// Step 1: wait for the element with should (retries)
cy.get("[data-testid='total']").should("be.visible");
 
// Step 2: extract and assert with then + expect (runs once, but the
// element is now guaranteed to be ready because step 1 passed)
cy.get("[data-testid='total']").then(($el) => {
  const total = parseFloat($el.text().replace("£", ""));
  expect(total).to.be.closeTo(142.5, 0.01);
});

If you skip the first line, the then runs against whatever the DOM looked like at the moment the chain executed — which on a slow CI run might be empty.

A real product-card assertion

Putting it all together — assertions on a single product card that exercise visibility, text, attribute, length, and arithmetic:

describe("Product card", () => {
  beforeEach(() => {
    cy.visit("/products");
  });
 
  it("displays correct details on the first product card", () => {
    cy.get("[data-testid='product-card']")
      .first()
      .within(() => {
        cy.get("[data-testid='product-name']")
          .should("be.visible")
          .and("not.be.empty");
        cy.get("[data-testid='product-price']")
          .should("contain", "£");
        cy.get("[data-testid='product-image']")
          .should("have.attr", "src")
          .and("match", /\.(png|jpe?g|webp)$/);
        cy.contains("button", "Add to cart")
          .should("be.visible")
          .and("not.be.disabled");
      });
  });
 
  it("calculates a sensible price range", () => {
    cy.get("[data-testid='product-price']").should(($prices) => {
      const numbers = $prices
        .toArray()
        .map((el) => parseFloat(el.textContent!.replace("£", "")));
      expect(numbers).to.have.length.greaterThan(0);
      expect(Math.min(...numbers)).to.be.greaterThan(0);
      expect(Math.max(...numbers)).to.be.lessThan(10_000);
    });
  });
});

The first test uses chained .should().and() calls. The second uses callback-form .should for arithmetic across all visible prices. Both retry, both fail clearly when something's wrong.

⚠️ Common mistakes

  • Putting expect inside .then() for elements that might not be ready. cy.get(".total").then(($el) => expect($el.text()).to.equal("£99.99")) runs once, no retry. If the total is calculated after a network call, the test flakes the day CI is slow. Use .should("have.text", "£99.99") instead — same intent, automatic retry.
  • Confusing not.exist with not.be.visible. Asserting not.be.visible on an element that's been removed from the DOM hangs (Cypress is waiting for the element to exist first, then become invisible). Asserting not.exist on a hidden-but-present modal passes when it shouldn't. Match the assertion to what the app actually does to the element.
  • Writing seven separate cy.get(...).should(...) calls when one chained .should().and().and() would do. Each cy.get re-queries the DOM. Chaining reuses the lookup, runs faster, and reads as one logical assertion. Use chains when the assertions are about the same element.

🎯 Practice task

Wire up assertions on a real cart page. 25-30 minutes.

  1. Log in to Sauce Demo, add the Sauce Labs Backpack and Sauce Labs Bike Light to the cart, and visit /cart.html.
  2. In cypress/e2e/cart-assertions.cy.ts, write a describe("Cart assertions") with these tests:
    • "shows exactly two items" — assert the cart-list length and assert the cart-badge text equals "2".
    • "each item has name, price, and remove button" — use cy.get + cy.within to scope to each row and chain .should("be.visible").and("not.be.empty") on the relevant fields.
    • "total price is the sum of the line prices" — use callback-form .should to extract every price, sum them with Array.reduce, and assert the total matches.
    • "remove button removes the item" — click the remove button on the Backpack row, then assert the cart now has length 1 and the Backpack name is not.exist.
  3. Add a fifth test that asserts the Continue Shopping button is be.visible, have.text, not.be.disabled, and have.class("btn_secondary") in a single chained .should().and().and().and() call.
  4. Force a flake to feel the retry behaviour. Add cy.wait(0) before an assertion to leave timing to chance, then change your .should("have.length", 2) to .then(($items) => expect($items).to.have.length(2)). Run the spec headlessly several times. The then form will eventually fail on a slow run; the should form won't.
  5. Stretch: write one assertion using each of the formats — chained .should(), .should().and(), callback-form .should(($el) => ...), and .then(($el) => expect(...)). Comment each one with a one-line explanation of why that style suits this assertion.

That closes chapter 2. You now have the four-tool kit — selection, navigation, interaction, assertion — that every Cypress test reuses. The next chapter takes those tools into the fiddly parts of the DOM: iframes, shadow DOM, native browser dialogs, and file uploads.

// tip to track lessons you complete and pick up where you left off across devices.