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 (shouldran 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
expectinside.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.existwithnot.be.visible. Assertingnot.be.visibleon an element that's been removed from the DOM hangs (Cypress is waiting for the element to exist first, then become invisible). Assertingnot.existon 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. Eachcy.getre-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.
- Log in to Sauce Demo, add the Sauce Labs Backpack and Sauce Labs Bike Light to the cart, and visit
/cart.html. - In
cypress/e2e/cart-assertions.cy.ts, write adescribe("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.withinto 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
.shouldto extract every price, sum them withArray.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.
- Add a fifth test that asserts the Continue Shopping button is
be.visible,have.text,not.be.disabled, andhave.class("btn_secondary")in a single chained.should().and().and().and()call. - 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. Thethenform will eventually fail on a slow run; theshouldform won't. - 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.