← Retour au blog
AngularPlaywrightE2E testingAutomationQuality Assurance

E2E with Playwright on Angular Apps: Robust Strategy and Avoided Pitfalls

Playwright has emerged as the gold-standard E2E tool for modern Angular applications. Unlike the deprecated Protractor or Cypress, Playwright supports multiple browser engines natively (Chromium, Firefox, WebKit) and plays well with contemporary frameworks. Testing an Angular SPA brings specific challenges: waiting for change detection cycles, managing asynchronous observables, validating NgRx state without exposing internals, and avoiding race conditions in route navigation. This article walks you through a pragmatic, production-tested E2E strategy.

Setting Up Playwright for Angular: Beyond Vanilla Config

Basic setup ( npm install -D @playwright/test ) is insufficient. You must configure Playwright to wait until your Angular app is truly ready. The old waitForAngularReady() hook vanished in Angular 12, but you can implement an equivalent strategy. Create a playwright.config.ts that defines a local base URL with a dev server startup delay. Critically, set navigationTimeout and actionTimeout to at least 30 seconds: Angular can trigger expensive change detection cycles on initial load. Use the webServer option to auto-launch ng serve , eliminating manual start-up oversights.

Next, craft a reusable fixture exposing Angular-aware utilities. This fixture must wait for DOM stability and initial animations to complete. A robust pattern: wait for all elements with [appLoading] to vanish, guaranteeing route resolvers and initial HTTP requests are done. You can also inject a window.__e2eReady flag from main.ts in dev mode, which Playwright verifies via page.evaluate() . This gives you fine-grained control over readiness signals.

Navigation Without Breaking Rendering: Routes and Change Detection

Angular orchestrates navigation through Router and ActivatedRoute . Playwright must respect this cycle. Clicking a link triggers Angular navigation: the old component unmounts, the new one mounts, resolvers execute. Wait for the route to activate using page.waitForURL() in parallel, never sequentially. For example, instead of waiting for URL change then element appearance in series, use Promise.all([page.waitForURL(/\/dashboard/), page.click('a:has-text("Dashboard")')]) . This prevents race conditions where Playwright considers the action complete too early.

For route guards ( canActivate , canDeactivate ), Playwright cannot observe them directly. Test their observable effects: if a guard denies access, the URL doesn't change and an error message appears. Build specific test cases for each scenario (authenticated vs. unauthenticated, insufficient permissions). Avoid mocking the auth service at the E2E level: test real integration with your backend or a realistic stub.

Testing NgRx and Global State Without Dangerous Exposure

Modern Angular apps rely on NgRx for state management. The common mistake: accessing the store directly via page.evaluate(() => window.ngRxStore.select(...)) . This is fragile and creates tight coupling with implementation. Instead, validate state through its visible effects: if a LoadUsers action dispatches, wait for the user list to render. If an error occurs, hunt for the error message in the DOM.

If you absolutely must inspect state for debugging, expose a controlled test API. Wrap NgRx in an E2E service available only in dev mode, guarded by if (!environment.production) . Example: window.__ngRxDebug = { selectState: (selector) => store.select(selector).pipe(take(1)).toPromise() } . This remains controlled and documented. In production, this code never ships.

Handling Observables and Async Timers

Observables are Angular's lifeblood. HTTP requests, WebSockets, setInterval : all introduce timing challenges. Playwright typically waits via timeout (30 seconds default), rarely the issue. The real trap: false positives. An element flashes, disappears, reappears. Your test passes once in three runs. Use page.waitForFunction() to await a stable condition, not just DOM presence.

Instead of await page.locator('.data-row').count() which may return a provisional number, use: await page.waitForFunction(() => document.querySelectorAll('.data-row').length === expectedCount && document.querySelector('.loading') === null) . This ensures the row count is exact AND the loader has vanished. Pair this with waitForLoadState('networkidle') for standard HTTP requests: Playwright waits for network silence lasting 500ms.

Writing Readable, Maintainable Tests: Adapted Page Object Model

Structure tests using Page Object Model (POM), a battle-tested practice. Create a class per major page/component that encapsulates selectors and interactions. The payoff: if the DOM shifts, you fix one place. Example: class DashboardPage { async navigateTo() { await page.goto('/dashboard'); } async clickUserFilter(name) { await page.click( button:has-text("${name}") ); } async getUserCount() { return page.locator('.user-row').count(); } } . Tests become readable: await dashboard.navigateTo(); await dashboard.clickUserFilter('Active'); expect(await dashboard.getUserCount()).toBeGreaterThan(0); .

Don't bloat POM with business logic. Keep it as a DOM interface. Logic (waiting, asserting, combining) stays in tests. Organize files mirroring your Angular structure: e2e/pages/dashboard.page.ts matches src/app/features/dashboard . This eases navigation and long-term maintenance.

Common Pitfall: Testing Implementation Details Instead of Behavior

Many Angular E2E tests fail because they test ChangeDetectionStrategy or NgRx internals. Bad example: verifying *ngIf="isLoading" is false rather than checking the loader is invisible. Refactor without changing behavior and the test breaks. Always test observable behavior: what does the user see, can they interact, is data correct. Implementation details (change detection, OnPush strategy, signal vs. observable) must remain invisible to tests.

Another pitfall: artificial delays. Avoid await page.waitForTimeout(2000) . If your code needs 2 seconds, that's a bug or poor architecture. Wait for an event or condition. The only exceptions: CSS animations you cannot detect otherwise (then wait for completion with page.waitForFunction(() => getComputedStyle(...).opacity === '1') ).

CI/CD Integration and Angular Universal

In CI/CD, run Playwright with --workers=1 if your backend isn't isolated (otherwise parallel tests create data conflicts). Use Playwright fixtures to create/tear down test data before each suite. With Angular Universal (SSR), E2E tests remain unchanged: server-side rendering is transparent to the browser. Playwright tests the final DOM, whether it came from the server or client.

For performance testing (Core Web Vitals), integrate @playwright/test with tools like web-vitals . Measure First Contentful Paint (FCP) and Interaction to Next Paint (INP) in E2E tests. This alerts you if a refactor slows the app in real conditions.

Conclusion: A Durable E2E Strategy

Testing an Angular app with Playwright demands discipline: respecting change detection cycles, avoiding mocks at the E2E level, structuring with POM, and testing behavior over implementation. Start with critical happy paths (auth, data flows), then add edge cases. A well-written E2E suite becomes living documentation and a safety net during refactors. Playwright's stability and speed make this viable in production.

Développeur Angular & Mobile freelance — Strasbourg.

© 2026 Emilien Pons — Tous droits réservés.Conçu avec Angular, PrimeNG et ❤️