Prerequisites
- Basic TypeScript knowledge π
- Understanding of web applications β‘
- Familiarity with DOM interactions π»
- Integration testing concepts π
What you'll learn
- Set up Cypress with TypeScript for E2E testing π―
- Write comprehensive end-to-end test scenarios ποΈ
- Handle complex user interactions and workflows π
- Test real browser behavior and edge cases π
- Debug and optimize E2E test performance π
π― Introduction
Welcome to the ultimate testing experience - End-to-End (E2E) testing with Cypress! π If integration tests are like testing your recipe by tasting each step, E2E tests are like inviting friends over for dinner and watching them enjoy the complete meal experience! π½οΈ
Cypress is the rockstar of E2E testing frameworks, and when combined with TypeScript, it becomes a powerhouse that ensures your entire application works flawlessly from the userβs perspective. Youβll learn to simulate real user interactions, test complete workflows, and catch those sneaky bugs that only appear in production.
By the end of this tutorial, youβll be confidently writing E2E tests that give you peace of mind about your applicationβs reliability! Letβs dive into this exciting journey! πββοΈ
π Understanding End-to-End Testing
π€ What is End-to-End Testing?
E2E testing is like being a secret shopper for your own app! π΅οΈββοΈ It tests your application exactly as a real user would interact with it - clicking buttons, filling forms, navigating pages, and verifying the complete user experience.
E2E testing with Cypress means:
- β¨ Testing in real browsers (Chrome, Firefox, Edge)
- π Simulating actual user interactions
- π‘οΈ Verifying complete user workflows
- π Testing across different screen sizes and devices
π‘ Why Cypress + TypeScript?
Hereβs why this combination is absolutely magical:
- Type Safety π: Catch errors before running tests
- IntelliSense π»: Amazing autocomplete and suggestions
- Refactoring Support π§: Rename safely across test files
- Better Debugging π: Clear error messages and stack traces
- Team Collaboration π€: Self-documenting test code
Real-world example: Your TypeScript E2E tests catch a breaking change when someone renames a data attribute that your tests depend on! π‘οΈ
π§ Cypress + TypeScript Setup
π Installation and Configuration
Letβs get Cypress running with TypeScript:
# π¦ Install Cypress and TypeScript support
npm install --save-dev cypress typescript
npm install --save-dev @cypress/webpack-preprocessor ts-loader
# π Initialize Cypress
npx cypress open
TypeScript Configuration:
// π cypress.config.ts
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
// π― Base URL for your application
baseUrl: 'http://localhost:3000',
// π Test files location
specPattern: 'cypress/e2e/**/*.cy.ts',
// π§ Support files
supportFile: 'cypress/support/e2e.ts',
// π Viewport settings
viewportWidth: 1280,
viewportHeight: 720,
// β±οΈ Timeouts
defaultCommandTimeout: 10000,
requestTimeout: 10000,
responseTimeout: 10000,
// πΉ Video and screenshots
video: true,
screenshotOnRunFailure: true,
// π¨ Test runner settings
experimentalStudio: true,
setupNodeEvents(on, config) {
// π§ TypeScript preprocessing
const webpack = require('@cypress/webpack-preprocessor');
const options = {
webpackOptions: {
resolve: {
extensions: ['.ts', '.tsx', '.js']
},
module: {
rules: [
{
test: /\.tsx?$/,
loader: 'ts-loader',
options: { transpileOnly: true }
}
]
}
}
};
on('file:preprocessor', webpack(options));
return config;
}
}
});
TypeScript Support Files:
// π cypress/support/e2e.ts
import './commands';
// π― Global type declarations
declare global {
namespace Cypress {
interface Chainable {
// π Custom login command
login(email: string, password: string): Chainable<void>;
// π Custom shopping cart commands
addToCart(productId: string): Chainable<void>;
checkout(): Chainable<void>;
// π Form helpers
fillContactForm(data: ContactFormData): Chainable<void>;
// π― Wait for API calls
waitForApi(endpoint: string): Chainable<void>;
}
}
}
// π Custom types for test data
export interface ContactFormData {
name: string;
email: string;
message: string;
emoji?: string;
}
export interface UserTestData {
id: string;
email: string;
password: string;
name: string;
role: 'user' | 'admin';
}
π¨ Custom Commands
Create reusable TypeScript commands:
// π cypress/support/commands.ts
import { ContactFormData, UserTestData } from './e2e';
// π Authentication commands
Cypress.Commands.add('login', (email: string, password: string) => {
cy.log(`π Logging in as ${email}`);
cy.visit('/login');
cy.get('[data-cy=email-input]').type(email);
cy.get('[data-cy=password-input]').type(password);
cy.get('[data-cy=login-button]').click();
// β
Verify successful login
cy.url().should('not.include', '/login');
cy.get('[data-cy=user-menu]').should('be.visible');
cy.log('β
Login successful!');
});
// π E-commerce commands
Cypress.Commands.add('addToCart', (productId: string) => {
cy.log(`π Adding product ${productId} to cart`);
cy.get(`[data-cy=product-${productId}]`).within(() => {
cy.get('[data-cy=add-to-cart-btn]').click();
});
// β
Verify product added
cy.get('[data-cy=cart-count]').should('contain', '1');
cy.get('.toast-success').should('contain', 'Added to cart! π');
cy.log('β
Product added to cart!');
});
// π Form helpers
Cypress.Commands.add('fillContactForm', (data: ContactFormData) => {
cy.log(`π Filling contact form for ${data.name}`);
cy.get('[data-cy=name-input]').type(data.name);
cy.get('[data-cy=email-input]').type(data.email);
cy.get('[data-cy=message-textarea]').type(data.message);
if (data.emoji) {
cy.get('[data-cy=emoji-selector]').select(data.emoji);
}
cy.log('β
Contact form filled!');
});
// π API waiting helpers
Cypress.Commands.add('waitForApi', (endpoint: string) => {
cy.log(`β³ Waiting for API call to ${endpoint}`);
cy.intercept('GET', `**/api${endpoint}`).as('apiCall');
cy.wait('@apiCall').then((interception) => {
expect(interception.response?.statusCode).to.eq(200);
});
cy.log('β
API call completed!');
});
π‘ Practical E2E Test Examples
π Example 1: E-commerce User Journey
Letβs test a complete shopping experience:
// π§ͺ cypress/e2e/ecommerce/shopping-flow.cy.ts
describe('π E-commerce Shopping Flow', () => {
beforeEach(() => {
// π± Setup test data
cy.task('seedDatabase');
cy.visit('/');
});
describe('π― Happy Path Shopping', () => {
it('should complete full shopping journey', () => {
// π Start from homepage
cy.log('π Starting shopping journey from homepage');
// π Search for product
cy.get('[data-cy=search-input]').type('TypeScript Coffee Mug{enter}');
cy.get('[data-cy=search-results]').should('be.visible');
// π± Verify product appears
cy.get('[data-cy=product-list]').within(() => {
cy.contains('TypeScript Coffee Mug β').should('be.visible');
cy.get('[data-cy=product-price]').should('contain', '$19.99');
});
// π Click on product
cy.get('[data-cy=product-typescript-mug]').click();
// π Verify product details page
cy.url().should('include', '/products/typescript-mug');
cy.get('[data-cy=product-title]').should('contain', 'TypeScript Coffee Mug');
cy.get('[data-cy=product-description]').should('be.visible');
cy.get('[data-cy=product-images]').should('be.visible');
// π Add to cart
cy.addToCart('typescript-mug');
// π Proceed to checkout
cy.get('[data-cy=cart-icon]').click();
cy.get('[data-cy=checkout-button]').click();
// π€ Login for checkout
cy.login('[email protected]', 'SecurePass123!');
// π Fill shipping information
cy.log('π Filling shipping information');
cy.get('[data-cy=shipping-form]').within(() => {
cy.get('[data-cy=address-input]').type('123 TypeScript Lane');
cy.get('[data-cy=city-input]').type('Code City');
cy.get('[data-cy=zip-input]').type('12345');
cy.get('[data-cy=country-select]').select('United States πΊπΈ');
});
// π³ Payment information
cy.log('π³ Processing payment');
cy.get('[data-cy=payment-form]').within(() => {
cy.get('[data-cy=card-number]').type('4242424242424242');
cy.get('[data-cy=card-expiry]').type('12/25');
cy.get('[data-cy=card-cvc]').type('123');
cy.get('[data-cy=card-name]').type('Happy Customer');
});
// π Review order
cy.get('[data-cy=order-summary]').within(() => {
cy.contains('TypeScript Coffee Mug').should('be.visible');
cy.contains('$19.99').should('be.visible');
cy.contains('Shipping: $4.99').should('be.visible');
cy.contains('Total: $24.98').should('be.visible');
});
// π― Place order
cy.get('[data-cy=place-order-button]').click();
// β
Verify success
cy.url().should('include', '/order-confirmation');
cy.get('[data-cy=success-message]').should('contain', 'Order placed successfully! π');
cy.get('[data-cy=order-number]').should('be.visible');
// π§ Verify confirmation email notice
cy.get('[data-cy=email-notice]').should('contain', 'confirmation email has been sent');
cy.log('π Complete shopping journey successful!');
});
it('should handle out of stock products gracefully', () => {
// π Search for out-of-stock product
cy.visit('/products/out-of-stock-item');
// β οΈ Verify out of stock message
cy.get('[data-cy=stock-status]').should('contain', 'Out of Stock');
cy.get('[data-cy=add-to-cart-btn]').should('be.disabled');
// π§ Enable notification option
cy.get('[data-cy=notify-when-available]').should('be.visible');
cy.get('[data-cy=email-notification-input]').type('[email protected]');
cy.get('[data-cy=notify-button]').click();
// β
Verify notification setup
cy.get('.toast-success').should('contain', 'We\'ll notify you when available! π§');
cy.log('β
Out of stock handling test passed!');
});
});
describe('π« Error Scenarios', () => {
it('should handle payment failures gracefully', () => {
// π Add product and proceed to checkout
cy.visit('/products/typescript-mug');
cy.addToCart('typescript-mug');
cy.get('[data-cy=checkout-button]').click();
cy.login('[email protected]', 'SecurePass123!');
// π³ Use invalid card number
cy.get('[data-cy=payment-form]').within(() => {
cy.get('[data-cy=card-number]').type('4000000000000002'); // Declined card
cy.get('[data-cy=card-expiry]').type('12/25');
cy.get('[data-cy=card-cvc]').type('123');
cy.get('[data-cy=card-name]').type('Test User');
});
cy.get('[data-cy=place-order-button]').click();
// β οΈ Verify error handling
cy.get('[data-cy=payment-error]').should('contain', 'Payment failed');
cy.get('[data-cy=retry-payment-btn]').should('be.visible');
// π User should be able to retry
cy.url().should('include', '/checkout');
cy.log('β
Payment failure handling test passed!');
});
});
});
π Example 2: Authentication and User Management
Testing user authentication flows:
// π§ͺ cypress/e2e/auth/authentication.cy.ts
describe('π Authentication System', () => {
beforeEach(() => {
cy.task('resetDatabase');
cy.visit('/');
});
describe('π€ User Registration', () => {
it('should register new user successfully', () => {
const newUser = {
name: 'New TypeScript Developer π¨βπ»',
email: '[email protected]',
password: 'SecureTypeScript123!'
};
// π Navigate to registration
cy.get('[data-cy=register-link]').click();
cy.url().should('include', '/register');
// π Fill registration form
cy.get('[data-cy=registration-form]').within(() => {
cy.get('[data-cy=name-input]').type(newUser.name);
cy.get('[data-cy=email-input]').type(newUser.email);
cy.get('[data-cy=password-input]').type(newUser.password);
cy.get('[data-cy=confirm-password-input]').type(newUser.password);
// β
Accept terms
cy.get('[data-cy=terms-checkbox]').check();
// π Submit registration
cy.get('[data-cy=register-button]').click();
});
// β
Verify registration success
cy.url().should('include', '/verify-email');
cy.get('[data-cy=verification-message]').should('contain', 'Check your email');
// π§ Simulate email verification
cy.task('getVerificationToken', newUser.email).then((token) => {
cy.visit(`/verify-email?token=${token}`);
// β
Verify account activation
cy.get('[data-cy=verification-success]').should('be.visible');
cy.get('[data-cy=login-link]').click();
});
// π Login with new account
cy.login(newUser.email, newUser.password);
// β
Verify successful login
cy.get('[data-cy=welcome-message]').should('contain', newUser.name);
cy.get('[data-cy=user-avatar]').should('be.visible');
cy.log('π User registration flow completed successfully!');
});
it('should validate registration form inputs', () => {
cy.get('[data-cy=register-link]').click();
// π« Test empty form submission
cy.get('[data-cy=register-button]').click();
// β οΈ Verify validation errors
cy.get('[data-cy=name-error]').should('contain', 'Name is required');
cy.get('[data-cy=email-error]').should('contain', 'Email is required');
cy.get('[data-cy=password-error]').should('contain', 'Password is required');
// π§ Test invalid email
cy.get('[data-cy=email-input]').type('invalid-email');
cy.get('[data-cy=register-button]').click();
cy.get('[data-cy=email-error]').should('contain', 'Invalid email format');
// π Test weak password
cy.get('[data-cy=email-input]').clear().type('[email protected]');
cy.get('[data-cy=password-input]').type('weak');
cy.get('[data-cy=register-button]').click();
cy.get('[data-cy=password-error]').should('contain', 'Password too weak');
cy.log('β
Form validation tests passed!');
});
});
describe('π Password Reset Flow', () => {
it('should handle password reset process', () => {
const userEmail = '[email protected]';
// π Go to login page
cy.get('[data-cy=login-link]').click();
// π Click forgot password
cy.get('[data-cy=forgot-password-link]').click();
// π§ Enter email for reset
cy.get('[data-cy=reset-email-input]').type(userEmail);
cy.get('[data-cy=send-reset-button]').click();
// β
Verify reset email sent
cy.get('[data-cy=reset-sent-message]').should('contain', 'Reset link sent');
// π Simulate clicking reset link
cy.task('getPasswordResetToken', userEmail).then((token) => {
cy.visit(`/reset-password?token=${token}`);
// π Set new password
const newPassword = 'NewSecurePass123!';
cy.get('[data-cy=new-password-input]').type(newPassword);
cy.get('[data-cy=confirm-new-password-input]').type(newPassword);
cy.get('[data-cy=reset-password-button]').click();
// β
Verify password reset success
cy.get('[data-cy=password-reset-success]').should('be.visible');
// π Test login with new password
cy.login(userEmail, newPassword);
cy.get('[data-cy=user-menu]').should('be.visible');
});
cy.log('π Password reset flow completed successfully!');
});
});
});
π± Example 3: Responsive Design Testing
Test across different devices:
// π§ͺ cypress/e2e/responsive/mobile-experience.cy.ts
describe('π± Mobile Experience', () => {
const devices = [
{ name: 'iPhone 12', width: 390, height: 844 },
{ name: 'iPad', width: 820, height: 1180 },
{ name: 'Samsung Galaxy', width: 360, height: 740 }
];
devices.forEach((device) => {
describe(`π ${device.name} (${device.width}x${device.height})`, () => {
beforeEach(() => {
cy.viewport(device.width, device.height);
cy.visit('/');
});
it('should have working mobile navigation', () => {
// π Test hamburger menu
cy.get('[data-cy=hamburger-menu]').should('be.visible');
cy.get('[data-cy=desktop-nav]').should('not.be.visible');
// π Open mobile menu
cy.get('[data-cy=hamburger-menu]').click();
cy.get('[data-cy=mobile-nav]').should('be.visible');
// π Test navigation links
cy.get('[data-cy=mobile-nav]').within(() => {
cy.get('[data-cy=nav-products]').should('be.visible');
cy.get('[data-cy=nav-about]').should('be.visible');
cy.get('[data-cy=nav-contact]').should('be.visible');
});
// β Close menu
cy.get('[data-cy=close-mobile-nav]').click();
cy.get('[data-cy=mobile-nav]').should('not.be.visible');
cy.log(`β
Mobile navigation works on ${device.name}!`);
});
it('should have touch-friendly buttons', () => {
cy.visit('/products');
// π― Test button sizes (minimum 44px for touch)
cy.get('[data-cy=add-to-cart-btn]').first().then(($btn) => {
const height = $btn.height() || 0;
expect(height).to.be.at.least(44);
});
// π Test touch interactions
cy.get('[data-cy=product-card]').first().within(() => {
cy.get('[data-cy=add-to-cart-btn]').click();
});
// β
Verify touch feedback
cy.get('.toast-success').should('be.visible');
cy.log(`β
Touch interactions work on ${device.name}!`);
});
});
});
});
π Advanced Cypress Patterns
π§ββοΈ API Testing Integration
Combine E2E with API testing:
// π Testing API calls during E2E flows
describe('π API Integration Tests', () => {
it('should intercept and verify API calls', () => {
// π Set up API intercepts
cy.intercept('GET', '/api/products*', { fixture: 'products.json' }).as('getProducts');
cy.intercept('POST', '/api/orders', { fixture: 'order-response.json' }).as('createOrder');
// π Perform user actions
cy.visit('/products');
cy.wait('@getProducts').then((interception) => {
expect(interception.response?.statusCode).to.eq(200);
expect(interception.response?.body).to.have.property('products');
});
// π― Test order creation
cy.addToCart('typescript-mug');
cy.get('[data-cy=checkout-button]').click();
cy.login('[email protected]', 'SecurePass123!');
cy.get('[data-cy=place-order-button]').click();
// β
Verify API was called correctly
cy.wait('@createOrder').then((interception) => {
expect(interception.request.body).to.have.property('items');
expect(interception.request.body.items).to.have.length(1);
expect(interception.request.body.items[0].productId).to.eq('typescript-mug');
});
cy.log('π API integration test passed!');
});
});
π Page Object Pattern
Organize your tests with Page Objects:
// π cypress/support/pages/LoginPage.ts
export class LoginPage {
private selectors = {
emailInput: '[data-cy=email-input]',
passwordInput: '[data-cy=password-input]',
loginButton: '[data-cy=login-button]',
errorMessage: '[data-cy=login-error]',
forgotPasswordLink: '[data-cy=forgot-password-link]'
};
visit(): void {
cy.visit('/login');
cy.log('π Visited login page');
}
fillEmail(email: string): LoginPage {
cy.get(this.selectors.emailInput).type(email);
return this;
}
fillPassword(password: string): LoginPage {
cy.get(this.selectors.passwordInput).type(password);
return this;
}
clickLogin(): void {
cy.get(this.selectors.loginButton).click();
}
login(email: string, password: string): void {
this.fillEmail(email)
.fillPassword(password)
.clickLogin();
cy.log(`π Logged in as ${email}`);
}
verifyErrorMessage(message: string): void {
cy.get(this.selectors.errorMessage).should('contain', message);
}
clickForgotPassword(): void {
cy.get(this.selectors.forgotPasswordLink).click();
}
}
// π§ͺ Using Page Object in tests
import { LoginPage } from '../support/pages/LoginPage';
describe('π Login with Page Object', () => {
const loginPage = new LoginPage();
it('should login successfully', () => {
loginPage.visit();
loginPage.login('[email protected]', 'password123');
// β
Verify successful login
cy.url().should('not.include', '/login');
});
});
β οΈ Common E2E Testing Pitfalls
π± Pitfall 1: Flaky Tests Due to Timing
// β Wrong - race conditions and flaky tests!
describe('π« Flaky Test Example', () => {
it('has timing issues', () => {
cy.visit('/dashboard');
cy.get('[data-cy=loading-spinner]'); // Doesn't wait!
cy.get('[data-cy=dashboard-data]').should('be.visible'); // π₯ Fails randomly!
});
});
// β
Correct - proper waiting and assertions!
describe('β
Stable Test Example', () => {
it('waits properly for content', () => {
cy.visit('/dashboard');
// β³ Wait for loading to complete
cy.get('[data-cy=loading-spinner]').should('be.visible');
cy.get('[data-cy=loading-spinner]').should('not.exist');
// β
Then verify content
cy.get('[data-cy=dashboard-data]').should('be.visible');
cy.get('[data-cy=user-stats]').should('contain', 'Welcome back');
});
});
π€― Pitfall 2: Hardcoded Test Data
// β Wrong - brittle hardcoded values!
describe('π« Hardcoded Test Data', () => {
it('uses hardcoded values', () => {
cy.visit('/products');
cy.get('[data-cy=product-list]').should('have.length', 42); // π₯ Brittle!
cy.contains('Specific Product Name').click(); // π₯ Brittle!
});
});
// β
Correct - flexible test data!
describe('β
Flexible Test Data', () => {
beforeEach(() => {
// π± Seed known test data
cy.task('seedTestProducts', {
count: 5,
category: 'typescript-tools'
});
});
it('uses dynamic test data', () => {
cy.visit('/products');
// β
Flexible assertions
cy.get('[data-cy=product-list]').should('have.length.at.least', 1);
cy.get('[data-cy=product-card]').first().within(() => {
cy.get('[data-cy=product-name]').should('not.be.empty');
cy.get('[data-cy=product-price]').should('match', /\$\d+\.\d{2}/);
});
});
});
π οΈ E2E Testing Best Practices
- π― Test User Journeys: Focus on complete workflows, not individual features
- π± Test Multiple Viewports: Ensure responsive design works everywhere
- β‘ Keep Tests Independent: Each test should work in isolation
- π·οΈ Use Data Attributes: Add
data-cy
attributes for reliable selectors - π Monitor Test Performance: Keep test suite execution time reasonable
- π Practice DRY: Create reusable commands and page objects
- π Mock External Services: Control third-party dependencies
π§ͺ Hands-On Exercise
π― Challenge: Build a Social Media App E2E Test Suite
Create comprehensive E2E tests for a social media platform:
π Requirements:
- β User registration and profile setup
- π Create, edit, and delete posts
- β€οΈ Like and comment on posts
- π₯ Follow/unfollow users
- π Search for users and content
- π± Test on mobile and desktop
- π Test dark/light theme switching
π Bonus Points:
- Real-time notification testing
- Image upload functionality
- Privacy settings testing
- Report content functionality
π‘ Solution Starter
π Click to see solution starter
// π― Social media E2E test starter
describe('π± Social Media Platform', () => {
const testUser = {
name: 'Social Media Star β',
email: '[email protected]',
password: 'SocialPass123!'
};
beforeEach(() => {
cy.task('resetSocialDatabase');
cy.task('createTestUser', testUser);
});
describe('π€ User Profile Journey', () => {
it('should complete profile setup after registration', () => {
// π Login
cy.login(testUser.email, testUser.password);
// π€ Setup profile
cy.get('[data-cy=profile-setup-prompt]').should('be.visible');
cy.get('[data-cy=setup-profile-btn]').click();
// π Fill profile information
cy.get('[data-cy=profile-form]').within(() => {
cy.get('[data-cy=bio-textarea]').type('TypeScript enthusiast! π');
cy.get('[data-cy=location-input]').type('Code City ποΈ');
cy.get('[data-cy=website-input]').type('https://typescript.dev');
});
// πΈ Upload profile picture
cy.get('[data-cy=avatar-upload]').selectFile('cypress/fixtures/avatar.jpg');
// β
Save profile
cy.get('[data-cy=save-profile-btn]').click();
// π Verify profile creation
cy.get('[data-cy=profile-success]').should('contain', 'Profile updated! π');
cy.get('[data-cy=user-avatar]').should('be.visible');
cy.log('π€ Profile setup completed successfully!');
});
});
describe('π Content Creation Flow', () => {
it('should create and interact with posts', () => {
cy.login(testUser.email, testUser.password);
// π Create new post
cy.get('[data-cy=new-post-btn]').click();
cy.get('[data-cy=post-content]').type('Just learned TypeScript E2E testing! π #typescript #testing');
cy.get('[data-cy=publish-post-btn]').click();
// β
Verify post appears in feed
cy.get('[data-cy=post-feed]').within(() => {
cy.contains('Just learned TypeScript E2E testing!').should('be.visible');
cy.get('[data-cy=post-author]').should('contain', testUser.name);
});
cy.log('π Post creation test passed!');
});
});
});
π Key Takeaways
Youβve mastered E2E testing with Cypress and TypeScript! Hereβs what you can now do:
- β Set up Cypress with TypeScript for type-safe E2E testing π§
- β Write comprehensive user journey tests that simulate real usage π
- β Handle complex interactions like authentication and payments π
- β Test responsive design across multiple devices π±
- β Debug and optimize E2E test performance π
- β Apply best practices for maintainable test suites π οΈ
Remember: E2E tests are your final safety net - they ensure your users have an amazing experience! π―
π€ Next Steps
Congratulations! π Youβve mastered E2E testing with Cypress and TypeScript!
Hereβs what to explore next:
- π» Practice with the social media app exercise above
- ποΈ Add E2E tests to your current project
- π Learn about Playwright for modern E2E testing (Tutorial #153)
- π Set up continuous integration for your E2E tests
- π Explore visual regression testing
Remember: Great E2E tests give you confidence to ship features knowing your users will love them. Keep testing, keep improving, and build amazing user experiences! π
Happy testing! ππ―β¨