Writing E2E Test Scenarios

Crafting Effective End-to-End Tests for Your Applications

Introduction

In our previous lecture, we explored the concepts of End-to-End (E2E) testing and why it's crucial for ensuring reliable applications. Today, we'll dive deeper into the practical side by learning how to write effective E2E test scenarios.

Writing good E2E test scenarios is both an art and a science. It requires understanding user behavior, application workflows, and the technical aspects of the testing framework. By the end of this lecture, you'll be equipped with the knowledge to plan and write effective E2E tests that provide real value for your applications.

E2E Test Development Process

flowchart LR A[Identify Critical Flows] --> B[Plan Test Scenarios] B --> C[Write Test Code] C --> D[Execute Tests] D --> E[Analyze Results] E --> F[Refine Tests] F --> C

Planning Test Scenarios

Before writing a single line of code, proper planning is essential for effective E2E tests. Think of it as mapping a journey before embarking on it.

Identify Business-Critical Paths

Start by identifying the most important user journeys in your application. These are workflows that:

Real-World Example: Netflix's Approach

Netflix focuses their E2E tests on the critical path of content playback. They prioritize testing:

  1. User login and authentication
  2. Browsing and navigating content
  3. Selecting and starting playback
  4. Video playback quality and controls
  5. Recommendations based on viewing history

By focusing on these core experiences, they ensure the most important aspect of their service works flawlessly while leaving more specific features to lower-level tests.

User Personas and Scenarios

Create user personas and scenarios to better understand different user perspectives:

Example Persona: Maria (E-commerce Shopper)
  • Persona: Maria is a busy professional who shops online during her lunch break
  • Scenario: Maria searches for a specific product, compares options, adds to cart, applies a coupon, and completes checkout with saved payment info
  • Test Focus: Speed of search, filtering options, saved payment processing

Scenario Mapping Exercise

Use this template to map your scenarios before writing tests:

Scenario Name User Goals Starting Conditions Steps Expected Outcomes Priority
New User Registration Create an account successfully User is not logged in 1. Visit homepage
2. Click "Sign Up"
3. Fill form with valid data
4. Submit form
5. Verify email (if required)
- Account created
- User logged in
- Welcome message displayed
- Redirect to dashboard
High

Anatomy of an E2E Test

A well-structured E2E test should have several distinct parts that make it clear, maintainable, and effective.

Structure of an E2E Test

classDiagram class E2ETest { Setup Test Environment Arrange Initial State Act (Perform User Actions) Assert Expected Outcomes Cleanup }

1. Test Setup

This includes initializing the testing environment, resetting databases, or establishing test data.


// Cypress example - Test setup
describe('Shopping Cart Functionality', () => {
  beforeEach(() => {
    // Reset application state
    cy.request('POST', '/api/testing/reset-db');
    
    // Seed with test products
    cy.request('POST', '/api/testing/seed-products');
    
    // Log in a test user
    cy.login('testuser@example.com', 'Password123');
    
    // Visit the products page
    cy.visit('/products');
  });

  // Tests will go here...
});
                

2. Arrangement (Initial State)

Set up the specific starting conditions for the particular test you're running.


// Inside a specific test
it('should add products to cart from product page', () => {
  // Arrange: Navigate to a specific product
  cy.get('[data-testid="product-card"]').first().click();
  cy.url().should('include', '/product/');
                

3. Actions (User Behavior)

Simulate the user interactions that make up the test scenario.


  // Act: Perform user actions
  cy.get('[data-testid="quantity-selector"]').select('2');
  cy.get('[data-testid="add-to-cart-button"]').click();
                

4. Assertions (Verification)

Verify that the application behaved as expected after the actions.


  // Assert: Verify expected outcomes
  cy.get('[data-testid="cart-notification"]').should('be.visible');
  cy.get('[data-testid="cart-count"]').should('contain', '2');
  
  // Check cart contents
  cy.get('[data-testid="cart-icon"]').click();
  cy.get('[data-testid="cart-items"]').should('have.length', 1);
  cy.get('[data-testid="item-quantity"]').should('contain', '2');
});
                

5. Cleanup

Some frameworks require explicit cleanup after tests to leave the system in a known state.


// Test cleanup (if needed)
after(() => {
  // Clear local storage, cookies, etc.
  cy.clearLocalStorage();
  cy.clearCookies();
  
  // Additional cleanup API calls if needed
  cy.request('POST', '/api/testing/cleanup');
});
                

The Recipe Analogy

Writing an E2E test is like creating a cooking recipe:

  • Setup: Preparing your kitchen and gathering ingredients
  • Arrange: Measuring and organizing specific ingredients for a step
  • Act: Following the cooking instructions step by step
  • Assert: Checking if the dish looks and tastes as expected
  • Cleanup: Washing dishes and restoring the kitchen to its original state

Just as a good recipe is clear, precise, and leads to consistent results, a good E2E test should provide the same level of clarity and consistency.

Effective Test Writing Techniques

Use Page Objects for Maintainability

The Page Object Model (POM) is a design pattern that creates an object repository for UI elements. It reduces duplication and improves maintenance.


// Page Object Example in Cypress
// In cypress/support/pageObjects/LoginPage.js
class LoginPage {
  visit() {
    cy.visit('/login');
  }
  
  fillUsername(username) {
    cy.get('#username').type(username);
  }
  
  fillPassword(password) {
    cy.get('#password').type(password);
  }
  
  submit() {
    cy.get('#login-button').click();
  }
  
  getErrorMessage() {
    return cy.get('.error-message');
  }
}

export default new LoginPage();

// In your test
import LoginPage from '../support/pageObjects/LoginPage';

describe('Login Functionality', () => {
  it('should login successfully with valid credentials', () => {
    LoginPage.visit();
    LoginPage.fillUsername('validuser');
    LoginPage.fillPassword('validpass');
    LoginPage.submit();
    
    cy.url().should('include', '/dashboard');
  });
  
  it('should show error with invalid credentials', () => {
    LoginPage.visit();
    LoginPage.fillUsername('invaliduser');
    LoginPage.fillPassword('invalidpass');
    LoginPage.submit();
    
    LoginPage.getErrorMessage()
      .should('be.visible')
      .and('contain', 'Invalid username or password');
  });
});
                

Custom Commands for Common Actions

Create custom commands for frequently used operations to keep tests concise and readable.


// In cypress/support/commands.js
Cypress.Commands.add('login', (email, password) => {
  cy.visit('/login');
  cy.get('#email').type(email);
  cy.get('#password').type(password);
  cy.get('#login-button').click();
});

// In your test
describe('Dashboard Features', () => {
  beforeEach(() => {
    // Using custom command
    cy.login('user@example.com', 'password123');
  });
  
  it('should display user profile correctly', () => {
    cy.get('[data-testid="profile-link"]').click();
    cy.url().should('include', '/profile');
    cy.get('[data-testid="username-display"]').should('contain', 'user@example.com');
  });
});
                

Data-Driven Testing

Use data sets to test the same flow with different inputs, making tests more comprehensive.


// Data-driven testing example
const testCases = [
  { username: '', password: '', errorMessage: 'Username is required' },
  { username: 'user', password: '', errorMessage: 'Password is required' },
  { username: 'user@example.com', password: 'short', errorMessage: 'Password must be at least 8 characters' },
  { username: 'notanemail', password: 'password123', errorMessage: 'Invalid email format' }
];

describe('Login Validation', () => {
  testCases.forEach((testCase) => {
    it(`should show correct error: ${testCase.errorMessage}`, () => {
      cy.visit('/login');
      
      if (testCase.username) {
        cy.get('#username').type(testCase.username);
      }
      
      if (testCase.password) {
        cy.get('#password').type(testCase.password);
      }
      
      cy.get('#login-button').click();
      
      cy.get('.error-message')
        .should('be.visible')
        .and('contain', testCase.errorMessage);
    });
  });
});
                

Descriptive Test Naming

Use clear, behavior-focused names for your tests to serve as documentation.


// Bad test naming
it('test1', () => {
  // Test code...
});

// Good test naming
it('should allow users to reset their password through email confirmation', () => {
  // Test code...
});
                

Spotify's Test Organization Strategy

Spotify organizes their E2E tests by user journey rather than by page or component. For example, they have test suites like "Playlist Creation Journey" or "Music Discovery Journey" instead of "Homepage Tests" or "Search Component Tests." This approach ensures tests focus on the entire user experience rather than isolated parts of the UI.

Handling Common E2E Testing Challenges

Dealing with Asynchronous Operations

Modern web applications are full of asynchronous operations. Here's how to handle them:


// Bad: Using arbitrary waits
cy.wait(5000); // Wait 5 seconds hoping the operation completes
cy.get('#results').should('be.visible');

// Good: Waiting for specific conditions
cy.get('#results', { timeout: 10000 }).should('be.visible');

// Good: Waiting for network requests to complete
cy.intercept('GET', '/api/products').as('productLoad');
cy.visit('/products');
cy.wait('@productLoad');
cy.get('#product-list').should('have.length.greaterThan', 0);
                

Managing Test Data

Effective management of test data is crucial for reliable E2E tests:


// Example of data management in Playwright
test.describe('User Profile Tests', () => {
  test.beforeAll(async ({ request }) => {
    // Seed test database with known data
    await request.post('/api/testing/seed-database', {
      data: { seedFile: 'profile-tests-seed.json' }
    });
  });
  
  test.beforeEach(async ({ page, request }) => {
    // Create a fresh test user for each test
    const response = await request.post('/api/testing/create-user', {
      data: {
        email: `test-${Date.now()}@example.com`,
        name: 'Test User',
        settings: { theme: 'light', notifications: true }
      }
    });
    
    const { userId, authToken } = await response.json();
    
    // Set authentication state
    await page.context().addCookies([{
      name: 'auth-token',
      value: authToken,
      domain: 'example.com',
      path: '/'
    }]);
    
    // Store user ID for later use
    test.info().annotations.push({
      type: 'userId',
      description: userId
    });
  });
  
  test.afterEach(async ({ request }) => {
    // Clean up test user after each test
    const userId = test.info().annotations
      .find(a => a.type === 'userId')?.description;
      
    if (userId) {
      await request.delete(`/api/testing/delete-user/${userId}`);
    }
  });
  
  // Tests here...
});
                

Handling Flaky Tests

"Flaky" tests are inconsistent tests that sometimes pass and sometimes fail without code changes. Here's how to reduce flakiness:


// Example of dealing with potential flakiness in Cypress
Cypress.Commands.add('clickWithRetry', (selector, options = {}) => {
  const { maxAttempts = 3, delayBetweenAttempts = 1000 } = options;
  
  function attempt(attemptNumber) {
    return cy.get(selector, { timeout: 10000 })
      .should('be.visible')
      .then($el => {
        try {
          $el.trigger('click');
        } catch (error) {
          if (attemptNumber < maxAttempts) {
            cy.wait(delayBetweenAttempts);
            return attempt(attemptNumber + 1);
          } else {
            throw error;
          }
        }
      });
  }
  
  return attempt(1);
});

// Using the retry command
it('should navigate to product details', () => {
  cy.visit('/products');
  // Use custom command for potentially flaky element
  cy.clickWithRetry('[data-testid="product-card"]');
  cy.url().should('include', '/product/');
});
                

Testing Across Browsers and Devices

Modern applications need to work across multiple browsers and devices. Consider these approaches:


// Playwright example of cross-browser testing
import { test, devices } from '@playwright/test';

// Test on desktop browsers
test.describe('Product search on desktop', () => {
  test('in Chromium', async ({ browser }) => {
    const context = await browser.newContext();
    const page = await context.newPage();
    // Test code here...
  });
  
  test('in Firefox', async ({ browser }) => {
    const context = await browser.newContext();
    const page = await context.newPage();
    // Test code here...
  });
  
  test('in WebKit', async ({ browser }) => {
    const context = await browser.newContext();
    const page = await context.newPage();
    // Test code here...
  });
});

// Test on mobile devices
test.describe('Product search on mobile', () => {
  test('iPhone 12', async ({ browser }) => {
    const context = await browser.newContext({
      ...devices['iPhone 12']
    });
    const page = await context.newPage();
    // Mobile-specific test code here...
  });
  
  test('Samsung Galaxy S8', async ({ browser }) => {
    const context = await browser.newContext({
      ...devices['Galaxy S8']
    });
    const page = await context.newPage();
    // Mobile-specific test code here...
  });
});
                

Advanced Test Scenarios

Testing Authentication Flows

Authentication is critical to most applications and requires careful testing:


// Testing multi-step password reset flow
describe('Password Reset Flow', () => {
  it('should allow user to reset password via email link', () => {
    // Visit login page
    cy.visit('/login');
    
    // Click forgot password
    cy.get('[data-testid="forgot-password"]').click();
    
    // Enter email for reset
    cy.get('[data-testid="reset-email"]').type('test@example.com');
    cy.get('[data-testid="submit-reset"]').click();
    
    // Verify confirmation message
    cy.get('[data-testid="reset-confirmation"]')
      .should('be.visible')
      .and('contain', 'Email sent');
    
    // In a real scenario, we would intercept the email
    // For testing, we can use a direct API call to get the reset token
    cy.request('GET', '/api/testing/get-reset-token?email=test@example.com')
      .then((response) => {
        const resetToken = response.body.token;
        
        // Visit reset link with token
        cy.visit(`/reset-password?token=${resetToken}`);
        
        // Enter new password
        cy.get('[data-testid="new-password"]').type('NewPassword123!');
        cy.get('[data-testid="confirm-password"]').type('NewPassword123!');
        cy.get('[data-testid="submit-new-password"]').click();
        
        // Verify success and redirect to login
        cy.get('[data-testid="reset-success"]')
          .should('be.visible')
          .and('contain', 'Password updated');
        
        cy.url().should('include', '/login');
        
        // Verify login works with new password
        cy.get('#email').type('test@example.com');
        cy.get('#password').type('NewPassword123!');
        cy.get('#login-button').click();
        
        cy.url().should('include', '/dashboard');
      });
  });
});
                

Testing Payment Processes

Payment flows are critical business processes that require thorough testing:


// Testing payment flow with test cards
describe('Payment Process', () => {
  beforeEach(() => {
    // Set up cart with items
    cy.visit('/');
    cy.addProductToCart('product-1'); // Custom command
    cy.get('[data-testid="checkout"]').click();
    cy.url().should('include', '/checkout');
  });

  it('should process successful payment', () => {
    // Fill shipping info
    cy.fillShippingInfo({
      name: 'Test User',
      address: '123 Test St',
      city: 'Test City',
      zip: '12345'
    });
    
    cy.get('[data-testid="continue-to-payment"]').click();
    
    // Use test card that will succeed (many payment gateways provide test cards)
    cy.get('[data-testid="card-number"]').type('4242424242424242');
    cy.get('[data-testid="card-expiry"]').type('1230');
    cy.get('[data-testid="card-cvc"]').type('123');
    cy.get('[data-testid="card-name"]').type('Test User');
    
    cy.get('[data-testid="submit-payment"]').click();
    
    // Verify order confirmation
    cy.url().should('include', '/order-confirmation');
    cy.get('[data-testid="order-number"]').should('exist');
    cy.get('[data-testid="payment-status"]').should('contain', 'Payment successful');
  });
  
  it('should handle declined payment', () => {
    // Fill shipping info
    cy.fillShippingInfo({
      name: 'Test User',
      address: '123 Test St',
      city: 'Test City',
      zip: '12345'
    });
    
    cy.get('[data-testid="continue-to-payment"]').click();
    
    // Use test card that will be declined
    cy.get('[data-testid="card-number"]').type('4000000000000002');
    cy.get('[data-testid="card-expiry"]').type('1230');
    cy.get('[data-testid="card-cvc"]').type('123');
    cy.get('[data-testid="card-name"]').type('Test User');
    
    cy.get('[data-testid="submit-payment"]').click();
    
    // Verify error message
    cy.get('[data-testid="payment-error"]')
      .should('be.visible')
      .and('contain', 'Your card was declined');
    
    // Verify we're still on the payment page
    cy.url().should('include', '/payment');
  });
});
                

Testing Real-time Features

Modern applications often include real-time components like chat, notifications, or live updates:


// Testing a chat application
describe('Real-time Chat', () => {
  beforeEach(() => {
    // Create two browser contexts to simulate two users
    cy.task('createTwoUsers').then(([user1, user2]) => {
      cy.task('openUserChat', { user1, user2 });
    });
  });
  
  it('should deliver messages in real-time', () => {
    // In the first browser (user1)
    cy.get('@user1').within(() => {
      cy.get('[data-testid="message-input"]').type('Hello from user 1');
      cy.get('[data-testid="send-button"]').click();
    });
    
    // In the second browser (user2), the message should appear
    cy.get('@user2').within(() => {
      cy.get('[data-testid="chat-messages"]')
        .should('contain', 'Hello from user 1');
        
      // User2 replies
      cy.get('[data-testid="message-input"]').type('Hi back from user 2');
      cy.get('[data-testid="send-button"]').click();
    });
    
    // Back in the first browser, we should see the reply
    cy.get('@user1').within(() => {
      cy.get('[data-testid="chat-messages"]')
        .should('contain', 'Hi back from user 2');
    });
  });
  
  it('should show typing indicators', () => {
    // User1 starts typing
    cy.get('@user1').within(() => {
      cy.get('[data-testid="message-input"]').type('I am typing...');
    });
    
    // User2 should see typing indicator
    cy.get('@user2').within(() => {
      cy.get('[data-testid="typing-indicator"]')
        .should('be.visible')
        .and('contain', 'User1 is typing');
    });
    
    // When user1 stops typing, indicator should disappear
    cy.get('@user1').within(() => {
      cy.get('[data-testid="message-input"]').clear();
    });
    
    // Typing indicator should disappear for user2
    cy.get('@user2').within(() => {
      cy.get('[data-testid="typing-indicator"]')
        .should('not.exist');
    });
  });
});
                

Complex Scenario Coverage

graph TD A[User Registration] --> B[Product Search] B --> C[Add to Cart] C --> D[Checkout Process] D --> E[Payment] E --> F[Order Confirmation] F --> G[Order History] B --> H[Save to Wishlist] H --> I[Share Wishlist] C --> J[Abandoned Cart Email] J --> K[Return to Cart] K --> D F --> L[Review Product]

E-commerce user flows that could be covered by E2E tests

Practical Activity: Writing a Multi-step Test Scenario

In this activity, you'll plan and implement a multi-step E2E test scenario for a blog application.

Scenario: Creating and Publishing a Blog Post

The scenario should test the full lifecycle of blog post creation:

  1. User logs in as an author
  2. User creates a new blog post with a title, content, and tags
  3. User saves the post as a draft
  4. User previews the post
  5. User makes edits to the post
  6. User publishes the post
  7. User verifies the post appears on the public blog
  8. User verifies post can be searched for

Step 1: Plan Your Test

Create a test plan using this template:

Step Action Expected Result Elements to Interact With Potential Assertions
1 Log in as author Author dashboard displayed - Login form
- Submit button
- URL includes '/dashboard'
- Welcome message visible

Step 2: Implement the Test

Now implement your test plan as code using the framework of your choice (Cypress example below):


describe('Blog Post Creation and Publishing', () => {
  beforeEach(() => {
    // Login as an author
    cy.login('author@example.com', 'authorPassword');
    cy.visit('/dashboard');
  });
  
  it('should allow creating and publishing a blog post', () => {
    // Step 1: Already logged in from beforeEach
    cy.url().should('include', '/dashboard');
    cy.get('[data-testid="welcome-message"]').should('contain', 'Welcome');
    
    // Step 2: Create new post
    cy.get('[data-testid="new-post-button"]').click();
    
    // Fill in post details
    const postTitle = `Test Post ${Date.now()}`;
    const postContent = 'This is a test post content with some **markdown** formatting.';
    
    cy.get('[data-testid="post-title"]').type(postTitle);
    cy.get('[data-testid="post-content"]').type(postContent);
    cy.get('[data-testid="tag-input"]').type('testing{enter}automation{enter}');
    
    // Step 3: Save as draft
    cy.get('[data-testid="save-draft-button"]').click();
    cy.get('[data-testid="save-confirmation"]').should('be.visible');
    
    // Step 4: Preview the post
    cy.get('[data-testid="preview-button"]').click();
    cy.url().should('include', '/preview');
    cy.get('[data-testid="preview-title"]').should('contain', postTitle);
    cy.get('[data-testid="preview-content"]').should('contain', 'This is a test post');
    cy.get('[data-testid="preview-tag"]').should('contain', 'testing');
    
    // Step 5: Edit the post
    cy.get('[data-testid="edit-button"]').click();
    const updatedContent = 'Updated content with even more **markdown** formatting.';
    cy.get('[data-testid="post-content"]').clear().type(updatedContent);
    cy.get('[data-testid="save-draft-button"]').click();
    cy.get('[data-testid="save-confirmation"]').should('be.visible');
    
    // Step 6: Publish the post
    cy.get('[data-testid="publish-button"]').click();
    cy.get('[data-testid="confirm-publish"]').click();
    cy.get('[data-testid="publish-confirmation"]')
      .should('be.visible')
      .and('contain', 'successfully published');
    
    // Step 7: Verify on public blog
    cy.visit('/blog');
    cy.get('[data-testid="blog-post"]')
      .should('contain', postTitle);
    
    // Click on the post
    cy.contains(postTitle).click();
    cy.url().should('include', '/blog/');
    cy.get('[data-testid="post-title"]').should('contain', postTitle);
    cy.get('[data-testid="post-content"]').should('contain', 'Updated content');
    
    // Step 8: Verify search
    cy.visit('/blog');
    cy.get('[data-testid="search-input"]').type(postTitle);
    cy.get('[data-testid="search-button"]').click();
    cy.get('[data-testid="search-results"]')
      .should('contain', postTitle);
  });
});
                

Step 3: Add Error Scenarios

Extend your test to handle error cases:

Try implementing at least one of these error scenarios as an additional test.

Key Takeaways

Further Resources

Homework Assignment

For this assignment, you'll develop a comprehensive E2E test suite for either:

  1. The blog application from our in-class exercise, or
  2. Your personal project application

Requirements:

Bonus:

Submit your code to the course repository by the next class.