Writing Your First Cypress Test — Visit, Query, Act, Assert

Cypress tests follow a distinctive pattern that feels different from Selenium. Instead of imperative commands (driver.find_element().click()), Cypress uses a chainable, declarative syntax: cy.get('.btn').click(). Every cy command is automatically queued, retried, and executed in order. You never await promises or manage async callbacks — Cypress handles all of that internally. This lesson walks through the four-step pattern of every Cypress test: visit, query, act, assert.

The Cypress Command Chain — Visit, Query, Act, Assert

Every Cypress test follows the same flow: navigate to a page, find elements, interact with them, and verify the result. The magic is that each step automatically retries and waits.

// ── First Cypress test: login flow ──
// File: cypress/e2e/login.cy.ts

describe('Login Page', () => {
  beforeEach(() => {
    // VISIT — navigate to the page
    cy.visit('/');  // Uses baseUrl from cypress.config.ts
  });

  it('should login with valid credentials and show inventory', () => {
    // QUERY — find elements (auto-retries until found or timeout)
    cy.get('[data-test="username"]').type('standard_user');
    cy.get('[data-test="password"]').type('secret_sauce');

    // ACT — interact with elements
    cy.get('[data-test="login-button"]').click();

    // ASSERT — verify the result
    cy.url().should('include', '/inventory');
    cy.get('.inventory_item').should('have.length', 6);
    cy.get('.inventory_item_name').first().should('contain.text', 'Sauce Labs');
  });

  it('should show error for invalid credentials', () => {
    cy.get('[data-test="username"]').type('invalid_user');
    cy.get('[data-test="password"]').type('wrong_password');
    cy.get('[data-test="login-button"]').click();

    // Assert error message appears
    cy.get('[data-test="error"]')
      .should('be.visible')
      .and('contain.text', 'do not match');
  });

  it('should show error when username is empty', () => {
    cy.get('[data-test="login-button"]').click();

    cy.get('[data-test="error"]')
      .should('be.visible')
      .and('contain.text', 'Username is required');
  });
});


// ── Key Cypress commands ──

/*
  NAVIGATION:
    cy.visit('/path')          Navigate to a URL (relative to baseUrl)
    cy.reload()                Reload the current page
    cy.go('back')              Browser back button

  QUERYING (all auto-retry):
    cy.get('selector')         Find element(s) by CSS selector
    cy.contains('text')        Find element containing text
    cy.get('form').find('input')  Find within a parent element

  ACTIONS:
    .click()                   Click an element
    .dblclick()                Double-click
    .type('text')              Type into an input
    .clear()                   Clear an input
    .check()                   Check a checkbox
    .uncheck()                 Uncheck a checkbox
    .select('value')           Select dropdown option
    .scrollIntoView()          Scroll element into viewport

  ASSERTIONS (.should() auto-retries):
    .should('be.visible')      Element is visible
    .should('not.exist')       Element is not in DOM
    .should('have.length', 6)  Collection has 6 items
    .should('contain.text', 'x')  Contains text
    .should('have.value', 'x')    Input has value
    .should('have.class', 'active')  Has CSS class
    .should('have.attr', 'href', '/path')  Has attribute value
    cy.url().should('include', '/dashboard')  URL check
*/

// ── Automatic retry example ──
// This command retries for up to 4 seconds (defaultCommandTimeout):
// cy.get('.loading-spinner').should('not.exist');
// ↑ Keeps checking until the spinner disappears — no manual wait!

// cy.get('.results').should('have.length.greaterThan', 0);
// ↑ Keeps checking until at least one result appears
Note: Cypress commands are not promises even though they look chainable. You cannot await them or store their results in variables like normal async JavaScript. Instead, Cypress commands are queued internally and executed in sequence. To work with a value, use .then(): cy.get('.price').invoke('text').then((text) => { expect(parseFloat(text)).to.be.greaterThan(0); }). This is the most common source of confusion for developers new to Cypress — treat commands as a queue, not as promises.
Tip: Prefer data-test or data-cy attributes for selectors: cy.get('[data-test="login-button"]'). Cypress’s best practices documentation recommends this approach because data attributes are immune to CSS changes, clearly communicate their purpose, and can be stripped from production builds. Many teams adopt a convention where developers add data-cy attributes to every interactive element — the same principle as data-testid in Selenium.
Warning: Never assign cy.get() to a variable and reuse it: const btn = cy.get('.btn'); btn.click(); btn.should('be.disabled'); — this does not work because Cypress commands are queued, not executed immediately. By the time btn.click() runs, the reference may be stale. Instead, chain commands or re-query: cy.get('.btn').click(); cy.get('.btn').should('be.disabled');.

Common Mistakes

Mistake 1 — Adding cy.wait() with fixed milliseconds

❌ Wrong: cy.wait(3000); cy.get('.results').should('have.length', 5);

✅ Correct: cy.get('.results').should('have.length', 5); — the .should() assertion automatically retries until the condition is true or the timeout expires. No fixed wait needed.

Mistake 2 — Storing Cypress commands in variables

❌ Wrong: const el = cy.get('.btn'); el.click(); — commands are not assignable like Selenium WebElements.

✅ Correct: cy.get('.btn').click(); — chain directly, or use .as('alias') for reuse: cy.get('.btn').as('submitBtn'); cy.get('@submitBtn').click();

🧠 Test Yourself

Why does cy.get('.product').should('have.length', 6) not need an explicit wait, unlike Selenium?