Free Ebook cover Cypress End-to-End Testing Basics: Stable UI Tests for Web Apps

Cypress End-to-End Testing Basics: Stable UI Tests for Web Apps

New course

10 pages

Writing Your First Maintainable Cypress Tests (Arrange–Act–Assert and Clear Assertions)

Capítulo 4

Estimated reading time: 7 minutes

+ Exercise

Naming Tests Around User Outcomes

Maintainable Cypress tests read like a short story: a user goal, the steps taken, and the expected result. The fastest way to improve readability is to name tests by the outcome a user cares about, not by implementation details.

Prefer outcome-focused names

  • Good: “allows a signed-in user to add an item to the cart”
  • Good: “shows an error when payment is declined”
  • Avoid: “clicks the add button” (describes mechanics, not value)
  • Avoid: “POST /api/cart returns 200” (too low-level for a UI E2E test)

Outcome-focused names make failures actionable: when a test fails, you immediately know what user capability is broken.

Arrange–Act–Assert (AAA) Inside Each Test

As a test suite grows, the main reason tests become hard to maintain is that setup, actions, and checks get mixed together. Arrange–Act–Assert is a simple structure that keeps each test readable and makes refactoring safer.

What each section means in Cypress

  • Arrange: put the app into the required starting state (seed data, visit page, sign in, set feature flags, stub network if needed).
  • Act: perform the user interaction being tested (click, type, submit, navigate).
  • Assert: verify the outcome that matters (UI state, navigation, key messages, critical side effects visible to the user).

Practical step-by-step: applying AAA

  • Step 1: Write the test name as the user outcome.
  • Step 2: Add three comment markers: // Arrange, // Act, // Assert.
  • Step 3: Move all setup code above // Act.
  • Step 4: Keep the // Act section focused on the single behavior under test.
  • Step 5: Put all checks under // Assert, and ensure they verify outcomes, not incidental implementation details.

Selecting the Right Assertion Granularity

Assertions are where maintainability is won or lost. Too few assertions and bugs slip through; too many and tests become brittle, failing for harmless UI changes. Aim for assertions that prove the user outcome with minimal coupling to layout.

What to assert

  • Primary outcome: the main thing the user expects (e.g., “item appears in cart”, “success message shown”, “redirected to dashboard”).
  • Critical supporting signals: one or two checks that confirm the outcome is real (e.g., cart count updated, total price updated, URL changed).
  • Avoid incidental details: exact CSS classes, pixel-perfect styles, DOM structure depth, or multiple redundant checks for the same outcome.

How many assertions?

  • Rule of thumb: 2–5 assertions per test is often enough for a single user outcome.
  • Prefer fewer, stronger assertions: one assertion that verifies a meaningful state is better than many that verify tiny details.
  • Split tests when: you find yourself asserting multiple independent outcomes (e.g., “creates order” and “sends email” and “updates inventory”). Those are separate behaviors and should be separate tests.

Clear assertions: make failures self-explanatory

Write assertions that read like expectations, and target stable UI signals. Prefer checking visible text, URL, and enabled/disabled state over internal attributes that might change.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

// Clear, outcome-focused assertions examples (illustrative selectors only) cy.url().should('include', '/cart') cy.get('[data-testid="cart-items"]').should('contain.text', 'Coffee Mug') cy.get('[data-testid="cart-count"]').should('have.text', '1') cy.get('[data-testid="checkout-button"]').should('be.enabled')

Using beforeEach for Shared Setup Without Hiding Intent

beforeEach is useful for repeated setup, but it can also make tests harder to understand if it hides important preconditions. The goal is to remove noise (repeated boilerplate) while keeping each test’s intent obvious.

Good uses of beforeEach

  • Visiting a common starting page for a group of tests.
  • Setting a consistent viewport or clock behavior for the suite (if relevant).
  • Creating a baseline state that is truly shared by every test in the describe block.

What not to hide in beforeEach

  • Key preconditions that explain the scenario (e.g., “user has an existing cart”, “user is an admin”, “feature flag is enabled”). If those vary by test, keep them inside the test’s Arrange section.
  • Long sequences of actions that are essential to understanding the test. If you must share them, wrap them in a clearly named helper (e.g., cy.loginAsAdmin()) and still call it in Arrange so the test reads well.

Practical pattern: minimal shared setup + explicit scenario setup

describe('Cart', () => {   beforeEach(() => {     // Shared, low-risk setup only     cy.visit('/shop')   })   it('adds a product to the cart', () => {     // Arrange     const productName = 'Coffee Mug'     // Act     cy.contains('[data-testid="product-card"]', productName)       .find('[data-testid="add-to-cart"]')       .click()     // Assert     cy.get('[data-testid="cart-count"]').should('have.text', '1')     cy.get('[data-testid="cart-items"]').should('contain.text', productName)   }) })

Inline Comments Only When They Explain “Why”

Comments that restate what the code already says add noise. Comments that explain why a step exists (a workaround, a business rule, a known risk) add long-term value.

Examples

  • Not useful: // click the button
  • Useful: // Use the promo code field instead of API seeding because pricing is calculated client-side
  • Useful: // This assertion guards against a regression where the UI updates but the cart total stays stale

Model Test Template (Copy/Paste)

Use this template to keep tests consistent across the suite. It bakes in outcome naming, AAA structure, and focused assertions.

describe('Feature or page under test', () => {   beforeEach(() => {     // Shared setup that applies to every test in this block     // Keep it short and unsurprising     // Example: cy.visit('/somewhere')   })   it('user outcome written as a sentence', () => {     // Arrange     // - Put the app in the required state     // - Create variables for important values (names, amounts)     // - Set up any scenario-specific preconditions     // Act     // - Perform the single behavior being tested     // Assert     // - Verify the primary outcome     // - Add 1–3 supporting assertions that prove it     // - Avoid asserting incidental UI details   }) })

Refactoring a Messy Test Into a Clean One

Below is an example of a test that “works” but is hard to maintain. We’ll refactor it using outcome naming, AAA sections, and clearer assertions.

Messy version (hard to read, brittle)

it('test cart', () => {   cy.visit('/shop')   cy.wait(1000)   cy.get('div:nth-child(2) > .btn').click()   cy.get('.nav > :nth-child(3)').click()   cy.wait(500)   cy.get('.cart > div > div > :nth-child(1)').should('exist')   cy.get('.cart').should('be.visible')   cy.get('.cart').find('div').should('have.length.greaterThan', 0)   cy.get('.total').should('not.have.text', '0')   cy.get('.checkout').should('have.class', 'enabled') })

What’s wrong with it (decisions to change)

  • Name is vague: “test cart” doesn’t describe the user outcome.
  • No structure: setup, actions, and assertions are mixed together.
  • Arbitrary waits: cy.wait(1000) and cy.wait(500) slow the suite and create flaky timing dependencies.
  • Brittle selectors: nth-child and deep CSS chains break when layout changes.
  • Weak/unclear assertions: “total is not 0” and “has class enabled” don’t clearly prove the correct item was added.
  • Redundant assertions: multiple checks that don’t add distinct confidence.

Clean version (AAA + clear assertions)

describe('Cart', () => {   beforeEach(() => {     cy.visit('/shop')   })   it('adds a selected product to the cart and updates the cart summary', () => {     // Arrange     const productName = 'Coffee Mug'     // Act     cy.contains('[data-testid="product-card"]', productName)       .find('[data-testid="add-to-cart"]')       .click()     cy.get('[data-testid="nav-cart"]').click()     // Assert     cy.url().should('include', '/cart')     cy.get('[data-testid="cart-items"]').should('contain.text', productName)     cy.get('[data-testid="cart-count"]').should('have.text', '1')     cy.get('[data-testid="cart-total"]').should('not.have.text', '$0.00')   }) })

What we improved (and why)

  • Outcome-based name: communicates the user value and expected result.
  • AAA structure: a future reader can scan the test and understand it quickly.
  • Removed arbitrary waits: the test relies on Cypress’s built-in retry-ability via assertions and element queries, rather than fixed delays.
  • Focused assertions: we assert the product is present, the count is correct, and the total changed—signals that collectively prove the outcome.
  • Less brittleness: the test targets stable UI hooks and avoids layout-dependent selectors.
  • Variables for key data: productName makes intent explicit and simplifies updates.

Now answer the exercise about the content:

Which approach best improves maintainability when refactoring a Cypress test for adding an item to the cart?

You are right! Congratulations, now go to the next page

You missed! Try again.

Maintainable tests use outcome-focused names, a clear AAA structure, and stable selectors. A small set of strong assertions (e.g., URL, item in cart, count, total) proves the user outcome without coupling to layout or timing.

Next chapter

Fixtures and Test Data in Cypress: Repeatable Inputs Without Flakiness

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.