+1 (726) 227-3745

E2E Testing an Angular App After Protractor: Playwright in Practice

Protractor was Angular's official end-to-end testing tool for eight years. It was deprecated in 2021 and reached end of life in 2023; the Angular CLI has not generated it since v12, and the Angular team's documented options are Playwright, Cypress, WebdriverIO, Nightwatch, and Puppeteer. We use Playwright on every Angular project we run, and this tutorial shows how we set it up, how we translate the Protractor patterns that are still sitting in older suites, and how we wire it into CI.

Why Protractor is gone, briefly

Protractor was built on WebDriver and on Angular-specific hooks (waitForAngular, by.model, by.binding) that depended on AngularJS internals. Modern Angular has no $digest cycle to wait for, so those hooks degraded into fixed sleeps and flaky tests. Playwright replaces them with auto-waiting assertions and locators based on the accessibility tree, which is both more robust and framework-agnostic.

Scaffolding Playwright in an Angular workspace

From the root of an Angular CLI project (Angular 22 here, but this works on any recent version):

npm init playwright@latest

Accept TypeScript, put tests in e2e, add the GitHub Actions workflow if you use GitHub, and install the browsers. Then edit playwright.config.ts so Playwright starts ng serve itself and reuses it locally:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list',
  use: {
    baseURL: 'http://localhost:4200',
    trace: 'on-first-retry',
  },
  webServer: {
    command: 'npx ng serve --port 4200',
    url: 'http://localhost:4200',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
});

Add a script to package.json:

"e2e": "playwright test"

npm run e2e now builds and serves the app, runs the suite in parallel across browsers, and writes an HTML report with traces for any retried failure.

Translating Protractor patterns

Here is a typical Protractor test from a MEAN application's login flow:

// Protractor (old)
describe('login', () => {
  it('logs in and shows the dashboard', async () => {
    await browser.get('/login');
    await element(by.model('vm.email')).sendKeys('user@example.com');
    await element(by.css('input[type=password]')).sendKeys('hunter2');
    await element(by.buttonText('Log in')).click();
    await browser.wait(ExpectedConditions.urlContains('/dashboard'), 5000);
    expect(await element(by.css('h1')).getText()).toBe('Dashboard');
  });
});

And the Playwright equivalent:

// e2e/login.spec.ts (Playwright)
import { test, expect } from '@playwright/test';

test('logs in and shows the dashboard', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('hunter2');
  await page.getByRole('button', { name: 'Log in' }).click();
  await expect(page).toHaveURL(/\/dashboard/);
  await expect(page.getByRole('heading', { level: 1 })).toHaveText('Dashboard');
});

The mapping that covers most of a legacy suite:

ProtractorPlaywright
browser.get(url)page.goto(url)
element(by.css(sel))page.locator(sel), but prefer getByRole / getByLabel / getByText
element(by.model('x')), by.bindingNo equivalent; use accessible labels or data-testid
element.all(by.repeater(...))page.getByRole('listitem') or a locator over the rendered list
.sendKeys(text).fill(text) (clears first) or .pressSequentially for key-by-key
browser.wait(EC.visibilityOf(el))Not needed; expect(locator).toBeVisible() auto-waits
browser.sleep(n)Remove it; if something genuinely needs time, assert on its outcome
ElementFinder page objectsPlain classes holding Locators

Two principles make the translated suite stable. First, locators are lazy: page.getByRole(...) does not touch the DOM until an action or assertion runs, and then it retries until the timeout. Second, every expect on a locator is a polling assertion. Together they remove the explicit waits that made Protractor suites fragile.

Page objects without ElementFinder

// e2e/pages/tasks.page.ts
import { Page, Locator, expect } from '@playwright/test';

export class TasksPage {
  readonly newTask: Locator;
  readonly addButton: Locator;
  readonly items: Locator;

  constructor(private page: Page) {
    this.newTask = page.getByPlaceholder('New task');
    this.addButton = page.getByRole('button', { name: 'Add' });
    this.items = page.getByRole('listitem');
  }

  async goto() { await this.page.goto('/'); }

  async add(title: string) {
    await this.newTask.fill(title);
    await this.addButton.click();
    await expect(this.items.filter({ hasText: title })).toHaveCount(1);
  }
}

Authentication once, not per test

Logging in through the UI in every test is slow and couples every test to the login form. Playwright's storage state lets a setup project log in once and share the session:

// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.E2E_USER!);
  await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!);
  await page.getByRole('button', { name: 'Log in' }).click();
  await expect(page).toHaveURL(/\/dashboard/);
  await page.context().storageState({ path: 'e2e/.auth/user.json' });
});

In playwright.config.ts, add a setup project and make the browser projects depend on it with storageState: 'e2e/.auth/user.json'. For a MEAN app using a JWT in localStorage, this works unchanged; storage state captures localStorage as well as cookies.

Mocking the Express API when you need to

Most E2E tests should hit a real API against a seeded test database. For error paths, though, page.route is simpler than engineering a failing backend:

test('shows an error when the API is down', async ({ page }) => {
  await page.route('**/api/tasks', route => route.fulfill({ status: 500, body: '{"error":"boom"}' }));
  await page.goto('/');
  await expect(page.getByRole('alert')).toContainText('Something went wrong');
});

CI wiring

The generated GitHub Actions workflow is close to what we ship. The essentials:

- uses: actions/setup-node@v4
  with: { node-version: 24 }
- run: npm ci
- run: npx playwright install --with-deps chromium webkit
- run: npm run e2e
  env:
    CI: true
    E2E_USER: ${{ secrets.E2E_USER }}
    E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
- uses: actions/upload-artifact@v4
  if: failure()
  with: { name: playwright-report, path: playwright-report }

Run the Express API and a MongoDB service container in the same job (GitHub's services: block with mongo:8.0 works well), seed the database before npm run e2e, and point baseURL at the served build rather than ng serve for a closer-to-production run. Retain traces on failure; a Playwright trace shows the DOM, network, and console at every step and is the fastest way we know to diagnose a flaky test.

Migrating an existing Protractor suite

Do not port it file by file. Inventory the user journeys the old suite covers, write Playwright tests for the ten most valuable, delete Protractor, and add the rest as features are touched. A Protractor suite that has been skipped in CI for two years has no coverage to preserve. We do this as part of our MEAN stack testing engagements, usually alongside an Angular upgrade, because the two share the same CI work.