E2E Anti-Patterns — The Refactoring Blueprint
Are your tests slow, flaky, and impossible to maintain? You might be committing E2E anti-patterns. Learn how to identify and refactor the 5 most common testing mistakes into a scalable architecture.
📖 The Story: The 4-Hour Monster
A growing SaaS company had a Playwright suite of 500 tests. It took 4 hours to run in CI. Even worse, 10% of the tests failed randomly every run. The developers stopped trusting the "red" CI flags.
The new QA lead, Marcus, investigated. He found that every single test file started by navigating to /login, typing an email and password, and waiting for the dashboard. 500 tests were testing the login flow. If the login API was slow, the entire suite slowed down.
Furthermore, all tests shared a single static user account (testuser@example.com). If Test A changed the user's profile picture, Test B (which was checking the profile picture) would fail if they ran in parallel.
Marcus spent one week refactoring. He implemented globalSetup to authenticate once via API. He created dynamic user factories for data isolation. He replaced all waitForTimeout calls with web-first assertions.
The result? The 4-hour suite dropped to 12 minutes. The flakiness rate dropped to 0%. The monster was tamed.
Anti-patterns don't break your tests today; they break your CI pipeline tomorrow. Refactor early.
🎯 Why Anti-Patterns Matter
Speed
Anti-patterns like UI logins add 5 seconds to every test. Multiply by 500 tests, and you've added 40 minutes of pure waste.
Flakiness
Shared state and hardcoded sleeps cause race conditions, making tests fail randomly, destroying developer trust.
Maintainability
Testing 3rd party services couples your tests to external APIs. When Twitter changes their API, your suite breaks.
🌍 Real World Example
A test needs to verify that a user can upload a profile picture. Instead of using the UI to click "Upload", select the file, and wait for the crop tool, the test should use the API to upload the picture *before* the test starts. The E2E test should only verify that the UI displays the already-uploaded picture. Setup belongs in the API; verification belongs in the UI.
🧒 Explain Like I'm 10
Imagine every time you want to check if the TV turns on, you first go outside, mine the iron, smelt it, build a screw, and assemble the TV. That's insane. You just check if the TV turns on.
Testing through the UI Login every time is like mining the iron. The login is already built and tested. Don't rebuild it every time. Just turn on the TV (inject the cookies) and check the channel.
🎓 Professional Explanation
E2E anti-patterns are architectural choices that optimize for short-term ease of writing tests at the expense of long-term suite stability and speed. They often stem from treating E2E tests like unit tests (testing every edge case) or failing to leverage the API layer for test setup. The solution is the "Test Pyramid" or "Test Trophy" approach: use E2E tests sparingly for true user journeys, push setup/teardown to the API layer, and ensure perfect data isolation.
📊 Bad vs Good Architecture
Test Setup Flow
The Anti-Pattern (Slow)
The Pattern (Fast)
🚫 Anti-Pattern 1: Testing Through the UI Login
If every test logs in via the UI, you are testing the login form 500 times. If the login form breaks, all 500 tests fail, masking the real bugs.
// ❌ BAD: 500 tests do this await page.goto('/login'); await page.fill('#email', 'user@test.com'); await page.fill('#password', 'pass'); await page.click('Submit'); // ✅ GOOD: Done in globalSetup, injected via storageState // Test starts already authenticated
Refactor: Use globalSetup to authenticate via API once, save the storageState, and apply it to the config. (See Chapter 7.1).
🚫 Anti-Pattern 2: Shared Static Data
Using the same testuser for every test causes parallel collisions. If Test A deletes a record, Test B fails.
// ❌ BAD: All tests share this user const EMAIL = 'testuser@example.com'; // ✅ GOOD: Dynamic data per test const email = `test_${Date.now()}@example.com`;
Refactor: Use API seeding and Factory Fixtures to create unique users for every test. (See Chapter 24.1).
🚫 Anti-Pattern 3: Hardcoded Sleeps (waitForTimeout)
Sleeps make tests slow and flaky. If the API takes 6 seconds instead of 5, the test fails.
// ❌ BAD: Blindly waits 5 seconds await page.click('Submit'); await page.waitForTimeout(5000); await page.click('Next'); // ✅ GOOD: Auto-retries until visible await page.click('Submit'); await expect(page.getByRole('button', { name: 'Next' })).toBeEnabled();
🚫 Anti-Pattern 4: Testing 3rd Party APIs
If your app integrates with Stripe, don't hit Stripe's API in your E2E tests. You will get rate-limited, and Stripe's downtime will break your CI.
// ❌ BAD: Relies on real Stripe API await page.click('Pay with Stripe'); // ✅ GOOD: Intercept and mock Stripe await page.route('**/api/stripe/checkout', route => route.fulfill({ status: 200, body: '{"status": "success"}' }));
🚫 Anti-Pattern 5: Testing Everything in E2E
E2E tests are slow. Do not test 50 edge cases of a math calculation in E2E. Test those in Unit tests. E2E should test the "Happy Path" (e.g., "User can successfully submit the form").
✅ Best Practices (The Refactoring Blueprint)
- Use
globalSetupfor authentication. Never log in via UI for standard tests. - Isolate Test Data. Use API factories to create unique records for every test.
- Trust Auto-Waiting. Delete all
waitForTimeoutcalls. Use web-first assertions. - Mock 3rd Parties. Use
page.route()to intercept external API calls. - Push Setup to API. Don't use UI to create test prerequisites (e.g., creating an order).
- Keep Tests Independent. Test B must never depend on data created by Test A.
🏗️ Senior Engineer Deep Dive
The Test Trophy
Kent C. Dodds' "Test Trophy" suggests a large base of fast unit tests, a thick layer of integration tests, and a small crown of E2E tests. E2E tests should cover only the most critical user journeys (Checkout, Signup, Core Feature). Trying to achieve 100% code coverage with E2E tests is an anti-pattern that will bankrupt your CI compute budget.
State Reset Between Tests
The ultimate anti-pattern is relying on test execution order. Playwright runs tests in parallel by default. If a test requires a "clean slate", it must perform that reset itself in a beforeEach hook, ideally by calling a backend API endpoint designed specifically to wipe the user's data.
💼 Interview Questions
waitForTimeout considered a bad practice?Show Answer
Show Answer
globalSetup to authenticate once via API, save the storageState (cookies/localStorage) to a JSON file, and configure the test runner to reuse that state for all tests.Show Answer
beforeEach hook to create unique records (using UUIDs or timestamps) specifically for that test. The test asserts on its own data, and an afterEach hook deletes it.Show Answer
networkidle, or tests testing 3rd party APIs live. I would refactor setup to use APIs, enable sharding, and mock external services.🏋️ Practical Exercise
Hunt the Anti-Pattern
- Open an existing Playwright test suite.
- Search for
waitForTimeout. Delete them and replace withexpect. - Search for
/loginin test files. Move that logic toglobalSetup. - Search for hardcoded emails. Replace them with dynamic
Date.now()emails.
🚀 Mini Project
🏗️ The 10x Speedup
- Take a test file with 5 tests that all log in via UI and share a static user.
- Record the execution time (e.g., 30 seconds).
- Implement
storageStatefor login. - Implement dynamic data generation for the user.
- Run the file again. Verify it now runs in under 5 seconds.
📝 Quiz
Test your understanding. Click an option to check your answer.
page.setAuth()storageState in globalSetup and apply it to the configwaitForTimeout to wait for loginstorageState injects the session cookies instantly without UI interaction.waitForTimeout(5000) an anti-pattern?waitForTimeout?expect(locator).toBeVisible()page.reload() until it workspage.route().beforeEach hookafterEach hooktest.describe.serial.❓ FAQ
Q: Should I ever test the UI login?
A: Yes, you should have exactly 1 or 2 tests that verify the login form works (e.g., successful login, invalid password). The other 499 tests should bypass it.
Q: Is it okay to use test.describe.serial?
A: Only as a last resort. If you use it because Test B relies on Test A, you have a data isolation anti-pattern. Refactor the tests to be independent.
📦 Summary
🎯 Key Takeaways
- Never log in via UI for standard tests. Use
globalSetup. - Never use
waitForTimeout. Use web-first assertions. - Never share static data. Use dynamic data and API factories.
- Never test 3rd party APIs live. Mock them with
page.route(). - Push setup to the API. Don't use the UI to prepare state.
- Keep tests independent. Test B must not depend on Test A.
