Appearance
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.tsKey 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>testDataacceptsRecord<string, string | number | null | undefined>null/undefinedvalues 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:buildChecklist: Adding a New E2E Test
- Add test IDs to the Angular template using
puTestId="component.element"(dot notation), optionally with[testData]for extra attributes - Import
E2ETestDirectivein the component'simportsarray if not already there - Add the constant to
libs/shared/util/lib/test-ids.tsand theTEST_IDSobject - Add locators to the relevant page object using
page.getByTestId(TEST_IDS.constantName) - Write the spec following patterns above
- Verify:
npx nx run product-updates:buildthennpx nx run product-updates-e2e:e2e
Common Pitfalls
| Pitfall | Solution |
|---|---|
| Route interception misses initial API calls | Set up page.route() before page.goto() |
**/api/** pattern doesn't match | Use **/product-updates** — that's the actual API path |
Click blocked by app-wrapper overlay | Use scrollIntoViewIfNeeded() + dispatchEvent('click') |
| Duplicate locators from sticky header | Always use .first() on header element locators |
| Article slug doesn't exist | Extract real slugs from feed page, don't hardcode |
force: true click doesn't trigger Angular navigation | Use 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 |