E2E Testing
End-to-End Testing
Overview
End-to-End (E2E) testing validates complete user journeys through the entire application stack—from user interface through APIs, business logic, databases, and external integrations. These tests simulate real user behavior, ensuring critical workflows work correctly in production-like environments. E2E tests provide the highest confidence but are the slowest, most brittle, and most expensive to maintain, making them suitable only for critical user paths.
Modern E2E testing uses tools like Playwright, Selenium, or Cypress to automate browser interactions. The Page Object Model (POM) pattern encapsulates UI structure into reusable components, reducing maintenance when UI changes. Test stability is paramount—flaky tests that randomly fail undermine confidence and slow development. E2E tests should represent 5-10% of your total test suite, focusing on high-value scenarios that can’t be verified at lower test levels.
Key Concepts
- Complete User Journeys - Test end-to-end workflows (signup → login → purchase → confirmation)
- Browser Automation - Control real browsers (Chrome, Firefox, Safari) programmatically
- Page Object Model - Encapsulate page structure and interactions into maintainable objects
- Test Stability - Design for reliability; avoid flakiness from timing issues and brittle selectors
- Explicit Waits - Wait for elements to be present/visible/clickable instead of using sleep()
- Test Data Independence - Each test creates its own data; tests don’t depend on each other
- Headless Execution - Run browsers without GUI for CI/CD environments
- Parallel Execution - Run tests concurrently to reduce total execution time
- Critical Path Focus - Test only essential user journeys; use lower-level tests for edge cases
- Environment Parity - Test in production-like environments with realistic configurations
Best Practices
- Limit E2E Tests to Critical Paths - Only test essential user journeys; avoid exhaustive scenario coverage
- Use Page Object Model - Encapsulate page structure to reduce maintenance when UI changes
- Avoid Brittle Selectors - Use data-testid attributes instead of CSS classes or XPath
- Use Explicit Waits - Wait for elements to be ready; never use fixed sleep() delays
- Create Test Data Per Test - Use API calls to create data before test; don’t rely on existing data
- Clean Up After Tests - Delete created data or use isolated test databases
- Run Tests in Parallel - Execute independent tests concurrently to reduce suite time
- Handle Asynchronous Operations - Wait for API calls, animations, and lazy-loaded content
- Take Screenshots on Failure - Capture evidence for debugging failed tests
- Monitor Test Flakiness - Track failure rates; investigate and fix flaky tests immediately
Code Examples
✅ Playwright E2E Test with Page Objects
// Page Object for Login page
public class LoginPage {
private final Page page;
public LoginPage(Page page) {
this.page = page;
}
public void navigateTo() {
page.navigate("http://localhost:8080/login");
}
public void enterEmail(String email) {
page.locator("[data-testid='email-input']").fill(email);
}
public void enterPassword(String password) {
page.locator("[data-testid='password-input']").fill(password);
}
public void clickLoginButton() {
page.locator("[data-testid='login-button']").click();
}
public void login(String email, String password) {
enterEmail(email);
enterPassword(password);
clickLoginButton();
}
public String getErrorMessage() {
return page.locator("[data-testid='error-message']").textContent();
}
}
// Page Object for Dashboard
public class DashboardPage {
private final Page page;
public DashboardPage(Page page) {
this.page = page;
}
public void waitForLoad() {
page.waitForSelector("[data-testid='dashboard-content']",
new WaitForSelectorOptions().setState(WaitForSelectorState.VISIBLE));
}
public String getWelcomeMessage() {
return page.locator("[data-testid='welcome-message']").textContent();
}
public boolean isLogoutButtonVisible() {
return page.locator("[data-testid='logout-button']").isVisible();
}
}
// E2E Test using Page Objects
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class LoginE2ETest {
private Playwright playwright;
private Browser browser;
@BeforeAll
void setUp() {
playwright = Playwright.create();
browser = playwright.chromium().launch(new BrowserType.LaunchOptions()
.setHeadless(true));
}
@AfterAll
void tearDown() {
browser.close();
playwright.close();
}
@Test
void shouldLoginSuccessfully() {
// Given
var context = browser.newContext();
var page = context.newPage();
var testUser = createTestUser("john@example.com", "Password123!");
var loginPage = new LoginPage(page);
var dashboardPage = new DashboardPage(page);
try {
// When
loginPage.navigateTo();
loginPage.login("john@example.com", "Password123!");
// Then
dashboardPage.waitForLoad();
assertThat(dashboardPage.getWelcomeMessage())
.contains("Welcome, John");
assertThat(dashboardPage.isLogoutButtonVisible()).isTrue();
} finally {
page.close();
context.close();
deleteTestUser(testUser.getId());
}
}
@Test
void shouldShowErrorOnInvalidCredentials() {
var context = browser.newContext();
var page = context.newPage();
var loginPage = new LoginPage(page);
try {
loginPage.navigateTo();
loginPage.login("invalid@example.com", "WrongPassword");
assertThat(loginPage.getErrorMessage())
.isEqualTo("Invalid email or password");
} finally {
page.close();
context.close();
}
}
}
✅ Spring Boot Integration with Playwright
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class CheckoutE2ETest {
@LocalServerPort
private int port;
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
private Playwright playwright;
private Browser browser;
private String baseUrl;
@BeforeEach
void setUp() {
playwright = Playwright.create();
browser = playwright.chromium().launch(new BrowserType.LaunchOptions()
.setHeadless(true));
baseUrl = "http://localhost:" + port;
}
@AfterEach
void tearDown() {
browser.close();
playwright.close();
}
@Test
void shouldCompleteFullCheckoutFlow() {
var context = browser.newContext();
var page = context.newPage();
try {
// Given - Create test user and login
var user = createTestUser("customer@example.com");
var loginPage = new LoginPage(page, baseUrl);
loginPage.navigateTo();
loginPage.login("customer@example.com", "Password123!");
// When - Add items to cart
var productPage = new ProductPage(page, baseUrl);
productPage.navigateTo("PROD-123");
productPage.addToCart(2);
// When - Proceed to checkout
var cartPage = new CartPage(page, baseUrl);
cartPage.navigateTo();
cartPage.proceedToCheckout();
var checkoutPage = new CheckoutPage(page, baseUrl);
checkoutPage.fillShippingAddress(
"123 Main St", "New York", "NY", "10001"
);
checkoutPage.fillPaymentDetails(
"4111111111111111", "12/25", "123"
);
checkoutPage.submitOrder();
// Then - Verify order confirmation
var confirmationPage = new OrderConfirmationPage(page, baseUrl);
confirmationPage.waitForLoad();
assertThat(confirmationPage.getOrderNumber()).isNotEmpty();
assertThat(confirmationPage.getOrderStatus()).isEqualTo("Confirmed");
// Verify order in database
var orderId = confirmationPage.getOrderNumber();
var order = orderRepository.findByOrderNumber(orderId);
assertThat(order).isPresent()
.hasValueSatisfying(o -> {
assertThat(o.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
assertThat(o.getItems()).hasSize(1);
assertThat(o.getItems().get(0).getQuantity()).isEqualTo(2);
});
} finally {
page.close();
context.close();
deleteTestUser(user.getId());
}
}
}
✅ Handling Asynchronous Operations
public class AsyncOperationsPage {
private final Page page;
public AsyncOperationsPage(Page page) {
this.page = page;
}
// Wait for element to be visible
public void waitForLoadingComplete() {
page.waitForSelector("[data-testid='loading-spinner']",
new WaitForSelectorOptions()
.setState(WaitForSelectorState.HIDDEN)
.setTimeout(10000));
}
// Wait for API call to complete
public void clickAndWaitForApiResponse(String buttonSelector) {
// Wait for specific API response
page.waitForResponse(
response -> response.url().contains("/api/submit")
&& response.status() == 200,
() -> page.locator(buttonSelector).click()
);
}
// Wait for dynamic content to load
public void waitForSearchResults() {
page.waitForFunction(
"document.querySelectorAll('[data-testid=\"search-result\"]').length > 0",
null,
new WaitForFunctionOptions().setTimeout(5000)
);
}
// Retry operation with exponential backoff
public void retryOperation(Runnable operation, int maxAttempts) {
int attempts = 0;
while (attempts < maxAttempts) {
try {
operation.run();
return;
} catch (PlaywrightException e) {
attempts++;
if (attempts >= maxAttempts) {
throw e;
}
try {
Thread.sleep(1000 * attempts); // Exponential backoff
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException(ie);
}
}
}
}
}
✅ Screenshot and Video on Failure
@ExtendWith(PlaywrightExtension.class)
class E2ETestWithFailureCapture {
@RegisterExtension
static PlaywrightExtension extension = new PlaywrightExtension();
@Test
void testWithScreenshotOnFailure(Page page) {
try {
// Test logic
page.navigate("http://localhost:8080");
assertThat(page.title()).isEqualTo("Expected Title");
} catch (AssertionError | Exception e) {
// Capture screenshot on failure
var timestamp = Instant.now().toEpochMilli();
var screenshotPath = Paths.get("target/screenshots",
"failure-" + timestamp + ".png");
page.screenshot(new Page.ScreenshotOptions()
.setPath(screenshotPath)
.setFullPage(true));
// Capture HTML snapshot
var htmlPath = Paths.get("target/screenshots",
"failure-" + timestamp + ".html");
Files.writeString(htmlPath, page.content());
throw e;
}
}
}
// JUnit 5 Extension for automatic screenshot capture
public class PlaywrightExtension implements AfterEachCallback, ParameterResolver {
private Browser browser;
private BrowserContext context;
private Page page;
@Override
public void beforeEach(ExtensionContext context) {
var playwright = Playwright.create();
browser = playwright.chromium().launch();
this.context = browser.newContext(new Browser.NewContextOptions()
.setRecordVideoDir(Paths.get("target/videos")));
page = this.context.newPage();
}
@Override
public void afterEach(ExtensionContext context) {
if (context.getExecutionException().isPresent()) {
// Test failed - capture screenshot
var testName = context.getDisplayName();
var screenshotPath = Paths.get("target/screenshots",
testName + "-failure.png");
page.screenshot(new Page.ScreenshotOptions()
.setPath(screenshotPath));
}
page.close();
context.close();
browser.close();
}
}
❌ Brittle Selectors and Fixed Sleeps
// ❌ BAD: Brittle CSS class selectors
page.locator(".btn-primary.mt-3.submit-button").click();
// Breaks when CSS classes change
// ❌ BAD: XPath selectors
page.locator("//div[@class='container']/form/button[2]").click();
// Breaks when HTML structure changes
// ❌ BAD: Fixed sleep delays
page.locator("[data-testid='submit-button']").click();
Thread.sleep(5000); // Arbitrary wait time
assertThat(page.locator("[data-testid='success-message']").isVisible()).isTrue();
// Flaky: may succeed sometimes, fail other times
// ✅ GOOD: Stable data-testid selectors
page.locator("[data-testid='submit-button']").click();
// Resilient to style and structure changes
// ✅ GOOD: Explicit waits
page.locator("[data-testid='submit-button']").click();
page.waitForSelector("[data-testid='success-message']",
new WaitForSelectorOptions().setState(WaitForSelectorState.VISIBLE));
// Waits only as long as necessary
❌ Testing Edge Cases with E2E
// ❌ BAD: Testing validation logic with E2E tests
@Test
void shouldRejectInvalidEmail() {
loginPage.enterEmail("invalid-email");
loginPage.clickLoginButton();
assertThat(loginPage.getErrorMessage()).contains("Invalid email format");
}
@Test
void shouldRejectShortPassword() {
loginPage.enterPassword("123");
loginPage.clickLoginButton();
assertThat(loginPage.getErrorMessage()).contains("Password too short");
}
// These should be unit tests of validation logic, not E2E tests
// ✅ GOOD: Testing critical user journey
@Test
void shouldCompleteLoginAndAccessProtectedPage() {
loginPage.login("user@example.com", "Password123!");
dashboardPage.waitForLoad();
dashboardPage.navigateToProtectedSection();
assertThat(protectedPage.isContentVisible()).isTrue();
}
// Tests end-to-end flow that can't be verified at lower levels
Anti-Patterns
- Testing Business Logic via UI - Using E2E tests to verify calculations, validations, or edge cases
- Brittle Selectors - Relying on CSS classes, XPath, or unstable DOM structure
- Fixed Sleep Delays - Using Thread.sleep() instead of explicit waits for elements
- Shared Test Data - Tests depending on pre-existing data or affecting each other
- Exhaustive E2E Coverage - Attempting to cover all scenarios with slow E2E tests
- No Page Objects - Duplicating selectors and interactions across tests
- Ignoring Flakiness - Accepting random test failures without investigation
- No Failure Artifacts - Not capturing screenshots or logs when tests fail
Testing Strategies
Parallel E2E Execution
// JUnit 5 parallel execution configuration
@Execution(ExecutionMode.CONCURRENT)
@TestInstance(TestInstance.Lifecycle.PER_METHOD)
class ParallelE2ETests {
private static final ThreadLocal<Browser> BROWSER = new ThreadLocal<>();
@BeforeEach
void setUp() {
var playwright = Playwright.create();
var browser = playwright.chromium().launch();
BROWSER.set(browser);
}
@AfterEach
void tearDown() {
BROWSER.get().close();
BROWSER.remove();
}
@Test
void test1() {
var page = BROWSER.get().newPage();
// Test logic
}
@Test
void test2() {
var page = BROWSER.get().newPage();
// Test logic
}
}
Flakiness Detection
// Track test execution stability
@RepeatedTest(10)
void stabilityTest() {
// Run test 10 times to detect flakiness
// If it fails even once, investigate
}
// Retry mechanism for known flaky tests (use sparingly!)
@RetryingTest(maxAttempts = 3)
void flakyTest() {
// Retries up to 3 times on failure
// Better to fix flakiness than retry
}
CI/CD Integration
# GitHub Actions workflow
name: E2E Tests
on: [push, pull_request]
jobs:
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-java@v3
with:
java-version: '17'
- name: Install Playwright browsers
run: mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install --with-deps"
- name: Run E2E tests
run: mvn test -Dgroups=e2e
- name: Upload screenshots on failure
if: failure()
uses: actions/upload-artifact@v3
with:
name: test-screenshots
path: target/screenshots/
References
- Playwright for Java
- Selenium WebDriver Documentation
- Page Object Model Pattern
- Google Testing Blog - Flaky Tests
- Test Automation Pyramid
Related Skills
- testing-pyramid.md - Test distribution strategy and E2E test ratios
- test-automation.md - CI/CD integration and test selection
- integration-testing.md - Integration testing as alternative to E2E
- test-data-management.md - Test data creation and cleanup strategies
- testing-strategies.md - Overall testing approach and risk-based testing