Skip to content

Product Updates E2E Testing Guide

Overview

E2E tests for the Product Updates app use Playwright with the Page Object Model pattern. Tests run against a live dev server (https://localhost:3020) and use data-testid attributes rendered by the puTestId Angular directive (E2ETestDirective). The directive also supports a [testData] input for additional data-testid-* attributes.


Project Structure

domains/product-updates/apps/product-updates-e2e/
├── playwright.config.ts          # Playwright configuration
├── tsconfig.json                 # Extends base tsconfig for @product-updates/shared-util imports
└── src/
    ├── page-objects/
    │   ├── index.ts              # Barrel exports for all page objects
    │   ├── header.component.ts   # Header component (shared across pages)
    │   ├── feed.page.ts          # Feed/What's New/Coming Soon page
    │   ├── article-detail.page.ts # Article detail page
    │   └── timeline.page.ts      # Timeline page
    ├── navigation.spec.ts
    ├── product-filter.spec.ts
    ├── theme-and-language.spec.ts
    ├── feed-content.spec.ts
    ├── article-detail.spec.ts
    ├── timeline.spec.ts
    └── url-params-and-deep-linking.spec.ts

Key Conventions

1. Test IDs Are Always Static — Never Concatenate

Test IDs must always be static strings. Never concatenate a test ID with a variable.

For dynamic data, use the [testData] input to attach data-testid-* attributes:

html
<!-- WRONG: concatenating test ID with a variable -->
<button [puTestId]="'header.product-tab.' + tab">...</button>
<button [attr.data-testid]="'header.product-tab.' + tab">...</button>

<!-- CORRECT: static test ID + testData for dynamic values -->
<button puTestId="header.product-tab" [testData]="{ product: tab }">...</button>

All test IDs follow component.element dot notation. Constants are defined in @product-updates/shared-util:

typescript
import { TEST_IDS } from '@product-updates/shared-util';
// e.g. TEST_IDS.headerNav → 'header.nav'
// e.g. TEST_IDS.postTitleLink → 'post.title-link'

File: domains/product-updates/libs/shared/util/lib/test-ids.ts

Examples of [testData] usage:

  • Product tabs: puTestId="header.product-tab" [testData]="{ product: tab }"
  • Language options: puTestId="header.language.option" [testData]="{ locale: locale }"

2. Adding Test IDs to Angular Templates

Use the puTestId directive (not raw data-testid):

html
<div class="feed__error" puTestId="feed.error">...</div>

The directive (E2ETestDirective) must be imported in the component's imports array:

typescript
import { E2ETestDirective } from '../../directives';

@Component({
  imports: [E2ETestDirective, ...],
})

For dynamic values, use [testData] to attach data attributes instead of dynamic test IDs:

html
<!-- Instead of dynamic test IDs like [attr.data-testid]="'header.product-tab.' + tab" -->
<button puTestId="header.product-tab" [testData]="{ product: tab }">...</button>

<!-- Instead of [attr.data-testid]="'header.language.option.' + locale" -->
<span puTestId="header.language.option" [testData]="{ locale: locale }">...</span>

In non-production this renders:

html
<button data-testid="header.product-tab" data-testid-product="ai">...</button>
<span data-testid="header.language.option" data-testid-locale="de">...</span>
  • testData accepts Record<string, string | number | null | undefined>
  • null/undefined values are skipped (attribute not rendered)
  • Attributes are reactively updated via Angular effect() with cleanup — old keys are removed before new ones are applied

Querying testData attributes in Playwright:

typescript
// Select a specific product tab
const aiTab = page.locator('[data-testid="header.product-tab"][data-testid-product="ai"]');

// Select a specific language option
const germanOption = page.locator('[data-testid="header.language.option"][data-testid-locale="de"]');

// Get all product tabs
const allTabs = page.getByTestId('header.product-tab');

// Read the dynamic value
const product = await page.getByTestId('header.product-tab').first().getAttribute('data-testid-product');

3. Page Object Pattern

Every page/component gets a page object class. Locators use TEST_IDS constants:

typescript
import { type Locator, type Page } from '@playwright/test';
import { TEST_IDS } from '@product-updates/shared-util';
import { HeaderComponent } from './header.component';

export class TimelinePage {
  readonly page: Page;
  readonly header: HeaderComponent;
  readonly container: Locator;
  readonly cards: Locator;

  constructor(page: Page) {
    this.page = page;
    this.header = new HeaderComponent(page);
    this.container = page.getByTestId(TEST_IDS.timelineContainer);
    this.cards = page.getByTestId(TEST_IDS.timelineCard);
  }

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

Export new page objects from page-objects/index.ts.

4. Sticky Header Duplicates

The app renders a normal header AND a sticky header with identical test IDs. Always use .first() on header locators:

typescript
this.header = page.getByTestId(TEST_IDS.header).first();
this.logo = page.getByTestId(TEST_IDS.headerLogo).first();

5. app-wrapper Pointer Interception

The app-wrapper div can intercept pointer events on elements like the back button. Use one of these workarounds:

typescript
// Option 1: scrollIntoView + dispatchEvent (preferred)
await locator.scrollIntoViewIfNeeded();
await locator.dispatchEvent('click');

// Option 2: force click (may not trigger Angular routerLink)
await locator.click({ force: true });

dispatchEvent('click') is more reliable for Angular routerLink navigation.


Spec File Patterns

Basic Test Structure

typescript
import { test, expect } from '@playwright/test';
import { FeedPage } from './page-objects';

test.describe('Feed Content', () => {
  let feedPage: FeedPage;

  test.beforeEach(async ({ page }) => {
    feedPage = new FeedPage(page);
  });

  test('should display post cards with title and summary', async () => {
    await feedPage.goto();
    await expect(feedPage.feedList).toBeVisible();

    const firstPost = feedPage.posts.first();
    await expect(feedPage.getPostTitleLink(firstPost)).toBeVisible();
    await expect(feedPage.getPostSummary(firstPost)).toBeVisible();
  });
});

Testing Loading States with Route Interception

Route interception must be set up BEFORE page.goto():

typescript
test('should show loading skeletons while feed loads', async ({ page }) => {
  // Set up BEFORE navigation
  await page.route('**/product-updates**', async route => {
    await new Promise(resolve => setTimeout(resolve, 3000));
    await route.continue();
  });

  await page.goto('/');
  await feedPage.header.waitForVisible();

  await expect(feedPage.postSkeletons.first()).toBeVisible();
  await expect(feedPage.postSkeletons.first()).not.toBeVisible({ timeout: 15000 });
});

API route pattern: Use **/product-updates** (the API path), not **/api/**.

Testing Navigation Between Pages

Navigate to feed first to get real data, then click through:

typescript
test('should navigate to article from post title', async ({ page }) => {
  const feedPage = new FeedPage(page);
  await feedPage.goto();
  await expect(feedPage.feedList).toBeVisible();

  const firstPost = feedPage.posts.first();
  await feedPage.getPostTitleLink(firstPost).click();

  await expect(page).toHaveURL(/\/article\//);
});

Testing Article Detail with Real Slugs

Don't hardcode slugs. Extract them from the feed:

typescript
test('should show article skeleton', async ({ page }) => {
  const feedPage = new FeedPage(page);
  await feedPage.goto();
  await expect(feedPage.feedList).toBeVisible();

  // Get a real slug
  const href = await feedPage.getPostTitleLink(feedPage.posts.first()).getAttribute('href');
  const slug = href?.replace('/article/', '') ?? '';

  // Then intercept and navigate
  await page.route('**/product-updates/**', async route => {
    await new Promise(resolve => setTimeout(resolve, 3000));
    await route.continue();
  });
  await page.goto(`/article/${slug}`);

  const articlePage = new ArticleDetailPage(page);
  await articlePage.header.waitForVisible();
  await expect(articlePage.skeleton).toBeVisible();
});

Testing URL Query Parameters

typescript
test('should initialize with product filter from URL', async ({ page }) => {
  const feedPage = new FeedPage(page);
  await feedPage.goto('/?product=ai');

  const aiTab = page.locator('[data-testid="header.product-tab"][data-testid-product="ai"]');
  await expect(aiTab).toHaveClass(/--active/);
});

Testing State Preservation Across Navigation

typescript
test('should preserve tab and product filter after back navigation', async ({ page }) => {
  const feedPage = new FeedPage(page);
  await feedPage.goto('/coming-soon?product=ai');
  await expect(feedPage.feedList).toBeVisible();

  // Navigate to article
  await feedPage.getPostTitleLink(feedPage.posts.first()).click();
  await expect(page).toHaveURL(/\/article\//);

  const articlePage = new ArticleDetailPage(page);
  await articlePage.waitForArticleLoaded();

  // Go back
  await articlePage.backButton.scrollIntoViewIfNeeded();
  await articlePage.backButton.dispatchEvent('click');

  await expect(page).toHaveURL(/\/coming-soon/);
  await expect(page).toHaveURL(/product=ai/);
});

Running Tests

bash
# Run all E2E tests (starts dev server automatically)
npx nx run product-updates-e2e:e2e

# Run a single spec file
npx nx run product-updates-e2e:e2e -- --grep "Feed Content"

# Run a single test by name
npx nx run product-updates-e2e:e2e -- --grep "should display post cards"

# Build app first to verify templates compile
npx nx run product-updates:build

Checklist: Adding a New E2E Test

  1. Add test IDs to the Angular template using puTestId="component.element" (dot notation), optionally with [testData] for extra attributes
  2. Import E2ETestDirective in the component's imports array if not already there
  3. Add the constant to libs/shared/util/lib/test-ids.ts and the TEST_IDS object
  4. Add locators to the relevant page object using page.getByTestId(TEST_IDS.constantName)
  5. Write the spec following patterns above
  6. Verify: npx nx run product-updates:build then npx nx run product-updates-e2e:e2e

Common Pitfalls

PitfallSolution
Route interception misses initial API callsSet up page.route() before page.goto()
**/api/** pattern doesn't matchUse **/product-updates** — that's the actual API path
Click blocked by app-wrapper overlayUse scrollIntoViewIfNeeded() + dispatchEvent('click')
Duplicate locators from sticky headerAlways use .first() on header element locators
Article slug doesn't existExtract real slugs from feed page, don't hardcode
force: true click doesn't trigger Angular navigationUse dispatchEvent('click') instead — it fires the Angular routerLink properly
Concatenating test ID with variable ('prefix.' + var)Never concatenate. Use static puTestId + [testData] for dynamic values