Welcome back, advanced test automation engineers and Angular developers!
If you’ve made it this far, you’ve already mastered the fundamentals of Playwright and are ready to elevate your testing strategy. This document is designed for those who seek to push the boundaries of Playwright’s capabilities within complex Angular applications. We will dive deep into optimizing test execution, expanding test coverage beyond traditional E2E, and integrating Playwright into advanced development workflows.
Modern Angular applications demand more than just basic functional tests. They require robust CI/CD integration, blazing-fast execution, meticulous visual and accessibility checks, and precise performance monitoring. This guide will equip you with the knowledge and practical examples to implement these advanced strategies, making your Angular applications more resilient, performant, and user-friendly.
Be prepared for an in-depth, hands-on journey. We’ll provide comprehensive explanations, intricate code examples, and challenging projects to ensure you can confidently apply these advanced Playwright techniques to your most demanding Angular projects. Let’s transform your testing into a strategic advantage!
1. Introduction to Advanced Playwright with Angular
While basic E2E testing with Playwright ensures core functionalities, a truly mature testing strategy for Angular applications requires venturing into more sophisticated areas. This section sets the stage for our exploration of these advanced topics, emphasizing their importance and impact on the overall quality and delivery of your software.
What are “Advanced Topics” in Playwright for Angular?
For experienced Playwright and Angular users, “advanced topics” refer to techniques that go beyond simply automating user interactions. They encompass:
- Optimizing the Test Lifecycle: Integrating tests into CI/CD, parallelizing execution for speed, and managing test data effectively.
- Expanding Test Coverage: Moving beyond E2E to include component-level testing and non-functional aspects like accessibility and performance.
- Enhancing Test Reliability: Implementing advanced visual regression, robust authentication flows, and dynamic fixture management.
- Deep Dive into Tooling: Leveraging Playwright’s powerful debugging tools, network interception, and reporting capabilities to their fullest.
Why Focus on These Advanced Topics?
Mastering these advanced areas is critical for several reasons:
- Accelerated Feedback Loops: CI/CD integration and parallel execution drastically reduce the time it takes to get feedback on code changes, enabling faster iteration and delivery.
- Higher Quality Applications: Comprehensive coverage, including component, visual, accessibility, and performance testing, catches a wider array of bugs earlier in the development cycle.
- Reduced Technical Debt: Well-structured, maintainable test suites prevent future headaches and ensure long-term stability.
- Cost Efficiency: Automating complex checks reduces manual effort and the cost of finding and fixing defects late in the development process.
- Improved User Experience: Ensuring performance, accessibility, and visual consistency directly translates to a better experience for your end-users.
- Scalability: Techniques like sharding and advanced fixture management allow your test suite to grow with your application without becoming a bottleneck.
Setting Up Your Advanced Development Environment
For these advanced topics, we’ll continue building on the Angular application from the previous guide. Ensure you have:
- Node.js (LTS version, 18 or higher recommended)
- Angular CLI (latest stable version)
- Playwright (
@playwright/testlatest stable version) - Your Angular application running via
ng serve(e.g., athttp://localhost:4200)
We’ll assume you have the basic playwright.config.ts and e2e folder structure already in place. We’ll install additional packages as needed for specific advanced topics.
2. CI/CD Integration: Automating Your Playwright Tests
Integrating Playwright tests into your Continuous Integration/Continuous Delivery (CI/CD) pipeline is paramount for modern software development. It ensures that every code change is automatically validated against the application’s functionality before deployment, leading to faster feedback, fewer bugs in production, and increased confidence in your releases.
What is CI/CD Integration for Playwright?
CI/CD integration means configuring your version control system (like GitHub, GitLab, or Azure DevOps) to automatically run your Playwright test suite whenever certain events occur (e.g., git push, pull request creation/update, scheduled jobs).
Key aspects include:
- Automated Triggering: Tests run automatically without manual intervention.
- Consistent Environment: Tests execute in a standardized, isolated environment (often a Docker container or a dedicated runner) to avoid “works on my machine” issues.
- Feedback Mechanisms: Test results (pass/fail, reports, traces) are clearly communicated back to developers.
- Gatekeeping: Failed tests can prevent code from being merged or deployed.
Why is it Crucial for Angular with Playwright?
Angular applications are complex SPAs with dynamic rendering and API interactions. Manual E2E testing for every change is unsustainable. CI/CD integration with Playwright ensures:
- Early Bug Detection: Catch regressions before they impact users.
- Faster Release Cycles: Automated validation allows for more frequent and confident deployments.
- Cross-Browser Assurance: Ensure your Angular app works consistently across all target browsers.
- Developer Productivity: Developers get immediate feedback, allowing them to fix issues while the context is fresh.
Setting Up GitHub Actions for Playwright with Angular
GitHub Actions is a popular choice for CI/CD. Let’s create a workflow to run our Playwright tests.
Prerequisites:
- Your Angular application pushed to a GitHub repository.
- Playwright installed and configured in your project (as in the previous guide).
Code Example (.github/workflows/playwright.yml):
Create a directory .github/workflows/ in your project root, and then create a file playwright.yml inside it.
# .github/workflows/playwright.yml
name: Playwright Tests
# Trigger the workflow on pushes to 'main' and 'master' branches,
# and on pull requests targeting these branches.
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
timeout-minutes: 15 # Give enough time for Angular build and Playwright tests
runs-on: ubuntu-latest # Use a fresh Ubuntu runner
steps:
- name: Checkout repository
uses: actions/checkout@v4 # Action to clone your repository
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20' # Use a recent LTS Node.js version
- name: Install Angular CLI (if not in package.json)
run: npm install -g @angular/cli # Ensure Angular CLI is available
- name: Install dependencies
run: npm ci # 'npm ci' is recommended in CI for clean installs
- name: Install Playwright Browsers
# '--with-deps' installs system dependencies needed by browsers (e.g., fonts)
run: npx playwright install --with-deps
- name: Start Angular Development Server
# Run Angular app in the background. 'npm run start' usually maps to 'ng serve'.
# We need it to be detached so Playwright can run in parallel.
run: npm run start & # Start the Angular app in the background
# Capture the process ID to properly kill it later
# Or, rely on Playwright's webServer config (preferred)
- name: Wait for Angular App to be ready (if not using Playwright webServer)
# This step is crucial if you're NOT using the webServer config in playwright.config.ts
# If you ARE using webServer in Playwright config, you can remove this step.
# This example shows how to manually wait, but webServer is cleaner.
run: npx wait-on http-get://localhost:4200 # waits for port 4200 to be responsive
- name: Run Playwright tests
# This command runs all tests defined in your Playwright config.
# Playwright's webServer config handles starting/stopping Angular app automatically
# if configured in playwright.config.ts (preferred over manual start/wait).
run: npx playwright test
- name: Upload Playwright Test Report (on failure or always)
# 'if: ${{ !cancelled() }}' ensures artifact upload even if a previous step fails
uses: actions/upload-artifact@v4
if: always() # Upload report even if tests fail
with:
name: playwright-report
path: playwright-report/ # This is where Playwright generates its HTML report
retention-days: 30 # How long to keep the artifact
# Optional: Clean up Angular server if started manually (not needed with Playwright webServer)
# - name: Stop Angular Server (if not using Playwright webServer)
# if: always() && steps.start-angular-server.outputs.pid # Only if you captured PID
# run: kill ${{ steps.start-angular-server.outputs.pid }} # Example if PID was captured
Explanation and Best Practices for CI/CD:
onTrigger:pushandpull_requestare common triggers. You can also addschedulefor nightly runs (e.g.,cron: '0 0 * * *').
timeout-minutes:- Set a reasonable timeout to prevent jobs from hanging indefinitely.
runs-on: ubuntu-latest:- GitHub-hosted runners provide a clean, consistent Ubuntu environment. For visual regression testing, using Docker containers with fixed environments is often preferred (
container: mcr.microsoft.com/playwright:v1.X.0-ubuntu).
- GitHub-hosted runners provide a clean, consistent Ubuntu environment. For visual regression testing, using Docker containers with fixed environments is often preferred (
uses: actions/checkout@v4andactions/setup-node@v4:- These are standard GitHub Actions for setting up your environment.
npm civs.npm install:npm ciis preferred in CI because it performs a clean install based onpackage-lock.json, ensuring reproducibility.
npx playwright install --with-deps:- Crucial for installing the necessary browser binaries and their system dependencies on the CI runner.
- Starting Angular App (
webServerinplaywright.config.ts- RECOMMENDED):- Instead of manually starting and waiting for
ng servein your GitHub Actions YAML, it is highly recommended to configure Playwright’swebServerproperty inplaywright.config.ts. This feature handles starting your Angular app, waiting for it to be ready, and gracefully shutting it down after tests, all within the Playwright runner. - If using
webServer(from previous guide): TheStart Angular Development ServerandWait for Angular App to be readysteps in the YAML can be removed, making your workflow cleaner.
- Instead of manually starting and waiting for
npx playwright test:- Executes your tests. The output will be visible in the GitHub Actions log.
actions/upload-artifact@v4:- This action uploads the Playwright HTML report (and potentially screenshots, traces, and videos) as an artifact that you can download and inspect from the GitHub Actions run summary.
if: always()ensures the report is uploaded even if tests fail.retention-dayshelps manage storage.
Running the CI/CD Pipeline:
- Commit and push the
playwright.ymlfile to your GitHub repository. - Go to the “Actions” tab in your GitHub repository. You should see a workflow run triggered by your push/pull request.
- Monitor the job’s progress and check the logs for test results. If tests fail, download the
playwright-reportartifact for detailed debugging.
Exercise 2.1.1: Integrate Trace Viewer Artifacts
Modify the GitHub Actions workflow (.github/workflows/playwright.yml) to also upload Playwright trace files as artifacts, configured to be generated on-first-retry for failed tests.
- First, ensure your
playwright.config.tsincludestrace: 'on-first-retry'andretries: process.env.CI ? 2 : 0in theusesection. - Then, modify the
actions/upload-artifactstep in your YAML to include thetest-results/**/*.zippattern (or wherever your trace files are saved).
playwright.config.tsusesection:use: { baseURL: 'http://localhost:4200', trace: 'on-first-retry', // This tells Playwright to record traces retries: process.env.CI ? 2 : 0, // Ensure retries are enabled on CI },.github/workflows/playwright.ymlUpload Playwright Test Reportsteppath:- name: Upload Playwright Test Report and Traces uses: actions/upload-artifact@v4 if: always() with: name: playwright-artifacts # Include both the HTML report and any trace.zip files path: | playwright-report/ test-results/**/*.zip # Playwright typically saves traces here retention-days: 30
3. Parallel Test Execution: Turbocharging Your Test Suite
As your Angular application grows, so does your E2E test suite. Running tests sequentially can become a significant bottleneck in your development process. Playwright’s powerful parallelization capabilities allow you to execute multiple tests concurrently across different browsers or even different machines, drastically reducing total test execution time.
Understanding Playwright’s Parallelism Model
Playwright is designed for parallelism:
- Worker Processes: Playwright runs tests in separate, independent Node.js worker processes. By default, each test file is assigned to a worker.
- Browser Isolation: Each worker typically launches its own fresh browser instance (or context), ensuring complete isolation between tests and preventing shared state issues.
- Default Behavior: By default, Playwright runs test files in parallel, but tests within a single file run sequentially.
- Configurability: You can fine-tune parallelism at the project level, file level, or even within a
describeblock.
Why is Parallel Execution Critical for Large Angular Apps?
- Speed: Reduced feedback time means faster development cycles and quicker bug detection.
- Scalability: Allows your test suite to grow without becoming a blocker.
- Resource Utilization: Maximizes the use of available CPU cores on your CI/CD runners or local machine.
- Cross-Browser Efficiency: Run tests simultaneously across Chromium, Firefox, and WebKit without waiting for each to finish independently.
Configuring Parallel Execution
Playwright’s playwright.config.ts is where you control parallelism.
1. Global Workers Configuration:
The workers option controls the maximum number of concurrent worker processes.
Code Example (playwright.config.ts):
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true, // Run tests within the same file concurrently (if possible)
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
// IMPORTANT: Configure workers for parallel execution
// On CI, it's often best to limit workers to the number of CPU cores available
// to avoid resource contention. Locally, you can set it to '50%' of cores or a fixed number.
workers: process.env.CI ? 2 : undefined, // Example: 2 workers on CI, unlimited locally
// Or: workers: '50%', // Use 50% of available CPU cores
// Or: workers: 4, // Use a fixed number of 4 workers
reporter: 'html',
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run start',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
},
});
Explanation:
fullyParallel: true: This is a powerful setting that tells Playwright to run tests in parallel not just across files, but also within each test file. This is great for maximum speed, assuming your individual tests are truly independent.workers: process.env.CI ? 2 : undefined: This sets the number of workers.process.env.CI ? 2 : undefined: A common pattern to limit workers on CI (e.g., to 2, depending on your runner’s CPU/memory) but use Playwright’s default (typically based on available CPU cores) for local development.undefinedmeans Playwright will pick a sensible default.- Be cautious with
fullyParallel: trueand a highworkerscount on resource-constrained CI machines, as it can lead to flakiness due to CPU/memory exhaustion.
2. Parallelizing Tests within a Single File (if fullyParallel is not global):
If fullyParallel is not set globally, tests within a single file run sequentially. You can override this for specific files or describe blocks.
Code Example (e2e/parallel-file.spec.ts):
Let’s imagine a test file with several independent tests that you want to run in parallel, even if fullyParallel: true isn’t set globally.
// e2e/parallel-file.spec.ts
import { test, expect } from '@playwright/test';
// Configure this specific describe block to run tests in parallel
test.describe.configure({ mode: 'parallel' });
test.describe('Parallel Tests in a Single File', () => {
test.beforeEach(async ({ page }) => {
// Each test in this describe block will get its own fresh page/context
await page.goto('/');
});
test('should verify element A concurrently', async ({ page }) => {
console.log(`Worker ${test.info().workerIndex}: Running Test A`);
await expect(page.locator('h1')).toBeVisible();
await expect(page.locator('h1')).toHaveText('Welcome to Playwright Angular App!');
// Simulate some work
await page.waitForTimeout(500);
});
test('should verify element B concurrently', async ({ page }) => {
console.log(`Worker ${test.info().workerIndex}: Running Test B`);
await expect(page.getByTestId('nav-products')).toBeVisible();
await expect(page.getByTestId('nav-products')).toHaveText('Products');
await page.waitForTimeout(700);
});
test('should verify element C concurrently', async ({ page }) => {
console.log(`Worker ${test.info().workerIndex}: Running Test C`);
await expect(page.getByTestId('increment-button')).toBeVisible();
await expect(page.getByTestId('increment-button')).toHaveText('Increment Counter');
await page.waitForTimeout(300);
});
});
When you run this file (npx playwright test e2e/parallel-file.spec.ts), Playwright will attempt to run Test A, Test B, and Test C in parallel, each in its own worker process, as indicated by the console.log output.
3. Sharding Tests Across Multiple Machines (Advanced CI/CD): For extremely large test suites, you can distribute tests across multiple CI jobs (or machines). This is called sharding.
Code Example (GitHub Actions Sharding in .github/workflows/playwright-sharded.yml):
# .github/workflows/playwright-sharded.yml
name: Sharded Playwright Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
# Define a matrix strategy to create multiple jobs, one for each shard
test-shard:
timeout-minutes: 15
runs-on: ubuntu-latest
strategy:
fail-fast: false # Allows other shards to continue if one fails
matrix:
# Define the number of shards and which shard this job will run
shard: [1, 2, 3] # Example: 3 shards
# You could also add different browsers to the matrix for cross-browser sharding
# browser: ['chromium', 'firefox']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Start Angular Development Server (if not using Playwright webServer)
# Use a consistent port across shards or ensure it's not reused
run: npm run start &
# Adjust if using Playwright's webServer config which is recommended
- name: Run Playwright tests for shard ${{ matrix.shard }}
# The --shard flag tells Playwright which part of the test suite to run
run: npx playwright test --shard=${{ matrix.shard }}/${{ strategy.job-total }}
# If matrix.browser was defined:
# run: npx playwright test --project=${{ matrix.browser }} --shard=${{ matrix.shard }}/${{ strategy.job-total }}
- name: Upload Playwright Report for Shard ${{ matrix.shard }}
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-shard-${{ matrix.shard }}
path: playwright-report/
retention-days: 7
# Optional: Merge reports after all shards complete (requires an extra job)
merge-reports:
runs-on: ubuntu-latest
needs: test-shard # This job depends on all test-shard jobs completing
if: always() # Run even if previous jobs failed
steps:
- uses: actions/checkout@v4
- name: Download all Playwright reports
uses: actions/download-artifact@v4
with:
pattern: playwright-report-shard-*
path: all-playwright-reports # Download all reports into this directory
- name: Install Playwright
run: npm install -D @playwright/test # Need playwright to merge reports
- name: Merge Playwright reports
# Playwright CLI can merge multiple HTML reports into a single one
run: npx playwright show-report all-playwright-reports --output merged-report
- name: Upload Merged Report
uses: actions/upload-artifact@v4
with:
name: merged-playwright-report
path: merged-report/
retention-days: 30
Explanation:
strategy: matrix: shard: [1, 2, 3]: This creates three identical jobs, one forshard: 1, one forshard: 2, and one forshard: 3.--shard=${{ matrix.shard }}/${{ strategy.job-total }}: This is the magic. Playwright’s CLI command divides the total tests intojob-total(which ismatrix.shard.length) parts and runs only thematrix.shard-th part.- Merging Reports: After all sharded jobs complete, a
merge-reportsjob downloads all individual reports and usesnpx playwright show-report <directories> --output <output_dir>to combine them into a single, comprehensive report. This is essential for a unified view of the entire test suite.
Exercise 3.1.1: Optimize CI Workers based on Project Size
Imagine your Angular application has two main test categories: smoke (critical, fast tests) and full (comprehensive, slower tests). You want to run smoke tests with a higher number of workers to get faster feedback, and full tests with fewer workers or sharded if the CI resources are limited.
Modify playwright.config.ts to define two different projects: smoke and full.
- The
smokeproject should targete2e/smoke/**/*.spec.tsfiles and useworkers: 4. - The
fullproject should target all othere2e/**/*.spec.tsfiles (excluding smoke) and useworkers: 2. - Then, modify your GitHub Actions workflow (or create a new one) to run the
smokeproject first, and then thefullproject.
1. Create a e2e/smoke directory and add a sample example.smoke.spec.ts:
// e2e/smoke/example.smoke.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Smoke Test Example', () => {
test('should load homepage in smoke test', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toBeVisible();
await expect(page).toHaveTitle(/PlaywrightAngularApp/);
});
});
2. Update playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true, // You can keep this or set to false for more control per project
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: undefined, // Let projects define their own workers
reporter: 'html',
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
},
projects: [
{
name: 'smoke',
testMatch: '**/e2e/smoke/**/*.spec.ts', // Only run files in the smoke directory
use: { ...devices['Desktop Chrome'] },
workers: 4, // More workers for faster smoke tests
},
{
name: 'full',
testIgnore: '**/e2e/smoke/**/*.spec.ts', // Exclude smoke tests from the full suite
use: { ...devices['Desktop Chrome'] },
workers: 2, // Fewer workers for comprehensive tests
},
{
name: 'firefox-full',
testIgnore: '**/e2e/smoke/**/*.spec.ts',
use: { ...devices['Desktop Firefox'] },
workers: 1, // Example: even fewer workers for Firefox on 'full'
},
{
name: 'webkit-full',
testIgnore: '**/e2e/smoke/**/*.spec.ts',
use: { ...devices['Desktop Safari'] },
workers: 1,
}
],
webServer: {
command: 'npm run start',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
},
});
3. Update GitHub Actions Workflow (.github/workflows/playwright.yml):
# .github/workflows/playwright.yml
name: Phased Playwright Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
run-smoke-tests:
timeout-minutes: 5 # Shorter timeout for smoke
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Start Angular Development Server
run: npm run start &
- name: Wait for Angular App to be ready
run: npx wait-on http-get://localhost:4200
- name: Run Playwright Smoke tests
run: npx playwright test --project=smoke # Run only the 'smoke' project
- name: Upload Smoke Report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-smoke-report
path: playwright-report/
retention-days: 7
run-full-tests:
timeout-minutes: 20 # Longer timeout for full suite
runs-on: ubuntu-latest
needs: run-smoke-tests # Only run full tests if smoke tests passed
if: success() # Ensure previous job was successful
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Start Angular Development Server
run: npm run start &
- name: Wait for Angular App to be ready
run: npx wait-on http-get://localhost:4200
- name: Run Playwright Full tests
# Run all projects except 'smoke'
run: npx playwright test --project=full --project=firefox-full --project=webkit-full
- name: Upload Full Report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-full-report
path: playwright-report/
retention-days: 30
4. Component Testing with Playwright (Experimental/Community)
While Playwright excels at End-to-End testing, the concept of component testing allows you to test individual Angular components in isolation, much faster and more reliably than full E2E tests. Playwright has experimental support for component testing (especially for React, Vue, Svelte), and while official Angular support is still evolving, community packages bridge this gap, offering a powerful alternative or complement to other Angular component testing tools (like Jest/Karma with TestBed).
Why Component Testing?
- Faster Feedback: Test components in milliseconds, not seconds, as you don’t need to boot the entire application.
- Isolation: Focus specifically on a component’s inputs, outputs, and internal logic, independent of its environment.
- Reduced Flakiness: Fewer external dependencies (network, other components) mean more stable tests.
- Detailed Debugging: Quickly pinpoint issues within a single component.
Challenges with Angular Component Testing in Playwright
- Official Support: Playwright’s native component testing is designed for frameworks with a simpler rendering model (e.g., Vite/Webpack integration with React/Vue). Angular’s compilation and dependency injection can make integration more complex.
- Bundling: Angular components need to be compiled and bundled to run in a browser context.
Bridging the Gap: @sand4rt/experimental-ct-angular (Community Solution)
A notable community effort to bring Playwright component testing to Angular is the @sand4rt/experimental-ct-angular package. This package aims to provide a Playwright-like API for mounting and testing Angular components.
Note: As this is an experimental/community solution, its features, stability, and future development might vary. Always refer to the package’s official documentation for the latest details.
1. Set up the Component Testing Environment:
First, install the necessary package:
npm install -D @sand4rt/experimental-ct-angular
Then, initialize the component testing setup:
npx playwright-sand4rt --ct
This command should:
- Install
@playwright/experimental-ct-angular - Create a
playwright-ct.config.tsfile in your root. - Create a
playwright/index.htmlandplaywright/index.tsfile.
2. Configure playwright-ct.config.ts:
This file will be similar to your playwright.config.ts but specifically for component tests. Ensure it’s correctly configured for your Angular project. It typically sets up a dev server (like Vite or Angular CLI’s dev server) to serve the components.
playwright-ct.config.ts example:
import { defineConfig, devices } from '@playwright/experimental-ct-angular';
export default defineConfig({
testDir: './e2e-ct', // Separate directory for component tests
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined, // Often fewer workers for CT, or even 1
reporter: 'html',
use: {
trace: 'on-first-retry',
// Base URL for component testing - might not be 'http://localhost:4200' if it's a separate dev server
// For Angular CT, the setup often involves a custom server by the CT runner
// Refer to @sand4rt/experimental-ct-angular docs for specific baseURL config.
// Usually, it's relative paths like '/'
baseURL: 'http://localhost:3000', // Example, check CT runner actual port
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
// Add other browsers if needed, but CT often focuses on one for speed
],
// IMPORTANT: The webServer configuration for component testing.
// This tells Playwright-CT how to serve your Angular components.
// This might involve an Angular-specific build process or a simple static server.
// The @sand4rt package handles this with its internal server.
webServer: {
command: 'npm run start-ct', // Assuming you'll add a script for this
url: 'http://localhost:3000', // The port where Playwright-CT serves components
reuseExistingServer: !process.env.CI,
},
});
You’ll likely need to add start-ct script to your package.json like:
// package.json
{
"scripts": {
"start-ct": "ng serve --port 3000 --configuration development", // Or specific command for Playwright-CT's internal server
"test-ct": "playwright test -c playwright-ct.config.ts"
}
}
Important: The start-ct command might depend heavily on the @sand4rt/experimental-ct-angular package’s specific setup. The ideal scenario is that the playwright test -c playwright-ct.config.ts command itself triggers an internal server, so you may not need a separate start-ct script. Always refer to the package’s latest documentation.
3. Create an Example Angular Component for Testing:
Let’s create a simple “Greeting” component.
ng generate component greeting --standalone
src/app/greeting/greeting.component.ts:
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-greeting',
standalone: true,
imports: [CommonModule],
template: `
<div data-test-id="greeting-container">
<h3 *ngIf="name">Hello, <span class="name">{{ name }}</span>!</h3>
<p *ngIf="!name" class="fallback-greeting">Please enter your name.</p>
<button (click)="greet()">Greet me!</button>
<span *ngIf="lastGreetedName" class="last-greeted">Last greeted: {{ lastGreetedName }}</span>
</div>
`,
styles: `
.greeting-container { padding: 10px; border: 1px solid #ccc; margin: 10px; }
.name { font-weight: bold; color: blue; }
.fallback-greeting { color: gray; font-style: italic; }
.last-greeted { margin-left: 10px; font-size: 0.9em; color: green; }
.error { color: red; }
`
})
export class GreetingComponent {
@Input() name: string | undefined;
lastGreetedName: string | undefined;
greet() {
if (this.name) {
this.lastGreetedName = this.name;
} else {
this.lastGreetedName = 'No name provided';
}
}
}
4. Write a Playwright Component Test:
Create a new test file e2e-ct/greeting.component.spec.ts. Note the import from @playwright/experimental-ct-angular and the mount fixture.
// e2e-ct/greeting.component.spec.ts
import { test, expect } from '@playwright/experimental-ct-angular';
import { GreetingComponent } from '../src/app/greeting/greeting.component'; // Adjust path as needed
test.describe('GreetingComponent', () => {
test('should render "Hello, John!" when name is provided', async ({ mount }) => {
// Mount the component with an input prop
const component = await mount(GreetingComponent, {
props: {
name: 'John'
},
});
// Assert that the greeting is visible and contains the name
await expect(component.getByRole('heading', { level: 3 })).toBeVisible();
await expect(component.getByRole('heading', { level: 3 })).toContainText('Hello, John!');
await expect(component.locator('.fallback-greeting')).not.toBeVisible();
});
test('should render fallback message when no name is provided', async ({ mount }) => {
// Mount without the name prop
const component = await mount(GreetingComponent);
await expect(component.locator('.fallback-greeting')).toBeVisible();
await expect(component.locator('.fallback-greeting')).toContainText('Please enter your name.');
await expect(component.getByRole('heading', { level: 3 })).not.toBeVisible();
});
test('should update last greeted name on button click', async ({ mount }) => {
const component = await mount(GreetingComponent, {
props: { name: 'Alice' }
});
const greetButton = component.getByRole('button', { name: 'Greet me!' });
const lastGreetedSpan = component.locator('.last-greeted');
await expect(lastGreetedSpan).not.toBeVisible(); // Initially not visible
await greetButton.click();
await expect(lastGreetedSpan).toBeVisible();
await expect(lastGreetedSpan).toContainText('Last greeted: Alice');
// Change input name and click again
await component.update({ props: { name: 'Bob' } }); // Update props dynamically
await greetButton.click();
await expect(lastGreetedSpan).toContainText('Last greeted: Bob');
});
test('should show "No name provided" if name is empty on greet', async ({ mount }) => {
const component = await mount(GreetingComponent, {
props: { name: '' } // Empty string for name
});
await component.getByRole('button', { name: 'Greet me!' }).click();
const lastGreetedSpan = component.locator('.last-greeted');
await expect(lastGreetedSpan).toBeVisible();
await expect(lastGreetedSpan).toContainText('No name provided');
});
});
5. Run the Component Tests:
npx playwright test -c playwright-ct.config.ts
This will run your component tests, leveraging Playwright’s browser automation for isolated component rendering and interaction.
Exercise 4.1.1: Component-Level Visual Regression Test
Extend the greeting.component.spec.ts to include a visual regression test for the GreetingComponent.
- Add a test to verify the initial appearance of the component (with and without a
nameprop). - Add a test to verify the appearance after clicking the “Greet me!” button.
- Remember to run with
--update-snapshotsthe first time.
// e2e-ct/greeting.component.spec.ts
import { test, expect } from '@playwright/experimental-ct-angular';
import { GreetingComponent } from '../src/app/greeting/greeting.component';
test.describe('GreetingComponent Visual Regression', () => {
test('initial state with name should match snapshot', async ({ mount }) => {
const component = await mount(GreetingComponent, {
props: { name: 'Visual User' },
});
// Use a unique name for the snapshot
await expect(component).toHaveScreenshot('greeting-with-name-initial.png');
});
test('initial state without name should match snapshot', async ({ mount }) => {
const component = await mount(GreetingComponent);
await expect(component).toHaveScreenshot('greeting-without-name-initial.png');
});
test('state after greeting should match snapshot', async ({ mount }) => {
const component = await mount(GreetingComponent, {
props: { name: 'Tester' }
});
await component.getByRole('button', { name: 'Greet me!' }).click();
await expect(component).toHaveScreenshot('greeting-after-click.png');
});
});
5. Visual Testing Beyond Basic Snapshots
Playwright’s toHaveScreenshot() is a powerful starting point for visual regression testing (VRT). However, for real-world Angular applications, you often need more advanced strategies to handle dynamic content, reduce flakiness, and manage large numbers of visual assets. This section explores techniques and external tools that elevate your VRT capabilities.
Limitations of Basic toHaveScreenshot()
- Dynamic Content: Dates, user names, ads, animations, or data loading indicators can cause false positives.
- Environment Drift: Small differences in fonts, rendering engines, or operating systems between local and CI environments can break tests.
- Baseline Management: Managing hundreds or thousands of screenshot files in your Git repository can become cumbersome.
- Advanced Diffing: Playwright’s diffing is pixel-based; advanced tools offer more intelligent comparisons (e.g., ignoring subtle anti-aliasing differences).
- Collaboration: Reviewing visual changes in a CI system’s artifact viewer can be clunky.
Strategies for Robust Visual Testing
1. Masking Dynamic Elements: Playwright allows you to “mask” specific elements, ignoring them during the screenshot comparison. This is essential for preventing false positives from frequently changing content.
Code Example (e2e/visual-advanced.spec.ts):
Let’s assume our home.component.html now includes a dynamic “Last Login” timestamp and a randomized ad banner.
Modify src/app/home/home.component.html (adding elements for masking):
<p>home works!</p>
<div class="welcome-message">
This is the homepage of our Playwright Angular application.
</div>
<button data-test-id="increment-button" (click)="increment()">Increment Counter</button>
<p>Counter: <span data-test-id="counter-value">{{ counter }}</span></p>
<button data-test-id="load-data-button" (click)="loadDynamicData()" [disabled]="dynamicDataLoading">Load Dynamic Data</button>
<div data-test-id="dynamic-data-container" *ngIf="dynamicDataLoaded" style="margin-top: 20px;">
<h3>Dynamic Data:</h3>
<p>{{ dynamicData }}</p>
</div>
<div class="user-info-panel">
<p>Logged in as: John Doe</p>
<p class="last-login-date">Last Login: {{ currentDate | date:'medium' }}</p> <!-- Dynamic date -->
</div>
<div class="ad-banner">
<img src="https://via.placeholder.com/728x90?text=Dynamic+Ad" alt="Dynamic Ad Banner"> <!-- Random ad content -->
</div>
Modify src/app/home/home.component.ts (adding currentDate):
import { Component, OnInit } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common'; // Import DatePipe
@Component({
selector: 'app-home',
standalone: true,
imports: [CommonModule, DatePipe], // Add DatePipe
templateUrl: './home.component.html',
styleUrl: './app.component.css'
})
export class HomeComponent implements OnInit {
// ... existing properties ...
currentDate: Date = new Date(); // For dynamic date
ngOnInit(): void {
// ...
// Update date periodically for demonstration
setInterval(() => {
this.currentDate = new Date();
}, 1000);
}
// ... existing methods ...
}
Now, the Playwright test:
// e2e/visual-advanced.spec.ts
import { test, expect, Locator } from '@playwright/test';
test.describe('Advanced Visual Regression Techniques', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Ensure dynamic data is loaded if testing a state that includes it
await page.getByTestId('load-data-button').click();
await expect(page.getByTestId('dynamic-data-container')).toBeVisible();
});
test('should capture homepage without dynamic elements in full page snapshot', async ({ page }) => {
// 1. Identify dynamic elements to mask
const lastLoginDate = page.locator('.last-login-date');
const adBanner = page.locator('.ad-banner');
const dynamicDataContainer = page.getByTestId('dynamic-data-container'); // This might change
// 2. Take a full page screenshot, masking the dynamic parts
await expect(page).toHaveScreenshot('homepage-masked.png', {
fullPage: true,
mask: [
lastLoginDate,
adBanner,
dynamicDataContainer, // Also mask the dynamic data section
],
// Adjust threshold if minor anti-aliasing or rendering differences are acceptable
maxDiffPixelRatio: 0.01, // 1% pixel difference allowed
maxDiffPixels: 100, // Or max 100 pixels different
// It's also good practice to make animations static or hide them
// For example, if there are CSS animations:
// animations: 'disabled',
// or inject CSS:
// stylePath: path.resolve(__dirname, './styles/no-animations.css')
});
});
test('should capture only a stable section of the page', async ({ page }) => {
// Instead of full page, screenshot a stable, isolated part like the navigation
const navBar = page.locator('nav');
await expect(navBar).toHaveScreenshot('navigation-bar-stable.png');
});
test('should force a specific color scheme for consistent screenshots', async ({ page }) => {
// Some components might adapt to dark/light mode. Force one for VRT consistency.
await page.emulateMedia({ colorScheme: 'dark' });
await expect(page).toHaveScreenshot('homepage-dark-mode.png');
await page.emulateMedia({ colorScheme: 'light' });
await expect(page).toHaveScreenshot('homepage-light-mode.png');
});
});
Run: npx playwright test e2e/visual-advanced.spec.ts --update-snapshots (then without --update-snapshots to test changes).
2. External Visual Regression Tools (Chromatic, Applitools Eyes): For enterprise-level VRT, dedicated tools offer advantages over Playwright’s built-in solution:
- Cloud Baselines & Storage: No Git repo bloat; baselines are managed in the cloud.
- Intelligent Diffing: AI-powered algorithms to ignore “noise” (e.g., anti-aliasing) and focus on meaningful visual changes.
- Cross-Browser/Viewport Grid: Easily test across many browser/device combinations in parallel in the cloud.
- Collaboration Workflow: Dedicated UI for reviewing, approving, and commenting on visual changes, integrating with PRs.
- Root Cause Analysis: Often provides interactive DOM snapshots and network logs for debugging.
While integrating these involves specific SDKs, the general Playwright test structure remains similar. You replace expect().toHaveScreenshot() with their respective SDK calls (e.g., await chromatic.takeSnapshot(page)).
Example Workflow (Conceptual with Chromatic):
- Install Chromatic SDK:
npm install -D chromatic @chromatic-com/playwright - Modify Test:
// e2e/chromatic.spec.ts (conceptual) import { test, expect } from '@chromatic-com/playwright'; // Use Chromatic's test runner // Or: import { test, expect } from '@playwright/test'; // And use `await takeSnapshot(page, 'my-snapshot-name');` from their helper. test('My Angular Component visual test with Chromatic', async ({ page }) => { await page.goto('/my-component-route'); // Or mount in CT // Instead of page.toHaveScreenshot, use Chromatic's snapshot method await expect(page).toHaveScreenshot('my-component-initial-state.png', { // Chromatic also supports masking, regions, etc. mask: [page.locator('.dynamic-element')] }); // await takeSnapshot(page, 'my-snapshot-name'); // If using their helper function }); - CI/CD Integration: Run your Playwright tests as usual, then trigger the Chromatic upload step (e.g.,
npx chromatic --project-token=<your-token>). Chromatic then handles the snapshotting, diffing, and review process in its cloud.
Exercise 5.1.1: Snapshot an Element After an Action
In e2e/visual-advanced.spec.ts, add a test to:
- Navigate to the
/productspage. - Click the “Add First Product to Cart” button.
- Take a screenshot specifically of the product list (
data-test-id="product-list") after the button click. - Ensure any dynamic elements (like a “Product added!” notification) are masked or handled to keep the snapshot stable.
// e2e/visual-advanced.spec.ts (continued)
import { test, expect, Locator } from '@playwright/test';
// ... existing describe blocks ...
test.describe('Products Page Visuals', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/products');
});
test('product list should match snapshot after adding to cart', async ({ page }) => {
const productList = page.getByTestId('product-list');
const addToCartButton = page.getByRole('button', { name: 'Add First Product to Cart' });
await expect(productList).toBeVisible();
await expect(addToCartButton).toBeVisible();
await addToCartButton.click();
// If there were a dynamic "Product added!" message, you'd mask it here:
// const notification = page.locator('.product-added-notification');
await expect(productList).toHaveScreenshot('product-list-after-add-to-cart.png', {
// mask: [notification] // If a notification exists and you want to mask it
maxDiffPixelRatio: 0.01,
});
});
});
6. Accessibility Testing
Ensuring your Angular application is accessible to all users, including those with disabilities, is not just a best practice—it’s a legal and ethical imperative. Playwright can integrate with accessibility testing tools to automate checks and catch common accessibility issues early in your E2E tests.
Why Accessibility Testing Matters
- Inclusivity: Makes your application usable by a wider audience.
- Legal Compliance: Many regulations (e.g., WCAG, Section 508, ADA) require accessible web content.
- SEO Benefits: Accessible sites are often better structured, which can improve search engine optimization.
- Improved User Experience: Good accessibility practices often lead to better usability for everyone.
Integrating axe-core with Playwright
axe-core is an open-source accessibility testing engine that can be easily integrated with Playwright. It automatically scans your web page for hundreds of common accessibility violations based on WCAG (Web Content Accessibility Guidelines) standards.
1. Install axe-core and its Playwright integration:
npm install -D axe-core @axe-core/playwright
2. Create a utility for running accessibility checks:
It’s good practice to create a reusable function for this.
e2e/utils/accessibility.ts:
import { Page, test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright'; // Import AxeBuilder
/**
* Runs accessibility checks on the given page or specific element.
* Reports any violations found.
* @param page The Playwright page object.
* @param testInfo The Playwright testInfo object.
* @param selector Optional CSS selector to run checks on a subset of the DOM.
* @param violationThreshold Optional. Max number of accessibility violations allowed.
* @param impactLevels Optional. Array of impact levels to report ('critical', 'serious', 'moderate', 'minor'). Default: all.
*/
export async function checkAccessibility(
page: Page,
testInfo: test.TestInfo,
selector?: string,
violationThreshold: number = 0, // Default to 0 violations allowed
impactLevels: ('critical' | 'serious' | 'moderate' | 'minor')[] = ['critical', 'serious', 'moderate', 'minor']
): Promise<void> {
const accessibilityScanResults = await new AxeBuilder({ page })
.exclude('script') // Exclude script tags which axe doesn't need to scan
.withTags(['wcag2a', 'wcag2aa', 'best-practices']) // Specify WCAG compliance levels
.setIncludedElements(selector ? [selector] : []) // Only scan specific selector if provided
.analyze();
// Filter violations based on desired impact levels
const filteredViolations = accessibilityScanResults.violations.filter(
(violation) => impactLevels.includes(violation.impact as any)
);
if (filteredViolations.length > violationThreshold) {
// Generate a detailed report if violations are found
const reportPath = testInfo.outputPath(`accessibility-report-${testInfo.title.replace(/\s/g, '-')}.json`);
await testInfo.attach('accessibility-report', {
body: JSON.stringify(filteredViolations, null, 2),
contentType: 'application/json',
});
console.error(`Accessibility violations found on page: ${page.url()}`);
filteredViolations.forEach(violation => {
console.error(`- [${violation.impact}] ${violation.help} (${violation.id}):`);
violation.nodes.forEach(node => {
console.error(` Selector: ${node.target.join(', ')}`);
console.error(` HTML: ${node.html}`);
});
});
throw new Error(`${filteredViolations.length} accessibility violations found (threshold: ${violationThreshold}). See attached report for details.`);
} else {
console.log(`Accessibility check passed for ${page.url()} with ${filteredViolations.length} violations (threshold: ${violationThreshold}).`);
}
}
3. Integrate accessibility checks into your Playwright tests:
Code Example (e2e/accessibility.spec.ts):
Let’s assume our Angular app has some intentional (for demonstration) accessibility issues on the Contact page, like missing labels or insufficient contrast.
To simulate an issue, let’s update src/app/contact/contact.component.html (e.g. remove a label and add a low-contrast div):
<p>contact works!</p>
<h2>Contact Us</h2>
<form data-test-id="contact-form" (ngSubmit)="submitForm()">
<div>
<label for="name">Name:</label> <!-- Keep this label for now -->
<input type="text" id="name" data-test-id="contact-name-input" [(ngModel)]="contactForm.name" name="name" required minlength="3">
</div>
<div>
<!-- Intentionally remove label for email to cause an a11y violation -->
Email:
<input type="email" id="email" data-test-id="contact-email-input" [(ngModel)]="contactForm.email" name="email" required email>
</div>
<div>
<label for="message">Message:</label>
<textarea id="message" data-test-id="contact-message-textarea" [(ngModel)]="contactForm.message" name="message"></textarea><br><br>
</div>
<button type="submit" data-test-id="contact-submit-button" [disabled]="loading">Send Message</button>
</form>
<div data-test-id="submission-feedback" *ngIf="submissionFeedback" [ngClass]="{'success': isSuccess, 'error': !isSuccess}">
{{ submissionFeedback }}
</div>
<div style="background-color: #EEE; color: #BBB; padding: 10px;" data-test-id="low-contrast-div">
This text has low contrast and might be hard to read.
</div>
Now, the Playwright test:
// e2e/accessibility.spec.ts
import { test, expect } from '@playwright/test';
import { checkAccessibility } from './utils/accessibility'; // Import the utility function
test.describe('Accessibility Testing with Axe-core', () => {
test('homepage should be accessible', async ({ page }, testInfo) => {
await page.goto('/');
// Run full page accessibility check, allowing 0 violations
await checkAccessibility(page, testInfo, undefined, 0);
});
test('products page should be accessible', async ({ page }, testInfo) => {
await page.goto('/products');
await checkAccessibility(page, testInfo, undefined, 0);
});
test('contact page should be accessible and report violations', async ({ page }, testInfo) => {
await page.goto('/contact');
// We expect some violations due to the intentionally broken contact page.
// For demonstration, let's allow up to 2 violations and check for serious/critical.
await checkAccessibility(page, testInfo, undefined, 2, ['critical', 'serious']);
// You could also expect the test to fail if the threshold is exceeded
// await expect(async () => {
// await checkAccessibility(page, testInfo, undefined, 0);
// }).toThrowError(/accessibility violations found/);
});
test('only specific section of contact page should be accessible', async ({ page }, testInfo) => {
await page.goto('/contact');
// Only check the contact form element, which we assume is well-formed (if issues, this will catch)
const formLocator = page.getByTestId('contact-form');
// Check only the form, which we will try to ensure is accessible in this specific test
// Let's set the threshold for this section to 0, expecting no issues here.
await checkAccessibility(page, testInfo, '[data-test-id="contact-form"]', 0);
});
});
Run: npx playwright test e2e/accessibility.spec.ts
Expected Outcome:
- The homepage and products page tests should pass.
- The contact page test should fail or report violations (depending on your
violationThreshold) because of the missing label for the email input and the low contrast div, attaching a JSON report. - The test checking only the specific form section might pass if the other issues are outside the form, or fail if the form itself has issues.
Best Practices for Accessibility Testing:
- Integrate Early: Run a11y checks frequently, preferably in CI, to catch issues early.
- Scan Key Flows: Prioritize critical user paths (registration, checkout, main dashboard).
- Selective Scanning: Use
AxeBuilder.include()andexclude()to focus scans on relevant parts of the DOM and avoid known noisy areas. - Set Baselines: Establish acceptable thresholds for violations (e.g.,
violationThreshold) and fail tests if they exceed them. - Review Reports: Regularly review the detailed JSON reports generated by
axe-coreto understand and fix violations. - Manual Audits: Automated tools catch ~50% of a11y issues. Supplement with manual audits by accessibility experts.
Exercise 6.1.1: Fix and Re-test a Contact Page Accessibility Issue
- Modify
src/app/contact/contact.component.htmlto fix the missinglabelfor the email input and the low-contrastdiv. - Rerun
e2e/accessibility.spec.tsand ensure all accessibility tests pass (or meet their defined thresholds).
src/app/contact/contact.component.html (updated):
<p>contact works!</p>
<h2>Contact Us</h2>
<form data-test-id="contact-form" (ngSubmit)="submitForm()">
<div>
<label for="name">Name:</label>
<input type="text" id="name" data-test-id="contact-name-input" [(ngModel)]="contactForm.name" name="name" required minlength="3">
</div>
<div>
<!-- Fixed: Added label for email input -->
<label for="email">Email:</label>
<input type="email" id="email" data-test-id="contact-email-input" [(ngModel)]="contactForm.email" name="email" required email>
</div>
<div>
<label for="message">Message:</label>
<textarea id="message" data-test-id="contact-message-textarea" [(ngModel)]="contactForm.message" name="message"></textarea><br><br>
</div>
<button type="submit" data-test-id="contact-submit-button" [disabled]="loading">Send Message</button>
</form>
<div data-test-id="submission-feedback" *ngIf="submissionFeedback" [ngClass]="{'success': isSuccess, 'error': !isSuccess}">
{{ submissionFeedback }}
</div>
<!-- Fixed: Adjusted colors for better contrast (example) -->
<div style="background-color: #FFF; color: #333; padding: 10px; border: 1px solid #CCC;" data-test-id="low-contrast-div">
This text now has better contrast and is easier to read.
</div>
After making these changes and running the tests again, the contact page should be accessible and report violations test should now pass (or fail with fewer violations, depending on if other issues exist).
7. Performance Testing (Lighthouse with Playwright)
Performance is a critical aspect of user experience. Slow-loading or unresponsive Angular applications can lead to user frustration, decreased engagement, and negative business impacts. Playwright, combined with Google Lighthouse, allows you to automate performance audits and integrate them directly into your testing pipeline.
Why Performance Testing with Playwright & Lighthouse?
- Real User Flows: Unlike standalone Lighthouse audits, Playwright allows you to run Lighthouse against complex, authenticated user flows (e.g., after login, interacting with dynamic content).
- Early Detection: Catch performance regressions during development, not after deployment.
- Actionable Metrics: Lighthouse provides industry-standard metrics (FCP, LCP, TTI, CLS) and actionable recommendations.
- CI/CD Integration: Automate performance checks on every push/PR to prevent performance degradation.
- Cross-Browser Insights: While Lighthouse primarily runs on Chromium, Playwright enables testing other browser-specific performance characteristics through standard E2E tests (e.g., responsiveness, animation smoothness).
Integrating Lighthouse
The recommended way to integrate Lighthouse with Playwright is by using a dedicated library like playwright-lighthouse or by directly launching Lighthouse against a Playwright-controlled browser instance.
We’ll use playwright-lighthouse for simplicity, which wraps the Lighthouse CLI for easier integration.
1. Install playwright-lighthouse and lighthouse:
npm install -D playwright-lighthouse lighthouse
# You might also need chrome-launcher for direct Lighthouse control
# npm install -D chrome-launcher
2. Configure your Playwright project to enable remote debugging for Lighthouse:
Lighthouse needs to connect to the browser Playwright is controlling. This is typically done via a remote debugging port.
playwright.config.ts modification (for a dedicated performance project):
It’s best to create a separate project for performance tests to run Lighthouse on a single Chromium instance with debugging enabled.
import { defineConfig, devices } from '@playwright/test';
import path from 'path'; // Needed for resolving paths
export default defineConfig({
testDir: './e2e',
// ... other global configs ...
projects: [
// ... existing projects (chromium, firefox, webkit, smoke, full) ...
{
name: 'performance',
testMatch: '**/e2e/performance/**/*.spec.ts', // Only run files in performance directory
use: {
...devices['Desktop Chrome'],
// IMPORTANT: Enable remote debugging for Lighthouse to connect
launchOptions: {
args: ['--remote-debugging-port=9222'], // Arbitrary port, ensure it's free
},
},
workers: 1, // Lighthouse should run on a single worker for consistency
timeout: 90 * 1000, // Longer timeout for performance audits (e.g., 90 seconds)
},
],
// ... other configs ...
});
3. Create a utility function for running Lighthouse audits:
e2e/utils/performance.ts:
import { Page, test } from '@playwright/test';
import { playAudit } from 'playwright-lighthouse';
import { Config, ConfigJson } from 'lighthouse'; // For Lighthouse configuration types
/**
* Runs a Lighthouse audit on the current page.
* @param page The Playwright page object.
* @param testInfo The Playwright testInfo object.
* @param thresholdConfig Optional. Custom performance thresholds (e.g., { performance: 80, accessibility: 90 }).
* @param auditConfig Optional. Custom Lighthouse configuration (e.g., 'desktop-config').
*/
export async function runLighthouseAudit(
page: Page,
testInfo: test.TestInfo,
thresholdConfig?: { [category: string]: number },
auditConfig?: ConfigJson,
): Promise<void> {
const reportsDir = testInfo.outputPath('lighthouse-reports');
const reportName = `${testInfo.title.replace(/\s/g, '-')}`;
// Default thresholds if not provided
const defaultThresholds = {
performance: 70, // Example: minimum 70 score for performance
accessibility: 85,
'best-practices': 85,
seo: 85,
};
// Default Lighthouse config (using desktop preset)
const desktopConfig = (await import('lighthouse/lighthouse-core/config/desktop-config')).default;
try {
await playAudit({
page: page,
config: auditConfig || desktopConfig as Config, // Use imported desktop config, cast to Config
thresholds: thresholdConfig || defaultThresholds,
port: 9222, // The remote debugging port configured in playwright.config.ts
ignoreError: false, // Fail the test if Lighthouse audit encounters a critical error
reports: {
formats: { html: true, json: true }, // Generate both HTML and JSON reports
name: reportName,
directory: reportsDir,
},
});
console.log(`Lighthouse audit for '${testInfo.title}' passed.`);
} catch (error: any) {
console.error(`Lighthouse audit for '${testInfo.title}' failed: ${error.message}`);
// Attach the report for debugging if available
const htmlReportPath = path.join(reportsDir, `${reportName}.html`);
const jsonReportPath = path.join(reportsDir, `${reportName}.json`);
if (testInfo.attachments) {
testInfo.attachments.push({
name: `${reportName}-report.html`,
contentType: 'text/html',
path: htmlReportPath,
});
testInfo.attachments.push({
name: `${reportName}-report.json`,
contentType: 'application/json',
path: jsonReportPath,
});
}
throw new Error(`Lighthouse audit failed for '${testInfo.title}'. See attached reports for details.`);
}
}
Important: You might encounter Module not found: Error: Can't resolve 'lighthouse/lighthouse-core/config/desktop-config' in older setups. Ensure your tsconfig.json or module resolution is set up correctly (e.g., moduleResolution: "node" or bundler). The (await import('lighthouse/lighthouse-core/config/desktop-config')).default syntax helps with ESM/CJS interop.
4. Create performance tests:
Code Example (e2e/performance/home.perf.spec.ts):
// e2e/performance/home.perf.spec.ts
import { test, expect } from '@playwright/test';
import { runLighthouseAudit } from '../utils/performance';
test.describe('Performance Testing - Home Page', () => {
test('homepage core web vitals should meet targets', async ({ page }, testInfo) => {
// Navigate to the page
await page.goto('/');
// Run Lighthouse audit with custom thresholds
await runLighthouseAudit(page, testInfo, {
performance: 85, // Aim for a high performance score
accessibility: 95,
seo: 90,
'best-practices': 90,
});
// You can also add traditional Playwright assertions for specific elements
await expect(page.locator('h1')).toBeVisible();
});
test('homepage performance after dynamic data load', async ({ page }, testInfo) => {
await page.goto('/');
// Simulate user interaction that loads dynamic content
await page.getByTestId('load-data-button').click();
await expect(page.getByTestId('dynamic-data-container')).toBeVisible();
// Run Lighthouse after the interaction, maybe with slightly relaxed performance goals
await runLighthouseAudit(page, testInfo, {
performance: 75, // Might be lower due to dynamic loading
accessibility: 90,
});
});
});
Run: npx playwright test --project=performance e2e/performance/home.perf.spec.ts
Expected Outcome:
- Playwright will launch Chromium with remote debugging enabled.
- Lighthouse will connect to this browser instance and run its audit.
- Reports (HTML, JSON) will be saved in
test-results/lighthouse-reports(or similar, based ontestInfo.outputPath). - The test will pass if Lighthouse scores meet the defined
thresholds. - The test will fail if scores are below thresholds or if Lighthouse encounters an error, attaching the reports for review.
Best Practices for Performance Testing:
- Dedicated Project: Use a separate Playwright project for performance tests, configured with
workers: 1andlaunchOptions: { args: ['--remote-debugging-port=<port>'] }. - Realistic Scenarios: Audit critical user flows, not just the homepage. Test authenticated states and interactions that trigger heavy UI/API activity.
- Consistent Environment: Run performance tests on a consistent CI environment to avoid fluctuating results.
- Thresholds: Define clear performance thresholds and fail builds if they are not met.
- Monitor Trends: Store historical performance reports and visualize trends over time to detect gradual regressions.
- Consider Impact: Differentiate between critical and less critical pages. A slight performance drop on a rarely visited page might be acceptable, but not on a conversion-critical page.
- Deep Dive: Use the generated Lighthouse HTML reports for detailed insights and recommendations.
Exercise 7.1.1: Performance Audit for the Products Page
Create a new performance test e2e/performance/products.perf.spec.ts that:
- Navigates to the
/productspage. - Mocks the
/api/productsendpoint to return a large number of products (e.g., 50 items) to simulate a heavier page load. - Runs a Lighthouse audit, expecting a slightly lower performance score due to the increased data, but maintaining high accessibility and SEO.
// e2e/performance/products.perf.spec.ts
import { test, expect } from '@playwright/test';
import { runLighthouseAudit } from '../utils/performance';
test.describe('Performance Testing - Products Page with Large Data', () => {
const largeMockProducts = Array.from({ length: 50 }, (_, i) => ({
id: i + 1,
name: `Product ${i + 1}`,
price: 10 + i * 2,
}));
test('products page with many items should meet performance targets', async ({ page }, testInfo) => {
// 1. Mock the API to return a large number of products
await page.route('**/api/products', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(largeMockProducts),
});
});
// 2. Navigate to the products page
await page.goto('/products');
// Wait for all products to be rendered (e.g., checking the number of rows)
await expect(page.getByTestId('product-list').locator('li')).toHaveCount(largeMockProducts.length);
// 3. Run Lighthouse audit with adjusted thresholds
await runLighthouseAudit(page, testInfo, {
performance: 65, // Slightly lower due to many items, adjust as needed
accessibility: 90,
seo: 85,
'best-practices': 85,
});
await expect(page.getByText('Product 1 ($10.00)')).toBeVisible();
await expect(page.getByText(`Product ${largeMockProducts.length}`)).toBeVisible();
});
});
To run: npx playwright test --project=performance e2e/performance/products.perf.spec.ts
8. Advanced Fixtures: Beyond Basic Page Objects
Playwright’s fixture system is incredibly powerful, allowing you to create isolated, reusable test environments. Beyond simple page or context setups, advanced fixtures enable complex scenarios like authenticated sessions, database seeding, or dynamically configured test contexts without polluting individual test files.
Why Advanced Fixtures?
- Ultimate Test Isolation: Ensure each test starts from a truly known and consistent state, even with complex prerequisites.
- Reduced Boilerplate: Abstract away repetitive setup/teardown code, making tests cleaner and more focused on logic.
- Enhanced Performance: Reuse expensive resources (like a logged-in session) across multiple tests efficiently.
- Dynamic Data Management: Generate unique test data per test or worker, preventing data collisions in parallel execution.
- Type Safety: With TypeScript, custom fixtures provide strong type checking, improving developer experience.
Types of Advanced Fixtures
- Worker-Scoped Fixtures: Setup once per worker process, then reused across all tests run by that worker. Ideal for expensive resources like authenticated
storageStateor a temporary database. - Test-Scoped Fixtures: Setup once per test, providing a fresh instance for each individual test.
- Parameterizing Fixtures: Allow tests to request a fixture with specific parameters.
Code Example: Authenticated User Fixture with Database Seeding
Let’s expand on our previous loggedInPage concept. Imagine our Angular app has a user profile page, and to test it, we need:
- A pre-existing user in a mock database.
- The browser context to be logged in as that user.
1. Mock Database Utility (e2e/utils/mock-db.ts):
// e2e/utils/mock-db.ts
interface MockUser {
id: string;
username: string;
email: string;
roles: string[];
}
const mockDatabase: Map<string, MockUser> = new Map();
export const mockDb = {
// Simulate an async operation
async createUser(user: MockUser): Promise<MockUser> {
console.log(`[Mock DB] Creating user: ${user.username}`);
if (mockDatabase.has(user.id)) {
throw new Error(`User with ID ${user.id} already exists.`);
}
mockDatabase.set(user.id, user);
return Promise.resolve(user);
},
async findUser(id: string): Promise<MockUser | undefined> {
return Promise.resolve(mockDatabase.get(id));
},
async deleteUser(id: string): Promise<boolean> {
console.log(`[Mock DB] Deleting user: ${id}`);
return Promise.resolve(mockDatabase.delete(id));
},
async clearDatabase(): Promise<void> {
console.log("[Mock DB] Clearing database.");
mockDatabase.clear();
return Promise.resolve();
},
};
2. Custom Test Fixture (e2e/fixtures/auth.fixture.ts):
This file extends Playwright’s test to provide new fixtures.
// e2e/fixtures/auth.fixture.ts
import { test as base, expect, Page, BrowserContext } from '@playwright/test';
import { mockDb } from '../utils/mock-db';
import path from 'path';
// Define the types of new fixtures available to tests
type AuthFixtures = {
// `dbUser` is a worker-scoped fixture providing a unique user for each worker
dbUser: { id: string; username: string; email: string };
// `loggedInPage` is a test-scoped fixture that provides a page logged in as dbUser
loggedInPage: Page;
// `loggedInContext` is a test-scoped fixture that provides a browser context logged in as dbUser
loggedInContext: BrowserContext;
// `adminPage` is a test-scoped fixture that logs in as a predefined admin user
adminPage: Page;
};
// Path to store authenticated state, unique per worker for parallel runs
const authFile = (workerIndex: number) =>
path.join(__dirname, `../.auth/user-${workerIndex}.json`);
export const test = base.extend<AuthFixtures>({
// 1. dbUser: Worker-scoped fixture to create and manage a unique test user
dbUser: [
async ({}, use, testInfo) => {
const user = {
id: `user-${testInfo.workerIndex}-${Date.now()}`,
username: `testuser-${testInfo.workerIndex}`,
email: `test${testInfo.workerIndex}@example.com`,
roles: ['user'],
};
await mockDb.createUser(user);
await use(user); // Provide the user object to tests that request `dbUser`
await mockDb.deleteUser(user.id); // Clean up the user after all tests in this worker
},
{ scope: 'worker', auto: true }, // 'auto: true' means it runs automatically for each worker
],
// 2. loggedInContext: Creates a logged-in browser context, worker-scoped
// This fixture depends on `dbUser`
loggedInContext: [
async ({ browser, dbUser }, use, testInfo) => {
const authFilePath = authFile(testInfo.workerIndex);
let context: BrowserContext;
try {
// Try to reuse authenticated state if it exists
context = await browser.newContext({ storageState: authFilePath });
// Verify the login state is still valid (e.g., by checking a known logged-in element)
const page = await context.newPage();
await page.goto('/');
const welcomeMessage = page.locator(`text=Welcome, ${dbUser.username}!`);
await expect(welcomeMessage).toBeVisible({ timeout: 5000 });
await page.close(); // Close the verification page
} catch (error) {
// If reuse fails or state doesn't exist, perform login
console.warn(`[Auth Fixture] Performing fresh login for worker ${testInfo.workerIndex}`);
context = await browser.newContext();
const page = await context.newPage();
await page.goto('/login'); // Navigate to your login page
await page.getByLabel('Username').fill(dbUser.username);
await page.getByLabel('Password').fill('password123'); // Assume a generic password
await page.getByRole('button', { name: 'Login' }).click();
await expect(page).toHaveURL('/'); // Assume redirection to homepage
await expect(page.locator(`text=Welcome, ${dbUser.username}!`)).toBeVisible();
// Save the authentication state for reuse
await context.storageState({ path: authFilePath });
await page.close(); // Close the login page
}
await use(context); // Provide the authenticated context to tests
// Teardown: Close the context
await context.close();
},
{ scope: 'worker' }, // This runs once per worker
],
// 3. loggedInPage: A test-scoped fixture that uses the loggedInContext
// This provides a fresh page within the authenticated context for each test
loggedInPage: [
async ({ loggedInContext }, use) => {
const page = await loggedInContext.newPage();
await page.goto('/'); // Navigate to a known starting point
await use(page); // Provide the authenticated page to the test
await page.close(); // Close the page after the test
},
{ scope: 'test' }, // This runs once per test
],
// 4. adminPage: A separate fixture for an admin user
adminPage: [
async ({ browser }, use) => {
const adminUsername = 'adminuser';
const adminPassword = 'adminpassword';
// Ensure admin user exists in mock DB
await mockDb.createUser({
id: 'admin-1',
username: adminUsername,
email: 'admin@example.com',
roles: ['admin'],
});
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/login');
await page.getByLabel('Username').fill(adminUsername);
await page.getByLabel('Password').fill(adminPassword);
await page.getByRole('button', { name: 'Login' }).click();
await expect(page).toHaveURL('/admin-dashboard'); // Assuming admin has a different redirect
await expect(page.locator(`text=Welcome, ${adminUsername}!`)).toBeVisible();
await use(page);
await page.close();
await context.close();
await mockDb.deleteUser('admin-1'); // Clean up admin user
},
{ scope: 'test' }, // This runs once per test
],
});
export { expect }; // Re-export Playwright's expect for convenience
3. Update playwright.config.ts to include the fixture file:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// ... existing configs ...
testDir: './e2e',
// Reference your custom test file that exports 'test' and 'expect'
// This is how Playwright knows about your custom fixtures
testMatch: '**/*.spec.ts', // Match all spec files
// For custom fixtures, explicitly import them via the 'test' object
// If you are using a base test file, this is often implicit.
// Make sure your tests import from `e2e/fixtures/auth.fixture.ts`
});
4. Create a Login Page and Profile Page in Angular:
To test our fixture, we need simple login and profile pages.
src/app/login/login.component.html:
<h2>Login</h2>
<form (ngSubmit)="login()" data-test-id="login-form">
<div>
<label for="username">Username:</label>
<input type="text" id="username" [(ngModel)]="username" name="username" data-test-id="login-username">
</div>
<div>
<label for="password">Password:</label>
<input type="password" id="password" [(ngModel)]="password" name="password" data-test-id="login-password">
</div>
<button type="submit" data-test-id="login-button">Login</button>
</form>
<div *ngIf="errorMessage" class="error">{{ errorMessage }}</div>
src/app/login/login.component.ts:
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
@Component({
selector: 'app-login',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './login.component.html',
styleUrl: './login.component.css'
})
export class LoginComponent {
username = '';
password = '';
errorMessage = '';
constructor(private router: Router) {}
login() {
// Basic hardcoded logic for demonstration, replace with real auth
if (this.username.startsWith('testuser-') && this.password === 'password123') {
localStorage.setItem('isLoggedIn', 'true');
localStorage.setItem('username', this.username);
this.router.navigate(['/profile']); // Navigate to profile on success
} else if (this.username === 'adminuser' && this.password === 'adminpassword') {
localStorage.setItem('isLoggedIn', 'true');
localStorage.setItem('username', this.username);
this.router.navigate(['/admin-dashboard']); // Admin specific dashboard
}
else {
this.errorMessage = 'Invalid username or password';
}
}
}
src/app/profile/profile.component.html:
<h2>User Profile</h2>
<div data-test-id="profile-info">
<p>Welcome, <span data-test-id="profile-username">{{ username }}</span>!</p>
<p>Email: <span data-test-id="profile-email">{{ email }}</span></p>
<p>User ID: <span data-test-id="profile-id">{{ userId }}</span></p>
</div>
<button (click)="logout()" data-test-id="logout-button">Logout</button>
src/app/profile/profile.component.ts:
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
@Component({
selector: 'app-profile',
standalone: true,
imports: [CommonModule],
templateUrl: './profile.component.html',
styleUrl: './profile.component.css'
})
export class ProfileComponent implements OnInit {
username: string | null = null;
email: string | null = null;
userId: string | null = null;
constructor(private router: Router) {}
ngOnInit(): void {
this.username = localStorage.getItem('username');
// For email/ID, we'd typically fetch from an API after login,
// but for demo, let's derive from username
if (this.username) {
this.email = `${this.username}@example.com`;
this.userId = `user-${this.username.split('-')[1]}`; // Simplified ID
}
// Redirect if not logged in
if (!localStorage.getItem('isLoggedIn')) {
this.router.navigate(['/login']);
}
}
logout() {
localStorage.removeItem('isLoggedIn');
localStorage.removeItem('username');
this.router.navigate(['/login']);
}
}
src/app/admin-dashboard/admin-dashboard.component.html (simple placeholder):
<h2>Admin Dashboard</h2>
<p>Welcome, Admin User! You have elevated privileges.</p>
<button (click)="logout()">Logout</button>
Add LoginComponent, ProfileComponent, and AdminDashboardComponent to your src/app/app.routes.ts:
// ... existing imports ...
import { LoginComponent } from './login/login.component';
import { ProfileComponent } from './profile/profile.component';
import { AdminDashboardComponent } from './admin-dashboard/admin-dashboard.component';
export const routes: Routes = [
// ... existing routes ...
{ path: 'login', component: LoginComponent },
{ path: 'profile', component: ProfileComponent },
{ path: 'admin-dashboard', component: AdminDashboardComponent },
// ...
];
5. Write Tests using the Custom Fixtures (e2e/profile.spec.ts):
Important: Your test files MUST import test and expect from your custom fixture file (./fixtures/auth.fixture.ts) instead of @playwright/test.
// e2e/profile.spec.ts
import { test, expect } from './fixtures/auth.fixture'; // IMPORTANT: Import from your custom fixture
test.describe('User Profile Page', () => {
// This test uses the 'loggedInPage' fixture, ensuring it starts logged in
test('should display logged-in user profile information', async ({ loggedInPage, dbUser }) => {
// Navigate to profile page (already logged in by fixture)
await loggedInPage.goto('/profile');
await expect(loggedInPage.getByTestId('profile-username')).toBeVisible();
await expect(loggedInPage.getByTestId('profile-username')).toHaveText(dbUser.username);
await expect(loggedInPage.getByTestId('profile-email')).toHaveText(dbUser.email);
await expect(loggedInPage.getByTestId('profile-id')).toHaveText(dbUser.id);
});
test('should allow user to log out from profile page', async ({ loggedInPage }) => {
await loggedInPage.goto('/profile');
await loggedInPage.getByTestId('logout-button').click();
await expect(loggedInPage).toHaveURL('/login'); // Should redirect to login page
await expect(loggedInPage.getByTestId('login-form')).toBeVisible(); // Login form should be visible
});
// This test uses the 'adminPage' fixture, starting as an admin
test('admin user should access admin dashboard', async ({ adminPage }) => {
// The adminPage fixture already navigates to the admin dashboard and asserts login
await expect(adminPage.locator('h2')).toHaveText('Admin Dashboard');
await expect(adminPage.locator('text=Welcome, Admin User!')).toBeVisible();
});
// Example of a test that needs a fresh, unauthenticated page
test('guest user cannot access profile page directly', async ({ page }) => {
await page.goto('/profile'); // Try to go to profile directly
await expect(page).toHaveURL('/login'); // Should be redirected to login
await expect(page.getByTestId('login-form')).toBeVisible();
});
});
Run: npx playwright test e2e/profile.spec.ts
Expected Outcome:
- You’ll see a browser open (or run headless).
- For the
loggedInPagetests, Playwright will either reuse the session fromauth.jsonor perform a fresh login, then execute the tests. - For
adminPage, a new admin session is created. dbUsersetup and teardown will run automatically per worker.
Best Practices for Advanced Fixtures:
- Minimal Setup: Ensure fixtures only set up what’s absolutely necessary.
- Clear Scope: Use
scope: 'worker'for expensive, reusable setups andscope: 'test'for fresh, isolated test prerequisites. - Lazy Initialization: Initialize resources within fixtures only when they are requested by a test.
- Explicit Dependencies: Use Playwright’s fixture dependencies (e.g.,
loggedInPage: async ({ loggedInContext }, use) => { ... }) to manage complex setups. - Cleanup (Teardown): Always implement
await use(...)followed by cleanup code to ensure a clean state for subsequent tests or runs. - Handle Flakiness: Incorporate retry logic or robust waiting within complex fixture setup.
- Error Handling: Make fixtures resilient to unexpected conditions.
Exercise 8.1.1: Parameterized Fixture for Different User Roles
Create a new parameterized fixture userWithRole that allows a test to request a user with a specific role (e.g., 'admin', 'editor', 'viewer') to be created in the mock DB and logged into a page.
- You’ll need to update
e2e/fixtures/auth.fixture.tsto add this new fixture. - Then, create a new test file
e2e/roles.spec.tsto demonstrate using this parameterized fixture.
1. Update e2e/fixtures/auth.fixture.ts:
// e2e/fixtures/auth.fixture.ts (add to existing AuthFixtures and test.extend)
// ... existing imports and types ...
type AuthFixtures = {
// ... existing fixtures ...
userWithRole: (role: 'admin' | 'editor' | 'viewer') => Promise<Page>; // Parameterized fixture
};
export const test = base.extend<AuthFixtures>({
// ... existing fixtures (dbUser, loggedInContext, loggedInPage, adminPage) ...
userWithRole: [
async ({ browser }, use, testInfo) => {
const role = testInfo.workerIndex; // Access parameter via `testInfo` or `_request`
// For this example, we'll simplify and use a hardcoded role,
// a more robust way involves defining `userWithRole` as a separate, parameterized fixture itself.
// Let's create a dynamic user for each test requesting this.
let requestedRole: string = (testInfo.titlePath[testInfo.titlePath.length - 1] as string).includes('admin') ? 'admin' : 'viewer'; // Heuristic
// Or, better yet, define the fixture to take a parameter:
// userWithRole: [async ({ browser, _request }, use) => {
// const role = _request.param;
// ...
// For simplicity here, we'll make this fixture generate a user dynamically with a role.
const username = `dynamic-${requestedRole}-user-${Date.now()}`;
const email = `${username}@example.com`;
const userId = `id-${username}`;
const password = 'password123';
const user = { id: userId, username, email, roles: [requestedRole] };
await mockDb.createUser(user);
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/login');
await page.getByLabel('Username').fill(username);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Login' }).click();
await expect(page.locator(`text=Welcome, ${username}!`)).toBeVisible(); // Generic welcome message
await use(page); // Yield the authenticated page
await page.close();
await context.close();
await mockDb.deleteUser(userId); // Clean up
},
{ scope: 'test' },
],
});
export { expect };
Correction for Parameterized Fixture: The above userWithRole is not a truly parameterized fixture in the Playwright sense (which uses _request.param). It’s more of a dynamic fixture that derives a role. A proper parameterized fixture would look like this:
// e2e/fixtures/auth.fixture.ts (updated with a true parameterized fixture)
// ... existing imports and types ...
type MyTestFixtures = {
// Define a parameterized fixture 'userPage'
userPage: (role: 'admin' | 'editor' | 'viewer') => Promise<Page>;
// ... other existing fixtures
};
export const test = base.extend<MyTestFixtures>({
// ... existing fixtures ...
// Parameterized fixture to get a page for a specific user role
userPage: [
async ({ browser }, use, testInfo) => {
// The parameter for this fixture is accessed via testInfo.workerIndex or _request
// For simplicity, we'll get the role from `testInfo.title` for demonstration
// In a real scenario, you'd pass this as a parameter directly to the fixture call
// or use `_request.param` if you defined it as such.
const requestedRole = (testInfo.title.includes('admin') ? 'admin' : testInfo.title.includes('editor') ? 'editor' : 'viewer');
const username = `dynamic-${requestedRole}-user-${testInfo.workerIndex}-${Date.now()}`;
const email = `${username}@example.com`;
const userId = `id-${username}`;
const password = 'password123';
const user = { id: userId, username, email, roles: [requestedRole] };
await mockDb.createUser(user);
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/login');
await page.getByLabel('Username').fill(username);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Login' }).click();
await expect(page.locator(`text=Welcome, ${username}!`)).toBeVisible();
await use(page); // Yield the authenticated page
await page.close();
await context.close();
await mockDb.deleteUser(userId); // Clean up
},
{ scope: 'test' },
],
});
export { expect }; // Re-export Playwright's expect
To use a truly parameterized fixture, the test function test() would take an object with { userPage: 'admin' } for example. However, Playwright’s type system handles this more fluidly with a function signature. For the test.extend API, the parameter comes from _request.param if the fixture is declared as such. For test('title', async ({ fixture }) => { ... }), the fixture is directly passed.
Given the goal, the previous loggedInPage and adminPage might be sufficient or a simpler function helper could be created. However, for a truly parameterized fixture, the Playwright documentation on extending fixtures with parameters should be consulted for the most correct implementation. The simplest way to achieve a “different user per role” would be to have different fixtures like adminUserPage, editorUserPage, each doing similar login logic. For now, let’s stick to using the loggedInPage and adminPage from the example and create helper functions within tests to manipulate roles.
Let’s refine the userWithRole fixture concept for simplicity as a regular test-scoped fixture that can dynamically create a user based on test context (though less “parameterized” in the strict Playwright sense, it achieves the goal for this exercise).
Let’s adjust the problem for the exercise to be simpler given the complexity of parameterized fixtures.
Revised Exercise 8.1.1: Create a userWithSpecificRole Fixture
Create a new test-scoped fixture userWithEditorRolePage that:
- Creates a new user in the mock database with the role
'editor'. - Logs this user into the Angular application.
- Provides a Playwright
Pageobject that is already authenticated as this editor user. - Cleans up the user after the test.
Then, create a new test file e2e/roles.spec.ts to demonstrate using this new fixture.
1. Update e2e/fixtures/auth.fixture.ts:
// e2e/fixtures/auth.fixture.ts (add to existing AuthFixtures and test.extend)
// ... existing imports and types ...
type AuthFixtures = {
// ... existing fixtures (dbUser, loggedInContext, loggedInPage, adminPage) ...
userWithEditorRolePage: Page; // New fixture
};
export const test = base.extend<AuthFixtures>({
// ... existing fixtures ...
userWithEditorRolePage: [
async ({ browser }, use) => {
const username = `editor-user-${Date.now()}`;
const email = `${username}@example.com`;
const userId = `id-${username}`;
const password = 'password123'; // Consistent password for mock users
const user = { id: userId, username, email, roles: ['editor'] };
await mockDb.createUser(user);
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/login');
await page.getByLabel('Username').fill(username);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Login' }).click();
await expect(page.locator(`text=Welcome, ${username}!`)).toBeVisible(); // Assume general welcome
await use(page); // Provide the authenticated page to the test
await page.close();
await context.close();
await mockDb.deleteUser(userId); // Clean up the user
},
{ scope: 'test' }, // This runs once per test
],
});
export { expect }; // Re-export Playwright's expect
2. Create a Test File (e2e/roles.spec.ts):
// e2e/roles.spec.ts
import { test, expect } from './fixtures/auth.fixture'; // IMPORTANT
test.describe('Role-Based Access Control Tests', () => {
test('editor user should access editor-specific content', async ({ userWithEditorRolePage }) => {
// userWithEditorRolePage is already logged in as an editor and navigated to '/'
await userWithEditorRolePage.goto('/editor-dashboard'); // Assume an editor dashboard route
await expect(userWithEditorRolePage.locator('h2')).toHaveText('Editor Dashboard');
await expect(userWithEditorRolePage.locator('text=Welcome, editor-user')).toBeVisible();
// Example: verify editor-specific button is visible, non-editor button is hidden
await expect(userWithEditorRolePage.getByRole('button', { name: 'Edit Content' })).toBeVisible();
await expect(userWithEditorRolePage.getByRole('button', { name: 'Admin Settings' })).not.toBeVisible();
});
// Example: a test for an admin specific feature
test('admin user should access admin-only features', async ({ adminPage }) => {
// adminPage fixture logs in as admin and navigates to /admin-dashboard
await adminPage.goto('/admin-settings'); // Assume an admin settings page
await expect(adminPage.locator('h2')).toHaveText('Admin Settings');
await expect(adminPage.getByRole('button', { name: 'Manage Users' })).toBeVisible();
});
test('regular user should not access editor dashboard', async ({ loggedInPage }) => {
// This test uses the general 'loggedInPage' (regular user)
await loggedInPage.goto('/editor-dashboard');
// Assuming redirection to login or an unauthorized page
await expect(loggedInPage).toHaveURL('/unauthorized'); // Example unauthorized page
// Or: await expect(loggedInPage.locator('text=You are not authorized')).toBeVisible();
});
});
You’d need to create placeholder Angular components for /editor-dashboard, /admin-settings, and /unauthorized to fully run these tests.
9. Testing Authentication Flows (JWT, Session Management)
Robustly testing authentication in modern Angular applications is critical but can be tricky due to token-based security (JWT, OAuth) and session management. Playwright provides powerful tools to manage authentication state efficiently without repeating login steps for every test.
Why Advanced Authentication Testing?
- Realism: Simulate actual login processes, including multi-factor authentication (MFA) if applicable.
- Efficiency: Avoid slow, repetitive login actions in each test by reusing authenticated states.
- Comprehensive Coverage: Test token expiration, refresh mechanisms, and different user roles.
- Security: Ensure secure handling of authentication credentials and tokens.
Key Playwright Features for Authentication
browserContext.storageState()andbrowserContext.newContext({ storageState }): This is the cornerstone. It allows you to save and load browser session state (cookies, local storage, session storage), effectively “logging in” a browser context without UI interaction.page.request(API testing within E2E): For more direct API-driven authentication (e.g., getting a JWT directly from an API call), you can usepage.requestto perform network requests.browserContext.addCookies()andbrowserContext.setExtraHTTPHeaders(): Useful for precise control over cookies or adding authorization headers.- Fixtures: As seen in the previous section, fixtures are ideal for encapsulating the entire authentication process.
Code Example: JWT-based Authentication Flow
Let’s assume our Angular app uses JWT tokens stored in localStorage for authentication.
1. Angular Setup (Conceptual, focus on Playwright interaction):
- Login Component: On successful login, the backend returns a JWT token which is stored in
localStorageasauthToken. - Auth Guard: Angular’s Auth Guard checks for
authTokeninlocalStorageto allow/deny access to protected routes. - API Service: Automatically attaches
Authorization: Bearer <authToken>header to outgoing HTTP requests.
For Playwright, we want to simulate this by directly injecting the token into localStorage after getting it from a mock or real API.
2. Authentication Setup File (e2e/auth.setup.ts):
This file runs once before all tests in a project (or globally) to generate the authenticated state.
// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'path';
// Define where to save the authentication state file
const authFile = path.join(__dirname, '.auth/user.json');
const adminAuthFile = path.join(__dirname, '.auth/admin.json');
setup('authenticate as a regular user', async ({ page }) => {
// Assuming a generic login page, replace with your actual login flow
await page.goto('/login');
await page.getByLabel('Username').fill('testuser');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Login' }).click();
// Wait for the login to complete and for the token to be set in localStorage
await expect(page).toHaveURL('/'); // Redirects to home page
await page.waitForFunction(() => localStorage.getItem('authToken')); // Wait for token
// Save the authenticated state (cookies, localStorage, sessionStorage)
await page.context().storageState({ path: authFile });
});
setup('authenticate as an admin user', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Username').fill('adminuser');
await page.getByLabel('Password').fill('adminpassword');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page).toHaveURL('/admin-dashboard'); // Admin redirect
await page.waitForFunction(() => localStorage.getItem('authToken'));
await page.context().storageState({ path: adminAuthFile });
});
3. playwright.config.ts to use auth.setup.ts:
Configure your projects to run these setup files and then use the saved state.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
// Define paths to authentication state files
const userAuthFile = path.join(__dirname, 'e2e/.auth/user.json');
const adminAuthFile = path.join(__dirname, 'e2e/.auth/admin.json');
export default defineConfig({
testDir: './e2e',
// Global setup to run authentication before any tests start
// This will run auth.setup.ts once for each setup.
globalSetup: require.resolve('./e2e/auth.setup'),
// ... other global configs ...
projects: [
{
name: 'setup-user',
testMatch: /auth\.setup\.ts/, // Only run the user authentication setup file
// Do not use the base URL for setup to prevent infinite loop
use: {
baseURL: 'http://localhost:4200',
}
},
{
name: 'setup-admin',
testMatch: /auth\.setup\.ts/, // Only run the admin authentication setup file
// Do not use the base URL for setup to prevent infinite loop
use: {
baseURL: 'http://localhost:4200',
}
},
{
name: 'authenticated-user',
testMatch: /^(?!.*auth\.setup\.ts).*spec\.ts$/, // Run all spec files except setup files
dependencies: ['setup-user'], // Ensure 'setup-user' runs before these tests
use: {
...devices['Desktop Chrome'],
baseURL: 'http://localhost:4200',
storageState: userAuthFile, // Load the saved user state
},
},
{
name: 'authenticated-admin',
testMatch: /^(?!.*auth\.setup\.ts).*admin\.spec\.ts$/, // Run only admin-specific tests
dependencies: ['setup-admin'],
use: {
...devices['Desktop Chrome'],
baseURL: 'http://localhost:4200',
storageState: adminAuthFile, // Load the saved admin state
},
},
// ... other projects (chromium, firefox, webkit, performance, component)
],
});
4. Write Tests for Authenticated Users:
e2e/authenticated.spec.ts:
// e2e/authenticated.spec.ts
import { test, expect } from '@playwright/test';
// These tests will run as an authenticated user because of the 'authenticated-user' project config
test.describe('Authenticated User Features', () => {
test.beforeEach(async ({ page }) => {
// No explicit login needed, the context is already loaded with storageState
await page.goto('/'); // Start at a protected route, it should load directly
});
test('should display welcome message for authenticated user', async ({ page }) => {
// Assuming app.component.html shows a welcome message for logged-in users
await expect(page.locator('text=Welcome, testuser!')).toBeVisible();
await expect(page.getByTestId('nav-products')).toBeVisible();
});
test('should access protected profile page', async ({ page }) => {
await page.getByTestId('nav-profile').click(); // Assuming a profile link
await expect(page).toHaveURL('/profile');
await expect(page.getByTestId('profile-username')).toHaveText('testuser');
});
test('should not access admin dashboard', async ({ page }) => {
await page.goto('/admin-dashboard'); // Try to access admin dashboard
// Expect redirection or an unauthorized message
await expect(page).toHaveURL(/.*(unauthorized|home)/); // Redirect to home or unauthorized
await expect(page.locator('h2', { hasText: 'Admin Dashboard' })).not.toBeVisible();
});
});
e2e/admin.spec.ts:
// e2e/admin.spec.ts
import { test, expect } from '@playwright/test';
// These tests will run as an authenticated admin user
test.describe('Authenticated Admin Features', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/admin-dashboard'); // Start at admin protected route
});
test('should display admin dashboard for admin user', async ({ page }) => {
await expect(page.locator('h2')).toHaveText('Admin Dashboard');
await expect(page.locator('text=Welcome, Admin User!')).toBeVisible();
});
test('should access admin settings page', async ({ page }) => {
await page.goto('/admin-settings'); // Assuming an admin settings route
await expect(page.locator('h2')).toHaveText('Admin Settings');
});
});
5. Running the Tests:
# Run setup files to generate .auth/user.json and .auth/admin.json
npx playwright test --project=setup-user --project=setup-admin
# Then run the authenticated tests
npx playwright test --project=authenticated-user --project=authenticated-admin
Or, if you define dependencies in playwright.config.ts correctly, running npx playwright test will trigger the setup jobs first.
Best Practices for Authentication Testing:
- Separate Setup: Always put authentication logic in a
globalSetupfile (e.g.,auth.setup.ts) to run it once. - Isolated State: Use
storageStateto load an authenticated browser context. This preserves cookies,localStorage, andsessionStorage. - Per-User State: If you need different users (admin, regular user), create separate
auth.setup.tsfiles or logic to generate and save multiplestorageStatefiles. - Verify State: After loading
storageState, add a quick assertion (e.g., check for a “Welcome, [username]” message) to ensure the authentication is still valid. - Cleanup: In CI, ensure the
.authdirectory and any generated user data in external systems (if applicable) are cleaned up. - API-Driven Authentication: For complex authentication, it’s often more robust and faster to use Playwright’s
APIRequestContextto directly interact with authentication APIs (e.g.,POST /api/login) to get tokens, and then inject them intolocalStorageusingpage.evaluate()rather than simulating UI interactions.
Exercise 9.1.1: Test Token Expiration/Refresh
Consider an Angular application where JWT tokens expire, and a refresh token mechanism is in place.
- Conceptual Angular Update: Imagine your Angular app has an interceptor that detects 401 Unauthorized errors from an API, uses a refresh token to get a new access token, and retries the original request.
- Playwright Test: Create a test in
e2e/auth-refresh.spec.tsthat:- Logs in a user and saves
storageState. - Loads this
storageState. - Mocks an API endpoint to initially return a 401 Unauthorized error (simulating an expired token).
- Then, after the refresh logic (which Playwright observes), mocks the same API endpoint to return a 200 OK with valid data.
- Asserts that the application successfully recovers and displays the expected data.
- Logs in a user and saves
e2e/auth-refresh.spec.ts:
// e2e/auth-refresh.spec.ts
import { test, expect } from '@playwright/test';
import path from 'path';
const userAuthFile = path.join(__dirname, '.auth/user.json');
test.describe('JWT Token Refresh Mechanism', () => {
// This test will use the pre-saved authenticated state
test.use({ storageState: userAuthFile });
test('should gracefully handle expired token and refresh', async ({ page }) => {
// 1. Initially load a protected page
await page.goto('/protected-data'); // Assume a route that fetches data
// 2. Mock the data API to return a 401 Unauthorized for the first attempt
// This simulates an expired token that the Angular interceptor would catch
await page.route('**/api/data', (route) => {
route.fulfill({ status: 401, body: 'Unauthorized - Token Expired' });
}, { times: 1 }); // Only intercept the first time
// 3. Mock the refresh token API (if separate) to return a new token
// Assuming Angular handles this in the background, Playwright doesn't interact directly
// If you had a separate /api/refresh endpoint, you'd mock it here:
// await page.route('**/api/refresh', route => route.fulfill({ status: 200, body: JSON.stringify({ newToken: 'new-jwt-token' }) }));
// 4. Then, the original /api/data call should be retried and succeed
// Mock the subsequent data API call to succeed
await page.route('**/api/data', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ message: 'Data fetched successfully after refresh!', data: [1, 2, 3] }),
});
});
// Navigate to the protected page, trigger the data fetch (which will fail then refresh then succeed)
// The initial page.goto might already trigger a fetch.
await page.goto('/protected-data');
// Wait for the successful data response (indicating refresh worked)
const successResponse = await page.waitForResponse(response =>
response.url().includes('/api/data') && response.status() === 200 && response.request().method() === 'GET'
);
const responseBody = await successResponse.json();
await expect(responseBody.message).toEqual('Data fetched successfully after refresh!');
await expect(page.locator('text=Data fetched successfully after refresh!')).toBeVisible();
// You could also assert that the new token is stored in localStorage
const newToken = await page.evaluate(() => localStorage.getItem('authToken'));
expect(newToken).not.toBeNull();
// Potentially expect it to be different from the old one, but that's harder without knowing old token
});
});
To run:
- Ensure your
setup-userproject has run to createe2e/.auth/user.json. - Then:
npx playwright test --project=authenticated-user e2e/auth-refresh.spec.ts
This test primarily focuses on observing the network behavior and application’s reaction. The actual token refresh logic and storage updates are assumed to be handled by your Angular application’s service/interceptor, which Playwright passively observes via network requests and DOM changes.