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
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:
- Directly impact business outcomes (e.g., purchasing a product)
- Are frequently used by users (e.g., user login)
- Have complex interactions across multiple parts of the system
- Have experienced problems in the past
Real-World Example: Netflix's Approach
Netflix focuses their E2E tests on the critical path of content playback. They prioritize testing:
- User login and authentication
- Browsing and navigating content
- Selecting and starting playback
- Video playback quality and controls
- 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
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:
- Seed data: Create known data states before tests run
- Isolation: Ensure tests don't interfere with each other
- Cleanup: Reset data after tests complete
// 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:
- Use explicit waiting instead of fixed timeouts
- Make selectors more robust (data-testid attributes)
- Implement smart retries for inherently unstable operations
- Isolate tests from each other to prevent interference
// 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:
- Use a framework that supports cross-browser testing (Playwright, TestCafe)
- Define a matrix of critical browsers to test
- Use responsive design testing for different viewport sizes
- Consider specialized services for device testing
// 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:
- Login with valid/invalid credentials
- Password reset flows
- Account lockout after failed attempts
- Social login integration
- Session timeout handling
- Remember me functionality
// 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:
- Credit card validation
- Payment gateway integration
- Order completion
- Receipt generation
- Error handling for declined payments
// 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
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:
- User logs in as an author
- User creates a new blog post with a title, content, and tags
- User saves the post as a draft
- User previews the post
- User makes edits to the post
- User publishes the post
- User verifies the post appears on the public blog
- 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:
- What happens if you try to publish without a title?
- What if the content is too short?
- What if you lose connection during publishing?
Try implementing at least one of these error scenarios as an additional test.
Key Takeaways
- Effective E2E tests start with proper planning and identifying critical user journeys
- Structure tests using the Arrange-Act-Assert pattern for clarity and maintainability
- Use page objects, custom commands, and data-driven approaches for better organization
- Handle asynchronous operations with explicit waiting and avoid fixed timeouts
- Manage test data carefully to ensure test reliability and isolation
- Advanced scenarios require special consideration for authentication, payments, and real-time features
Further Resources
Homework Assignment
For this assignment, you'll develop a comprehensive E2E test suite for either:
- The blog application from our in-class exercise, or
- Your personal project application
Requirements:
- Create a test plan document listing at least 5 critical user journeys
- Implement at least 3 of these journeys as E2E tests
- Use Page Objects or custom commands for reusable elements
- Include at least one data-driven test
- Handle at least one error scenario
- Implement proper waiting strategies (no fixed timeouts)
- Include a README explaining how to run the tests
Bonus:
- Set up your tests to run in a CI/CD pipeline
- Add cross-browser testing
- Implement visual testing for UI components
Submit your code to the course repository by the next class.