The Angular E2E testing setup we actually ship in 2026
I have a folder on my laptop called graveyard. It contains the last three Protractor test suites I wrote before Angular 15 shipped, each one a little shrine to confidence I no longer have. When the Angular team announced Protractor deprecation at the end of 2022, a lot of us hoped there'd be an official replacement coming in a release or two. There wasn't. There still isn't. Angular 17 came and went, standalone components took over, and the answer to "what do we use for E2E?" quietly became "figure it out."
So this is what we figured out at BetterQA after running Angular E2E for roughly 40 client projects since the Protractor funeral. Some of it is boring best practice. Some of it is warnings. One section near the end is me complaining about a Playwright bug that still hasn't been fixed.
What we tried first (and why most of it failed)
When Protractor was declared end-of-life, the migration suggestions from the Angular team were Cypress, WebDriverIO, Nightwatch, TestCafe, and Playwright. We tried four of those on real client codebases in 2023. Here's the honest scorecard.
Cypress worked great until we hit cross-origin auth flows (common in fintech, which is half our portfolio). We were on Cypress 12 at the time, before the real cy.origin() stabilization, and we burned two weeks on a workaround for a Keycloak redirect that turned out to be unfixable without rearchitecting the test suite. We shipped it, but I still feel bad about it.
Nightwatch had a beautiful config file and absolutely nothing else going for it in Angular-land. The waiting story was worse than Playwright's and the community examples all assumed jQuery selectors. We killed it after a week.
WebDriverIO was fine. Really, fine. Not exciting, not broken. Good Protractor migration tooling. We still use it on one legacy project where the client is locked into Selenium Grid infrastructure.
Playwright won because it was the only one that didn't lie about flakiness. Specifically: when a test failed, the trace viewer showed me exactly why, and the answer was almost never "Playwright got confused." That alone made it worth switching.
The version that actually works
If you're setting this up today (April 2026), this is the combination I've tested against:
- Angular 19.2 (works fine on 17.3 and 18.1 too)
- Playwright 1.51
- Node 20.12 LTS (do not use Node 22 yet, we hit two issues with the webServer block, I'll get to those)
-
@playwright/test(notplaywrightdirectly, this matters more than people think)
Starting from an existing Angular CLI project:
cd your-angular-project
npm init playwright@latest
When the wizard asks where to put tests, say e2e. When it asks about GitHub Actions, say no, you'll write your own, the generated workflow is fine for a blog post but it will not survive contact with a real monorepo.
The webServer gotcha nobody mentions
Here is the config we actually ship, and then I'll tell you about the part that cost me four hours last month:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI ? [['html'], ['github']] : 'html',
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'npm run start -- --configuration=test',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
timeout: 180_000,
},
});
That timeout: 180_000 is there because Angular's first compile on a cold CI cache regularly takes 90 seconds or more on a project of any real size. Playwright defaults to 60 seconds and then kills the server. We lost a whole afternoon to flaky CI runs before I realized Playwright was timing out the dev server, not the test.
The other thing, and this is the one I yell about: the webServer block uses a child process. If your Angular build fails (say, a TypeScript error in a component), Playwright does not surface the compiler error. It just sits there until timeout expires and says "webServer did not start." You have to go look at the server stdout yourself, and even then the output is buffered weirdly. We now have a pre-flight in our CI that runs ng build --configuration=test before Playwright runs, purely so we get a readable error when the build breaks.
Writing tests that don't die on refactor day
Playwright's locator API is genuinely better than Protractor's. That said, everyone writes their first tests wrong. Including me. My first Angular Playwright test looked like this:
await page.locator('.btn.btn-primary.submit').click();
Two weeks later a designer renamed the class and the test broke. This is the lesson that never sinks in until it bites you.
What we do now, and enforce in code review, is this:
// e2e/contact.spec.ts
import { test, expect } from '@playwright/test';
test('contact form submits successfully', async ({ page }) => {
await page.goto('/contact');
await page.getByTestId('name-input').fill('Test User');
await page.getByTestId('email-input').fill('test@example.com');
await page.getByTestId('submit-button').click();
await expect(page.getByTestId('success-message')).toBeVisible({ timeout: 10_000 });
});
Every interactive element in an Angular template gets a data-testid. Non-negotiable. We add an ESLint rule via eslint-plugin-angular-template to warn when a (click) binding has no test id. It is annoying. It is also the reason our tests survive the quarterly design refreshes.
One caveat: for route-based assertions, use Playwright's URL matchers, not the text on the page. Angular's router can transition visually before the URL updates depending on your navigation strategy, and we got burned by a race condition where the title changed before the URL did.
await page.getByRole('link', { name: 'Pricing' }).click();
await expect(page).toHaveURL(/\/pricing/); // do this
// not: await expect(page.locator('h1')).toContainText('Pricing'); // flaky
Network mocking replaces $httpBackend
If you're coming from Protractor, you probably used $httpBackend or ngMocks for HTTP interception. Playwright's page.route() is a drop-in replacement, and honestly it's nicer because it operates at the browser level, which means you catch real fetch calls, not just ones that went through Angular's HttpClient.
test('handles 500 from users API', async ({ page }) => {
await page.route('**/api/users', (route) =>
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'internal' }),
})
);
await page.goto('/users');
await expect(page.getByTestId('error-banner')).toContainText('Something went wrong');
});
The one thing page.route() cannot do gracefully is mock streaming responses. We have a client with a server-sent-events endpoint for real-time data, and mocking that required dropping down to a WebSocket-like shim that Playwright doesn't officially support. It works, but it's ugly, and any Playwright release could break it. If you're testing SSE in Angular, budget time for pain.
The part I still hate: standalone component tests
Angular 17+ pushes standalone components hard, and Playwright has a "component testing" mode that's supposed to let you test them without bootstrapping the full app. On paper this is great. In practice, in our experience, the Angular adapter for Playwright Component Testing is less mature than the React and Vue ones. We've had flaky tests where the component's change detection doesn't trigger on the first render, and you have to manually fixture.detectChanges() in a way that feels very 2018.
For now, we skip Playwright CT for Angular and use the regular full-app E2E approach, accepting the slower startup. When a client asks "why don't we use component testing?", I tell them honestly: it's not quite ready yet, and the debugging experience when it fails is worse than the thing it's supposed to replace. Maybe by Angular 21. Ask me again next year.
The GitHub Actions workflow we actually use
This is close to what we deploy on client projects. It's not the fanciest version, but it's the one that has survived real CI conditions for two years.
name: E2E
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1/3, 2/3, 3/3]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20.12'
cache: 'npm'
- run: npm ci
- name: Build Angular (fail fast on compile errors)
run: npx ng build --configuration=test
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E
run: npx playwright test --shard=${{ matrix.shard }}
- name: Upload trace on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-trace-${{ strategy.job-index }}
path: test-results/
retention-days: 7
A few things worth calling out:
The separate ng build step is the pre-flight I mentioned earlier. If the Angular code doesn't compile, we fail in 30 seconds with a readable error, not in three minutes with "webServer did not start."
We only install chromium in CI, not all three browsers. This saves about two minutes per run and we run Firefox and WebKit nightly on a separate workflow. Running all three on every PR is a luxury tax most projects can't afford.
fail-fast: false on the matrix is deliberate. If shard 1 fails, we still want to see what happened in shards 2 and 3. Otherwise you fix one flake, re-run, find another, fix that, re-run, find a third. Been there.
When you just don't have time to write all this
I've spent this whole article telling you how to hand-write Playwright setup for Angular, but I'll be honest about something: we don't always hand-write these tests on client projects. When a client comes to us with an Angular app and 0% E2E coverage and a deadline, we use Flows, which is a Chrome extension we built specifically because writing locators manually was the most soul-crushing part of the job.
The pitch is simple: you browse the app, click the things a user would click, and Flows generates the Playwright test. It's not magic, the generated code still needs review, and we've definitely shipped tests where Flows picked a brittle selector because the developer forgot the data-testid on a button. But as a starting point it turns a two-hour test-writing session into a ten-minute recording plus a code review. On a project where you need coverage yesterday, that's the difference between having tests and not.
We also use BugBoard to track the failures these tests catch, because once your E2E suite is actually running on every PR, you will find a surprising number of real bugs that were hiding behind "works on my machine" and spreadsheet-based bug tracking stops scaling around 40 open issues.
The setup we'd recommend if you're starting today
If you're reading this because you're about to set up E2E on a new Angular project, here's the short version of what I'd do:
Start with Playwright 1.51+, Node 20, the config block from the top of this article, and data-testid attributes on every interactive element from day one. Write five tests: login, logout, one happy-path transaction, one error state, one navigation flow. Get those running in CI before you write test number six. Don't try to get to 80% coverage in week one, that's how you end up with a flaky suite everyone ignores.
Then, when you need to scale, either commit to the maintenance cost of hand-written tests or use a recorder for the bulk of coverage and keep hand-written tests for the tricky edge cases. Both approaches work. What doesn't work is pretending a neglected test suite will fix itself.
If you want to read more of what we've learned running QA for 50+ engineers across roughly 200 client projects, we post it at BetterQA and on the BetterQA blog. Come say hi if Angular E2E is the kind of thing that keeps you up at night. You're in good company.
About the author
Tudor B. is founder at BetterQA. He started the company in 2018 after being hired for one healthcare project that had so many bugs the client needed him to scale from one to eight people. That became BetterQA. Today the team is 50+ engineers across 24+ countries, with NATO NCIA approvals and ISO 9001 certification. The philosophy is simple: the chef should not certify his own dish.
Top comments (0)