The Journey
While developing the platform for LiveSpaces, I identified a critical gap in our delivery workflow. As the application grew, our reliance on manual verification and our existing CI/CD checks proved insufficient. We needed a way to streamline release cycles without sacrificing quality.
I took the initiative to build a comprehensive End-to-End (E2E) test suite using Playwright, but I underestimated the complexity. It wasn't just about clicking buttons; it was about handling authentication flows, managing device states, and dealing with third-party integrations securely and reliably.
From authentication flows to device management, every feature presented unique challenges. Here is a retrospective on the architecture I built to solve these bottlenecks, the decisions I made, and the lessons I learned.
Prerequisites
This post focuses on advanced patterns and architectural decisions rather than basic installation. I assume you are familiar with the basics of Playwright or similar E2E tools (Cypress, Selenium).
If you are brand new to Playwright, I highly recommend checking out the official documentation first.
Quick Context:
The examples below use:
- Framework: Playwright (Node.js)
- Language: JavaScript/TypeScript
- Pattern: Page Object Model (POM)
What I Built
100+ Test Cases Across 6 Major Areas
- Email Signup Flow — The complete journey from registration to business setup.
- OAuth Integration — Signup and signin flows with comprehensive mocking.
- Device Pairing — Device pairing logic using 6-character OTP codes.
- Device Management — Renaming, deleting, and managing device settings.
- Account Type Selection — Complex modal handling for onboarding flows.
- Business Information Forms — Industry, demographics, and capacity selection logic.
Key Challenges & Solutions
Challenge 1: Email Verification in Tests
The Problem: I could not access real email inboxes (Gmail/Outlook) reliably in automated tests. Using real inboxes made the tests slow, flaky, and prone to security blocks.
The Solution: I implemented a smart mocking strategy.
- Development: I mocked the email verification endpoint to return a fixed success response.
- Production/Integration: I documented hooks for services like Mailosaur for when real delivery testing is strictly necessary.
Code Example:
// Mock the verification code endpoint to bypass email delivery
await page.route('**/api/verification/send', route => {
route.fulfill({
status: 200,
body: JSON.stringify({
success: true,
code: '123456' // Fixed mock code for testing
})
});
});
Challenge 2: OAuth Testing
The Problem: Automating third-party providers (Google, Facebook) is often a violation of their Terms of Service. Furthermore, dealing with 2FA, captchas, or external popups leads to incredibly flaky tests.
The Solution: Comprehensive OAuth flow mocking. Instead of visiting the provider, I intercepted the callback that the provider would have sent to our application.
Code Example:
// Mock the OAuth callback
await page.route('**/api/auth/oauth/callback**', route => {
const url = new URL(route.request().url());
// If the app is trying to exchange a token, give it a mock user
if (url.searchParams.has('access_token')) {
route.fulfill({
status: 200,
body: JSON.stringify({
jwt: 'mock_jwt_token',
user: { id: 123, email: 'test@example.com' }
})
});
}
});
Challenge 3: Integration vs. Mocked Tests
The Problem: I needed fast feedback loops during development (mocks) but actual verification before deployment (integration). I didn't want to maintain two separate test suites.
The Solution: Dual-mode test files. I designed the tests to auto-detect the presence of an authentication token in the environment variables to decide whether to mock network requests or hit the real backend.
The Logic Flow:
Code Example:
const AUTH_TOKEN = process.env.AUTH_TOKEN;
const USE_REAL_BACKEND = !!AUTH_TOKEN;
test.beforeEach(async ({ page }) => {
if (USE_REAL_BACKEND) {
console.log('Using REAL backend');
// No mocks; allow requests to pass through
} else {
console.log('Using MOCKED mode');
// Apply mocks defined in separate helper files
await applyNetworkMocks(page);
}
});
Challenge 4: Test Data Cleanup
The Problem: Integration tests running against a real backend leave behind "zombie" data (e.g., created devices), causing subsequent tests to fail due to duplicate name errors or database bloat.
The Solution:
- Strict
try/finallyblocks for guaranteed cleanup within the test. - (Pro Tip: For larger suites, moving this logic into Playwright Fixtures is the preferred pattern).
Code Example:
test('should create and manage device', async ({ page, request }) => {
let testDeviceId = null;
try {
// 1. Create test device via API
const response = await request.post(`${API_URL}/devices`, {
headers: { Authorization: `Bearer ${AUTH_TOKEN}` },
data: { name: 'Test Device', code: 'TEST123' }
});
testDeviceId = (await response.json()).data.id;
// 2. Test the UI flow interacting with this device...
await page.reload();
await expect(page.getByText('Test Device')).toBeVisible();
} finally {
// 3. Always cleanup, even if the assertion above fails
if (testDeviceId) {
await request.delete(`${API_URL}/devices/${testDeviceId}`, {
headers: { Authorization: `Bearer ${AUTH_TOKEN}` }
});
}
}
});
Challenge 5: Async Component Loading
The Problem: Third-party components (maps, heavy charts) often load asynchronously. Tests were failing because they tried to interact with elements before they were truly interactive.
The Solution: Utilizing Playwright's Web-First Assertions. Unlike manual timeouts (waitForTimeout), these assertions automatically retry until the condition is met or the timeout is reached.
Code Example:
const mapInput = page.getByTestId('map-input');
// Bad Practice:
// await page.waitForTimeout(1000);
// Best Practice: Wait for state, not time
// This automatically waits for the element to be in the DOM, visible, AND enabled
await expect(mapInput).toBeEnabled({ timeout: 10000 });
// Now safe to interact
await mapInput.click();
Challenge 6: Portal-Based UI Components
The Problem: Modern UI libraries (like Radix UI or Headless UI) often render dropdowns and modals in "portals" at the bottom of the <body> tag, physically outside the component that triggered them.
The Solution:
- I used specific ARIA roles.
- I scoped locators correctly to escape the current container and search the document root.
Code Example:
// The dropdown trigger is in the main container
await page.getByRole('button', { name: 'Options' }).click();
// The menu itself is in a portal at the document root
// We wait for the menu specifically to be visible
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
// Click the item inside the menu
await menu.getByRole('menuitem', { name: 'Rename' }).click();
What Worked Really Well
1. Page Object Model (POM) Pattern
Centralizing selectors in reusable classes was the single highest-ROI decision I made. When the UI changed (and it did often), I updated one file instead of 20.
The Architecture:
Code Example:
// pages/AuthPage.js
export class AuthPage {
constructor(page) {
this.page = page;
this.emailInput = page.getByPlaceholder('name@example.com');
this.loginButton = page.getByRole('button', { name: 'Login' });
}
async login(email, password) {
await this.emailInput.fill(email);
await this.loginButton.click();
}
}
2. Comprehensive Documentation
Tests without documentation become legacy code the moment they are written. I created 15+ markdown files covering "How to run," "How to mock," and "How to debug." This dramatically reduced onboarding time for future developers and prevented "fear of the test suite."
3. Error Case Testing
Testing the "Happy Path" is easy. Testing the "Sad Path" is where value lies. I systematically mocked 404s, 500s, and network timeouts to ensure the UI handled errors gracefully (e.g., showing Toast notifications) rather than crashing.
4. Dual-Mode Testing
By using environment variables to switch between Mocks and Real Backends, a single test file serves as both a Unit-like Test (Mocked: fast, stable, deterministic) and an Integration Test (Real: slower, verifies backend contracts).
Key Learnings & Tips
- Mock External Services: Never test Google's login page. Mock the OIDC response. You are testing your app, not Google's uptime.
- Prioritize User-Visible Locators: Use
getByRoleandgetByTextover CSS classes (.btn-primary). This mimics how users find elements and makes tests resilient to styling refactors. - Always Cleanup: Test data pollution causes flaky tests. If you create it, delete it (preferably in a
finallyblock or a fixture). - Wait Properly: Never use
waitForTimeout(5000). If you find yourself doing this, you are likely missing a proper state assertion or anawait expect(...).
The Numbers
- Test Files: 10+
- Test Cases: 100+
- Lines of Code: ~5,000+
- Coverage Areas: 6 major features
- Confidence Level: High
Further Reading
If you want to dive deeper into the concepts mentioned here, I recommend these resources:
- Playwright Page Object Models — Official documentation on structuring tests.
- Martin Fowler on Page Objects — The theory behind the pattern.
- Mailosaur Documentation — For handling email in tests.
- The Testing Trophy — Kent C. Dodds' approach to balancing static, unit, integration, and E2E tests.
This post is based on real-world experience building a production E2E suite. All code examples are simplified for clarity.


Top comments (0)