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
// Actsection 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 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
describeblock.
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)andcy.wait(500)slow the suite and create flaky timing dependencies. - Brittle selectors:
nth-childand 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:
productNamemakes intent explicit and simplifies updates.