Writing and Running Your First Test

8 min read

You have Cypress installed, scaffolded, and a sanity test passing. This lesson is the first one with real, runnable code against a real app — three tests for an e-commerce product page, a tour of how describe, it, and beforeEach glue them together, and the two ways to run the suite. Every snippet is meant to be typed (or pasted) into your scaffolded project and run.

Anatomy of a Cypress test

Open cypress/e2e/home.cy.ts and write:

describe("Home page", () => {
  it("should display the welcome message", () => {
    cy.visit("/");
    cy.get("h1").should("contain", "Welcome");
  });
});

Save. Cypress's runner reloads the spec list and the test runs in seconds. Read the file from the outside in:

  • describe("Home page", () => { ... }) is a test suite — a labelled group of related tests. Cypress inherits this from Mocha. The string is what shows up in the runner UI and the CI output.
  • it("should display the welcome message", () => { ... }) is a single test case. Each it is independent: it gets its own browser tab, its own clean cookie jar, its own clean local storage. The string is the assertion-friendly description that explains what you're checking.
  • cy.visit("/") navigates the browser to a URL. Because baseUrl is set in cypress.config.ts, "/" resolves to http://localhost:3000/ (or whatever you configured).
  • cy.get("h1") selects the first <h1> element on the page. Like every Cypress query, it auto-retries for up to four seconds while the DOM settles.
  • .should("contain", "Welcome") is the assertion — it keeps re-checking until the <h1> contains the text "Welcome" or the timeout fires. should is built on Chai under the hood.

Three commands, one assertion, one passing test. That's the whole shape.

Running tests two ways

Interactive mode — for authoring and debugging:

npx cypress open

The desktop app launches, you click your spec in the file list, and the test runs in a real visible browser. Every command is logged on the left. Save the file in VS Code and the runner re-executes the spec automatically. This is your day-to-day surface.

Headless mode — for CI and bulk execution:

npx cypress run

Cypress runs every spec in cypress/e2e/ in a headless browser, prints a per-spec summary table to stdout, and exits with code 0 (pass) or non-zero (fail). On every failure it captures a screenshot in cypress/screenshots/; on every spec it captures a video in cypress/videos/. CI pipelines use this exact command.

To run a specific spec:

npx cypress run --spec "cypress/e2e/home.cy.ts"

To run in a specific browser:

npx cypress run --browser chrome

If you added the npm scripts from lesson 2, npm run cy:open and npm run cy:run are the shortcuts you'll actually type.

A real multi-test spec

A single test rarely tells you anything useful. Real specs cluster three or four related tests under one describe block and share their setup with beforeEach:

describe("Product search", () => {
  beforeEach(() => {
    cy.visit("/products");
  });
 
  it("should display products on the page", () => {
    cy.get("[data-testid='product-card']")
      .should("have.length.greaterThan", 0);
  });
 
  it("should filter products by category", () => {
    cy.get("[data-testid='category-filter']").select("Electronics");
    cy.get("[data-testid='product-card']").each(($card) => {
      cy.wrap($card).should("contain", "Electronics");
    });
  });
 
  it("should search for a product by name", () => {
    cy.get("[data-testid='search-input']").type("Laptop");
    cy.get("[data-testid='product-card']")
      .should("have.length.greaterThan", 0);
    cy.get("[data-testid='product-card']")
      .first()
      .should("contain", "Laptop");
  });
});

Save it as cypress/e2e/products.cy.ts. Three tests, each independent, each starting from a fresh navigation to /products. Walk through what's new:

  • beforeEach(() => cy.visit("/products")) runs before every it() block. Use it for shared setup so the tests stay focused on what they're actually verifying. You'll see this pattern everywhere in real codebases.
  • cy.get("[data-testid='product-card']") matches every element with that data-testid. Cypress chains queries, so the result is a collection you can keep working with.
  • .should("have.length.greaterThan", 0) is a Chai assertion in Cypress's should syntax — the dot path defines the assertion. There are dozens; the Cypress commands cheat sheet lists them.
  • .each(($card) => cy.wrap($card).should("contain", "Electronics")) iterates the matched collection. $card is a jQuery-wrapped element; cy.wrap($card) rewraps it as a Cypress chainable so you can keep using .should. Every card on a filtered page should mention the category.
  • .first() narrows a multi-element selection down to the first match — the top result of the search.

Run this spec against any app with [data-testid] attributes on its product page. If your app doesn't have them, swap in selectors that match what's there — but stay disciplined: chapter 2 covers why data-testid is the only selector you should rely on long-term.

Test-execution flow

The two things to internalise: beforeEach runs before every it, not just before the first one. And every it starts from a clean browser session — Cypress wipes cookies, local storage, and session storage between tests by default, so one test can't accidentally pollute the next.

What if there's no real app to test against?

The spec above assumes a running e-commerce site at baseUrl. If you don't have one yet, three options:

  • The Cypress example app — set baseUrl: "https://example.cypress.io" and write tests against the public demo. Useful for syntax practice.
  • A public sandbox like https://automationexerciseweb.com or https://www.saucedemo.com — both designed for automation practice.
  • Your own app running locally on http://localhost:3000 — the realistic scenario. This is what every real QA job looks like.

For the rest of this course, we'll assume an e-commerce target with [data-testid] attributes wherever it matters. If you're following along against a different app, the patterns transfer; just substitute selectors.

Hooks beyond beforeEach

beforeEach is the workhorse, but the full set is:

  • before() — runs once at the start of the describe, before any it. Good for one-time expensive setup (seeding a test database).
  • beforeEach() — runs before every it. Good for navigation and per-test setup.
  • afterEach() — runs after every it, even if it failed. Good for cleanup that must always happen.
  • after() — runs once at the end of the describe, after every it. Rare; mostly for teardown of one-time setup.

Don't reach for before() to set up state your tests will mutate — Cypress doesn't reset between it blocks the way Jest does for module state, and you'll create flake-by-design. beforeEach is the safe default.

⚠️ Common mistakes

  • Putting setup in the test body instead of beforeEach. Three tests, three copy-pasted cy.visit("/products") lines at the top. Now you change the URL and have to update three places — or worse, miss one and create a bug. beforeEach exists precisely to centralise navigation. Use it from day one.
  • Selecting on volatile attributes (CSS class, nth-child, autogenerated IDs) instead of data-testid. A test like cy.get(".btn-primary.large") breaks the moment a designer renames a utility class. cy.get("[data-testid='submit-order']") survives every CSS refactor your team will ever do. Chapter 2 covers this in depth, but start the habit now — every example in this course uses data-testid.
  • Skipping cy.visit and assuming the previous test's URL is still loaded. Cypress wipes the browser between it blocks. Without a cy.visit in beforeEach (or at the top of each test), cy.get runs against about:blank and fails with "no element found." This is one of the most common confusions for new Cypress users.

🎯 Practice task

Author and run a multi-test spec end to end. 25-30 minutes.

  1. In your scaffolded project, set baseUrl: "https://www.saucedemo.com" in cypress.config.ts. (Sauce Demo is a free public e-commerce sandbox designed for automation practice — credentials standard_user / secret_sauce.)
  2. Create cypress/e2e/login.cy.ts with a describe block named "Login" and three it tests:
    • "loads the login page" — cy.visit("/") and assert the URL contains /.
    • "logs in with valid credentials" — type the user/password, click the login button, assert the URL contains /inventory.html.
    • "shows an error for invalid credentials" — type the user, type "wrong" as the password, click login, assert that the error container is visible and contains "Username and password do not match".
  3. Move the cy.visit("/") into a beforeEach. Confirm all three tests still pass.
  4. Use npm run cy:run --silent -- --spec "cypress/e2e/login.cy.ts" to run only this spec from the CLI. Inspect the screenshot/video output in cypress/screenshots/ (only on failure) and cypress/videos/.
  5. Force a failure to see Cypress's debugging in action. Change the password assertion to expect "Welcome, Admin" (a string that won't appear). Re-run. Read the assertion-failure message and inspect the auto-captured screenshot.
  6. Stretch: add a fourth test that logs in, clicks "Add to cart" on one product, and asserts the cart badge shows "1". You'll use cy.contains("Add to cart").first().click() and cy.get("[data-test='shopping-cart-badge']").should("have.text", "1"). This is your first complete user-flow test — the kind every product team has dozens of.

Once this works headlessly through cy:run and interactively through cy:open, you've completed the loop a real Cypress engineer runs daily. The next lesson breaks open the Test Runner UI itself — the Selector Playground, Time Travel, and the command log that turn a failed test into a five-second diagnosis.

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