Skip to main content

✍️ Writing E2E Test Scenarios

Knowing the Cypress commands is like knowing your vocabulary. Writing good scenarios is composing sentences that actually mean something — and that keep meaning it as the app grows. This lesson is about the craft: picking the right flows, structuring each test so it's readable and reliable, seeding state the smart way, and stubbing the network so your tests are fast and never flake.

Week 12 · Day 3 (Wednesday: End-to-End Testing) · Lecture 3

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Identify business-critical flows worth an E2E test and plan scenarios before coding
  • Structure a scenario with a clear setup → act → assert → teardown shape
  • Write independent tests that never depend on the order or state of other tests
  • Seed test state efficiently through the API and Cypress cy.task instead of the UI
  • Stub and wait on network requests with cy.intercept and request aliases
  • Eliminate arbitrary waits and reduce flakiness with resilient patterns

Estimated Time: 75 minutes

Project: Author a complete, independent checkout scenario end to end.

In This Lesson

Planning Before Coding

The best E2E scenarios start on paper, not in the editor. Before you write a line, decide which journeys matter. As you learned in the concepts lesson, E2E tests are expensive, so spend them on business-critical paths: flows that make money, are used constantly, span multiple systems, or have broken before.

flowchart LR A["Identify critical flows"] --> B["Plan the scenario"] B --> C["Write the test"] C --> D["Run it"] D --> E["Analyze results"] E --> F["Refine"] F --> C

A lightweight scenario map keeps you honest. For each flow, jot down its goal, starting conditions, the steps, and the outcomes you'll assert:

ScenarioStarting stateStepsExpected outcome
New user registration Logged out, email not taken Open sign-up, fill valid data, submit Account created, redirected to dashboard, welcome shown
Add to cart Logged in, product seeded Open product, choose options, add to cart Cart badge reads correct count

🎬 Organize by journey, not by page

Spotify groups its E2E suites by user journey — "Playlist Creation Journey," "Music Discovery Journey" — rather than "Homepage Tests." Naming by journey keeps tests focused on real experiences and makes the suite read like a list of promises the product keeps.

Structuring a Scenario

Every solid scenario has the same skeleton you met earlier: setup, act, assert, teardown — often summarized as Arrange-Act-Assert. Laying tests out this way makes them readable and easy to debug.

describe('Shopping cart', () => {
  beforeEach(() => {
    // SETUP (Arrange): known starting state, seeded via the API
    cy.request('POST', '/api/testing/reset');
    cy.request('POST', '/api/testing/seed-products');
    cy.login('shopper@example.com', 'Password123');   // custom command
    cy.visit('/products');
  });

  it('adds a product to the cart', () => {
    // ACT: perform the user's actions
    cy.get('[data-cy="product-card"]').first().click();
    cy.get('[data-cy="quantity"]').select('2');
    cy.get('[data-cy="add-to-cart"]').click();

    // ASSERT: verify the observable result
    cy.get('[data-cy="cart-notification"]').should('be.visible');
    cy.get('[data-cy="cart-badge"]').should('contain', '2');
  });

  afterEach(() => {
    // TEARDOWN: clean up so the next test starts fresh
    cy.clearCookies();
    cy.clearLocalStorage();
  });
});

💡 Custom commands keep intent front and center

That cy.login() hides several lines of API-login plumbing behind one readable verb. Define reusable steps in cypress/support/commands.js so your scenarios describe what the user does, not the mechanics of how.

// cypress/support/commands.js
Cypress.Commands.add('login', (email, password) => {
  cy.request('POST', '/api/login', { email, password })
    .then((res) => {
      window.localStorage.setItem('authToken', res.body.token);
    });
});

Keeping Tests Independent

The single most important rule of E2E testing: every test must stand alone. A test should never assume another test ran first, left data behind, or logged someone in. If test B only passes because test A ran before it, you have a house of cards — reorder or run one in isolation and everything collapses.

Independent tests each set up their own state, versus chained tests that depend on the previous test's leftovers Independent (good) Test A: seed → act → assert Test B: seed → act → assert Each brings its own state Chained (fragile) Test A: seed → act Test B: reuses A's leftovers Reorder and it breaks
Independent tests each establish their own state in beforeEach. Chained tests share hidden dependencies that make failures baffling and reordering impossible.

To guarantee independence:

  • Set up all needed state in beforeEach, not once in before
  • Reset the database or use unique data (timestamps, random ids) per test
  • Clear cookies and local storage between tests
  • Never write test B expecting a record test A created

⚠️ Unique data avoids collisions

If two tests both create user@example.com, the second fails on a duplicate. Generate unique values instead: const email = `test-${Date.now()}@example.com`;. Now tests can even run in parallel without stepping on each other.

Seeding State the Smart Way

Suppose your test needs a logged-in user with three orders in their history. You could click through registration, then place three orders through the UI — but that's slow, brittle, and tests things unrelated to what you actually care about. The rule: set up state through the fastest reliable path, and reserve UI actions for the behavior under test.

Seed through the API

An API request is far faster than a UI walkthrough and doesn't depend on unrelated screens working:

beforeEach(() => {
  // Create the user and their orders directly — no clicking required
  cy.request('POST', '/api/testing/seed', {
    user: { email: `test-${Date.now()}@example.com`, name: 'Test User' },
    orders: 3
  });
});

Seed through cy.task for deeper access

When you need to reach the database or the file system directly, register a Node-side task in the config. Tasks run in Node, outside the browser, so they can do things the browser can't — like talking to MongoDB.

// cypress.config.js
const { defineConfig } = require('cypress');
const { MongoClient } = require('mongodb');

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    setupNodeEvents(on, config) {
      on('task', {
        async seedUser(user) {
          const client = new MongoClient(config.env.MONGO_URI);
          await client.connect();
          await client.db('test').collection('users').insertOne(user);
          await client.close();
          return user.email;                 // tasks must return a value
        }
      });
    }
  }
});
// In a spec
it('shows the seeded user', () => {
  cy.task('seedUser', { email: 'ada@example.com', name: 'Ada' })
    .then((email) => {
      cy.login(email, 'Password123');
      cy.visit('/profile');
      cy.get('[data-cy="profile-name"]').should('contain', 'Ada');
    });
});

✅ Rule of thumb

Test the one flow the scenario is named for through the UI. Everything else — logging in, creating background data, resetting the database — should go through the fastest reliable back door (cy.request or cy.task). Your tests get faster and stop failing for reasons unrelated to what they're checking.

Stubbing the Network

cy.intercept lets you watch, wait on, and even replace network requests. It has two big jobs: waiting deterministically for a request to finish, and stubbing a response so you can test states that are hard to trigger for real (errors, empty results, slow loads).

Wait on a real request instead of guessing

Give a request an alias with .as(), then cy.wait('@alias') pauses precisely until it completes — no arbitrary sleeps.

it('loads products from the API', () => {
  cy.intercept('GET', '/api/products*').as('getProducts');
  cy.visit('/products');

  cy.wait('@getProducts');                    // waits for the exact request
  cy.get('[data-cy="product-card"]').should('have.length.greaterThan', 0);
});

Stub a response to force a state

Pass a response object and Cypress answers the request itself. This makes error and empty states trivial to test:

it('shows an error state when the API fails', () => {
  cy.intercept('GET', '/api/products*', {
    statusCode: 500,
    body: { error: 'Internal server error' }
  }).as('getProductsError');

  cy.visit('/products');
  cy.wait('@getProductsError');

  cy.get('[data-cy="error-message"]').should('be.visible');
  cy.get('[data-cy="retry-button"]').should('be.visible');
});

it('shows an empty state with no products', () => {
  cy.intercept('GET', '/api/products*', { statusCode: 200, body: [] }).as('empty');
  cy.visit('/products');
  cy.wait('@empty');
  cy.get('[data-cy="empty-state"]').should('contain', 'No products found');
});

Assert on what was sent

You can also inspect the request your app made — verifying the payload without hitting a real third party:

it('submits the sign-up form with the right data', () => {
  cy.intercept('POST', '/api/users').as('createUser');

  cy.visit('/signup');
  cy.get('[data-cy="username"]').type('newuser');
  cy.get('[data-cy="email"]').type('new@example.com');
  cy.get('[data-cy="submit"]').click();

  cy.wait('@createUser').its('request.body').should('include', {
    username: 'newuser',
    email: 'new@example.com'
  });
});

📖 Stub the flaky third parties

Payment gateways, OAuth providers, and email services are outside your control and slow. Stub them with cy.intercept so your test is fast and deterministic. Test the real integration separately, less often, against the provider's sandbox.

Writing Resilient Tests

A resilient test passes reliably and fails only when something is genuinely broken. The enemies of resilience are timing guesses and brittle selectors.

Never wait on the clock — wait on a condition

// ❌ Fragile: too short and it flakes, too long and it crawls
cy.get('[data-cy="search"]').type('shoes');
cy.wait(5000);
cy.get('[data-cy="results"]').should('be.visible');

// ✅ Resilient: wait on the actual network request, then assert
cy.intercept('GET', '/api/search*').as('search');
cy.get('[data-cy="search"]').type('shoes');
cy.wait('@search');
cy.get('[data-cy="results"]').should('be.visible');

Test critical flows, not every permutation

You don't need an E2E test for every filter combination. Cover the main happy path plus a couple of important failure paths (declined payment, invalid login), and let unit and integration tests handle the combinatorial explosion.

⚠️ Signs a scenario is doing too much

If a single it() block is 80 lines long and asserts fifteen unrelated things, it's hard to debug when it fails — which assertion broke? Split broad journeys into focused scenarios, each proving one clear promise.

A Full Checkout Scenario

Here's everything coming together: a business-critical journey, seeded state, stable selectors, a network wait, and clear assertions — all in one independent, readable test.

// cypress/e2e/checkout.cy.js

describe('Checkout journey', () => {
  beforeEach(() => {
    // SETUP: fresh DB + seeded product + logged-in shopper, all via API
    cy.request('POST', '/api/testing/reset');
    cy.request('POST', '/api/testing/seed-products');
    cy.login('shopper@example.com', 'Password123');
  });

  it('completes a purchase and shows an order confirmation', () => {
    // Stub the payment provider so the test is fast and deterministic
    cy.intercept('POST', '/api/orders', {
      statusCode: 201,
      body: { id: 'order-123', status: 'confirmed', total: 39.98 }
    }).as('createOrder');

    // ACT: walk the real user path through the UI
    cy.visit('/products');
    cy.get('[data-cy="product-card"]').first().click();
    cy.get('[data-cy="quantity"]').select('2');
    cy.get('[data-cy="add-to-cart"]').click();

    cy.get('[data-cy="cart-icon"]').click();
    cy.get('[data-cy="checkout"]').click();

    cy.get('[data-cy="ship-name"]').type('Ada Lovelace');
    cy.get('[data-cy="ship-address"]').type('123 Analytical Ave');
    cy.get('[data-cy="ship-city"]').type('London');
    cy.get('[data-cy="ship-zip"]').type('EC1A');
    cy.get('[data-cy="place-order"]').click();

    // ASSERT: wait for the order request, then verify the confirmation
    cy.wait('@createOrder');
    cy.url().should('include', '/order-confirmation');
    cy.get('[data-cy="order-number"]').should('contain', 'order-123');
    cy.get('[data-cy="order-status"]').should('contain', 'confirmed');
  });

  it('shows an error when payment is declined', () => {
    cy.intercept('POST', '/api/orders', {
      statusCode: 402,
      body: { error: 'Your card was declined' }
    }).as('declined');

    cy.visit('/products');
    cy.get('[data-cy="product-card"]').first().click();
    cy.get('[data-cy="add-to-cart"]').click();
    cy.get('[data-cy="cart-icon"]').click();
    cy.get('[data-cy="checkout"]').click();
    cy.get('[data-cy="ship-name"]').type('Ada Lovelace');
    cy.get('[data-cy="ship-address"]').type('123 Analytical Ave');
    cy.get('[data-cy="ship-city"]').type('London');
    cy.get('[data-cy="ship-zip"]').type('EC1A');
    cy.get('[data-cy="place-order"]').click();

    cy.wait('@declined');
    cy.get('[data-cy="payment-error"]')
      .should('be.visible')
      .and('contain', 'declined');
  });
});

Result

Checkout journey
  ✓ completes a purchase and shows an order confirmation (1284ms)
  ✓ shows an error when payment is declined (1102ms)

2 passing

Notice how each test seeds its own state, stubs the network, selects with data-cy, waits on a real request instead of a sleep, and asserts on exactly one clear outcome. That's a scenario you can trust in CI.

Practice & Quiz

🏋️ Exercise 1: Make it independent

Goal: This suite chains two tests — the second relies on the first. Refactor so each test stands alone.

describe('Posts', () => {
  it('creates a post', () => {
    cy.login('author@example.com', 'pw');
    cy.visit('/new');
    cy.get('[data-cy="title"]').type('My Post');
    cy.get('[data-cy="save"]').click();
  });

  it('shows the post in the list', () => {
    cy.visit('/posts');                       // assumes the post above exists!
    cy.contains('My Post').should('be.visible');
  });
});
💡 Hint

The second test shouldn't depend on the first having run. Seed a post through the API in a beforeEach so each test has its own data.

✅ Solution
describe('Posts', () => {
  beforeEach(() => {
    cy.request('POST', '/api/testing/reset');
    cy.login('author@example.com', 'pw');
  });

  it('creates a post', () => {
    cy.visit('/new');
    cy.get('[data-cy="title"]').type('My Post');
    cy.get('[data-cy="save"]').click();
    cy.get('[data-cy="save-confirmation"]').should('be.visible');
  });

  it('shows a seeded post in the list', () => {
    // Seed the post directly — don't rely on the other test
    cy.request('POST', '/api/posts', { title: 'Seeded Post' });
    cy.visit('/posts');
    cy.contains('Seeded Post').should('be.visible');
  });
});

🏋️ Exercise 2: Replace the sleep

Goal: Rewrite this so it waits on the network request instead of a fixed timeout.

cy.visit('/dashboard');
cy.wait(4000);
cy.get('[data-cy="stats"]').should('be.visible');
✅ Solution
cy.intercept('GET', '/api/stats*').as('getStats');
cy.visit('/dashboard');
cy.wait('@getStats');                 // precise, no wasted time
cy.get('[data-cy="stats"]').should('be.visible');

Waiting on @getStats resolves the instant the data arrives, so the test is both faster and immune to a slow-network flake.

🎯 Quick Quiz

Question 1: What's the best way to log a user in for a test that isn't testing the login screen itself?

Question 2: Why prefer cy.wait('@getData') over cy.wait(3000)?

Question 3: What does cy.task let you do that a browser command cannot?

Best Practices & Pitfalls

✅ Do

  • Plan scenarios and organize suites by user journey
  • Give every test its own state in beforeEach; use unique data
  • Seed setup through cy.request / cy.task; test only the target flow through the UI
  • Wait on aliased requests, never on the clock
  • Stub slow or external services with cy.intercept
  • Keep each it() focused on one clear outcome

❌ Don't

  • Chain tests so one depends on another's leftovers
  • Build state by clicking through unrelated screens
  • Sprinkle cy.wait(ms) to paper over timing
  • Try to cover every permutation with E2E — push combinations down the pyramid
  • Let a single scenario grow into an 80-line catch-all

📖 The same ideas in Playwright

Independence, API seeding, and network stubbing are universal. In Playwright you'd use page.route() to stub, request fixtures to seed, and test.beforeEach for setup. The craft transfers; only the API names change.

Summary

🎉 Key Takeaways

  • Plan scenarios first — cover business-critical journeys, organized by user journey
  • Give every scenario a clear setup → act → assert → teardown shape
  • Independence is non-negotiable: each test seeds its own state and cleans up
  • Seed via cy.request and cy.task; drive only the target behavior through the UI
  • Use cy.intercept to wait on real requests and to stub errors, empty, and third-party responses
  • Replace arbitrary waits with request aliases to kill flakiness

📚 Additional Resources

🚀 What's Next?

You can now write E2E scenarios that are focused, independent, and resilient. But how fast does your app respond under load? Next up, Performance Testing Tools takes you from "does it work" to "does it stay fast," measuring response times and throughput before your users feel the slowdown.

🎉 Test suite, leveled up!

You've gone from single commands to trustworthy, CI-ready journeys. That's the skill that keeps real products from breaking.