📖 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)
1. Navigate to /login
2. Fill email
3. Fill password
4. Click Submit
5. Wait for dashboard
6. (Test actually starts)
The Pattern (Fast)
1. globalSetup runs once
2. API logs in, saves state
3. Test starts with cookies injected
4. (Test runs instantly)

🚫 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.

typescript · ❌ Bad
// ❌ 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.

typescript · ❌ Bad
// ❌ 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.

typescript · ❌ Bad
// ❌ 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.

typescript · ❌ Bad
// ❌ 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)

  1. Use globalSetup for authentication. Never log in via UI for standard tests.
  2. Isolate Test Data. Use API factories to create unique records for every test.
  3. Trust Auto-Waiting. Delete all waitForTimeout calls. Use web-first assertions.
  4. Mock 3rd Parties. Use page.route() to intercept external API calls.
  5. Push Setup to API. Don't use UI to create test prerequisites (e.g., creating an order).
  6. 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

Beginner
Why is using waitForTimeout considered a bad practice?
Show Answer
It is a hardcoded sleep. It makes tests slow (waiting the full duration even if the element is ready) and flaky (failing if the operation takes longer than the sleep). Auto-waiting or web-first assertions should be used instead.
Intermediate
How do you handle authentication in a large E2E test suite?
Show Answer
I do not log in via the UI for every test. I use Playwright's 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.
Advanced
How do you ensure tests running in parallel do not collide on shared database records?
Show Answer
I avoid shared static data. I use API seeding in a 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.
Scenario
Your CI pipeline takes 2 hours to run. How do you identify the bottleneck?
Show Answer
I would look for UI logins being repeated, lack of parallelism (workers set to 1), tests waiting for networkidle, or tests testing 3rd party APIs live. I would refactor setup to use APIs, enable sharding, and mock external services.

🏋️ Practical Exercise

🎯 Hands-On Practice Medium

Hunt the Anti-Pattern

  1. Open an existing Playwright test suite.
  2. Search for waitForTimeout. Delete them and replace with expect.
  3. Search for /login in test files. Move that logic to globalSetup.
  4. Search for hardcoded emails. Replace them with dynamic Date.now() emails.

🚀 Mini Project

🏗️ The 10x Speedup

  1. Take a test file with 5 tests that all log in via UI and share a static user.
  2. Record the execution time (e.g., 30 seconds).
  3. Implement storageState for login.
  4. Implement dynamic data generation for the user.
  5. Run the file again. Verify it now runs in under 5 seconds.

📝 Quiz

Test your understanding. Click an option to check your answer.

1. What is the primary issue with testing the UI login for every single test?
A) It tests the login form 500 times, wasting time and masking real bugs.
B) It is insecure.
C) Playwright does not support UI logins.
D) It requires a database connection.
Answer: A — It adds unnecessary execution time and causes cascading failures if the login breaks.
2. How do you handle authentication properly in Playwright?
A) Use page.setAuth()
B) Save storageState in globalSetup and apply it to the config
C) Hardcode tokens in the test files
D) Use waitForTimeout to wait for login
Answer: B — storageState injects the session cookies instantly without UI interaction.
3. Why is waitForTimeout(5000) an anti-pattern?
A) It is too fast.
B) It is a hardcoded sleep that wastes time and causes flakiness.
C) It requires Node.js.
D) It blocks the main thread.
Answer: B — It doesn't auto-retry. If the app takes 6 seconds, it fails; if it takes 1 second, it wastes 4 seconds.
4. What should you do instead of waitForTimeout?
A) Increase the timeout duration
B) Use web-first assertions like expect(locator).toBeVisible()
C> Use page.reload() until it works
D) Disable the test
Answer: B — Web-first assertions dynamically auto-retry until the condition is met or the timeout expires.
5. Why is sharing a static test user across parallel tests dangerous?
A) It uses too much memory.
B) If Test A modifies the user, Test B will fail due to a race condition.
C) Playwright does not allow parallel tests.
D) It is insecure.
Answer: B — Parallel tests mutating the same database row cause unpredictable, flaky failures.
6. How should you handle 3rd party APIs (like Stripe) in E2E tests?
A) Test them live to ensure they work.
B) Mock them using page.route().
C) Skip the tests that use them.
D) Use a separate CI pipeline for them.
Answer: B — Mocking prevents external rate limits and downtime from breaking your CI.
7. Where should test data setup (like creating an order) happen?
A) In the UI, by clicking through forms
B) In the database, via raw SQL
C) Via API calls in a beforeEach hook
D) In the afterEach hook
Answer: C — API setup is 100x faster and more reliable than UI setup.
8. Should E2E tests verify 100% of code coverage?
A) Yes, that is the goal of QA.
B) No, E2E should cover critical paths; unit tests should cover edge cases.
C) Yes, but only in CI.
D) No, Playwright cannot measure coverage.
Answer: B — Testing everything in E2E is too slow and expensive. Follow the Test Trophy model.
9. If a test depends on the execution order of another test, what is wrong?
A) Nothing, that is standard.
B) It violates test isolation. Tests must be able to run independently and in parallel.
C) You need to use test.describe.serial.
D) The database is broken.
Answer: B — Tests should never rely on state created by a previous test.
10. What is the benefit of pushing setup to the API layer?
A) It makes the tests slower.
B) It bypasses the UI, making tests exponentially faster and less flaky.
C) It allows you to test the database.
D) It is required by Playwright.
Answer: B — API setup takes milliseconds compared to seconds for UI setup.

❓ 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.