Welcome, aspiring test automation engineers and Angular developers!
This document is your complete, hands-on guide to mastering Playwright for End-to-End (E2E) testing in Angular applications. Whether you’re entirely new to testing or looking to switch from other frameworks, this guide will take you from zero to hero with practical examples, detailed explanations, and engaging exercises.
E2E testing is crucial for ensuring your Angular applications deliver a consistent and reliable user experience. It simulates real user interactions across your entire application, from the user interface down to backend services, making sure everything works together as intended. Playwright, developed by Microsoft, has emerged as a powerful, modern, and highly capable tool for this purpose, offering speed, reliability, and cross-browser compatibility.
We’ll prioritize doing over just reading. Expect plenty of code, step-by-step instructions, and opportunities to apply what you’ve learned immediately. Let’s get started on building robust and reliable Angular applications with Playwright!
1. Introduction to Playwright with Angular
What is Playwright?
Playwright is an open-source Node.js library developed by Microsoft that provides a powerful and reliable solution for automating web browsers. It allows you to write scripts that interact with web pages in the same way a real user would: clicking buttons, filling forms, navigating between pages, and verifying content.
What sets Playwright apart is its ability to:
- Run across all modern browsers: Chromium (Chrome, Edge), Firefox, and WebKit (Safari).
- Support multiple languages: TypeScript, JavaScript, Python, .NET, and Java. We’ll be focusing on TypeScript, which is a natural fit for Angular.
- Be resilient: It has built-in auto-waiting mechanisms and web-first assertions, minimizing flaky tests due to timing issues.
- Offer powerful tooling: Includes features like code generation, an interactive inspector, and a trace viewer for efficient debugging.
- Provide isolated environments: Each test runs in a fresh browser context, ensuring no shared state between tests.
Why Learn Playwright for Angular E2E Testing?
For Angular developers, Playwright offers significant advantages for E2E testing:
- Ensuring User Experience: Angular applications are often complex, single-page applications (SPAs) with dynamic content. Playwright simulates real user flows, ensuring that your application functions correctly and provides a smooth experience from start to finish.
- Cross-Browser and Cross-Platform Compatibility: With Playwright, you can easily test your Angular app across different browsers (Chrome, Firefox, Safari) and platforms (Windows, macOS, Linux, even mobile emulation) with a single test suite. This is vital for reaching a broad audience.
- Speed and Reliability: Playwright is designed for speed. Its auto-waiting capabilities reduce flakiness, which is common in E2E tests when dealing with asynchronous Angular component rendering and API calls. Parallel test execution further speeds up your CI/CD pipelines.
- Modern Architecture: Unlike some older testing tools, Playwright was built for modern web applications. It handles complex scenarios like Shadow DOM, multiple tabs, and network interception effortlessly, which are often encountered in Angular apps.
- TypeScript Integration: As Angular is built with TypeScript, Playwright’s strong TypeScript support makes for a cohesive and type-safe testing experience, improving code quality and developer productivity.
- Industry Relevance: Playwright is rapidly gaining traction in the industry. Companies like GitHub and Shopify use it, making it a valuable skill for any frontend developer.
Setting Up Your Development Environment
To follow along with this guide, you’ll need the following:
Prerequisites:
- Node.js (version 16 or higher): You can download it from nodejs.org. Verify your installation by running:
node -v npm -v - Angular CLI: If you don’t have it, install it globally:Verify by running:
npm install -g @angular/cling version - A Code Editor: Visual Studio Code is highly recommended due to its excellent TypeScript and Playwright extensions.
Step-by-Step Setup:
Let’s create a new Angular project and integrate Playwright.
1. Create a New Angular Application:
If you already have an Angular project, you can skip this step. Otherwise, let’s create a minimal one.
ng new playwright-angular-app --no-standalone --routing --style=css
cd playwright-angular-app
When prompted, choose “No” for “Do you want to enable Google Analytics?”. For routing, choose “Yes”. For stylesheet format, choose “CSS”.
2. Serve Your Angular Application:
Before installing Playwright, ensure your Angular application can run.
ng serve --open
This command will compile your Angular application and open it in your default browser, usually at http://localhost:4200. Keep this running in a separate terminal tab or window as you’ll need the server active for Playwright to interact with it.
3. Install Playwright:
Now, let’s add Playwright to your Angular project. Playwright provides a convenient CLI tool for installation.
npm init playwright@latest
You will be prompted with a few questions. For this guide, use the following:
- Do you want to use TypeScript or JavaScript?
TypeScript - Where to put your end-to-end tests?
e2e(or accept the default,tests) - Add a GitHub Actions workflow?
false(We won’t cover CI/CD in detail here) - Install Playwright browsers (Chromium, Firefox, WebKit)?
true
This command will:
- Install
@playwright/testand its dependencies. - Download browser binaries (Chromium, Firefox, WebKit).
- Create a
playwright.config.tsfile in your project root. - Create an
e2e(ortests) directory with an example test file.
4. Configure playwright.config.ts for Angular:
Open the playwright.config.ts file. We need to tell Playwright to start our Angular development server before running tests. Locate the webServer property and configure it as follows:
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e', // Ensure this matches your test directory (e.g., 'e2e' or 'tests')
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:4200', // Our Angular app runs here
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run start', // Command to start your Angular dev server
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI, // Reuse if not in CI, for faster local development
},
});
Note: The command for webServer should match the script that starts your Angular application. By default, ng new creates a start script in package.json that runs ng serve. If you have a different setup (e.g., using nx serve), adjust command accordingly. The reuseExistingServer flag is excellent for local development, as it won’t restart your Angular app for every test run.
5. Run Your First Playwright Test:
With everything set up, run the example test that Playwright generated.
npx playwright test
You should see output indicating that tests are running and passing across Chromium, Firefox, and WebKit.
If you wish to run tests on a specific browser, you can do:
npx playwright test --project=chromium
Congratulations! You’ve successfully set up Playwright with your Angular application. You’re now ready to dive into the core concepts of writing powerful E2E tests.
2. Core Concepts and Fundamentals
In this section, we’ll break down the fundamental building blocks of Playwright testing. Each concept will be explained thoroughly, accompanied by practical code examples, and followed by exercises to solidify your understanding.
Let’s assume we have a simple Angular application with the following components and routes:
- A homepage at
/ - A “Products” page at
/productswith a list of items. - A “Contact Us” page at
/contactwith a simple form.
For our examples, we’ll modify the default Angular app.component.html to include some navigation and a simple “counter” component.
First, let’s update src/app/app.component.html:
<nav>
<a routerLink="/" data-test-id="nav-home">Home</a> |
<a routerLink="/products" data-test-id="nav-products">Products</a> |
<a routerLink="/contact" data-test-id="nav-contact">Contact</a>
</nav>
<h1>Welcome to Playwright Angular App!</h1>
<router-outlet></router-outlet>
Next, create the HomeComponent, ProductsComponent, and ContactComponent (if you didn’t create them with ng new or similar).
ng generate component home
ng generate component products
ng generate component contact
Now, configure your src/app/app.routes.ts (or src/app/app-routing.module.ts if not using standalone components) to include these routes:
import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { ProductsComponent } from './products/products.component';
import { ContactComponent } from './contact/contact.component';
export const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'products', component: ProductsComponent },
{ path: 'contact', component: ContactComponent },
{ path: '**', redirectTo: '' } // Redirect to home for unknown routes
];
And update src/app/app.component.ts to import RouterModule:
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet, RouterModule } from '@angular/router'; // Import RouterModule
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet, RouterModule], // Add RouterModule
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent {
title = 'playwright-angular-app';
}
Now let’s add some basic content to each component’s HTML:
src/app/home/home.component.html:
<p>home works!</p>
<div class="welcome-message">
This is the homepage of our Playwright Angular application.
</div>
<button data-test-id="increment-button">Increment Counter</button>
<p>Counter: <span data-test-id="counter-value">0</span></p>
src/app/home/home.component.ts:
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-home',
standalone: true,
imports: [CommonModule],
templateUrl: './home.component.html',
styleUrl: './home.component.css'
})
export class HomeComponent {
counter: number = 0;
increment() {
this.counter++;
}
}
src/app/products/products.component.html:
<p>products works!</p>
<h2>Our Products</h2>
<ul>
<li data-test-id="product-item-1">Laptop</li>
<li data-test-id="product-item-2">Mouse</li>
<li data-test-id="product-item-3">Keyboard</li>
</ul>
<button data-test-id="add-to-cart-button">Add First Product to Cart</button>
src/app/contact/contact.component.html:
<p>contact works!</p>
<h2>Contact Us</h2>
<form data-test-id="contact-form">
<label for="name">Name:</label>
<input type="text" id="name" data-test-id="contact-name-input"><br><br>
<label for="email">Email:</label>
<input type="email" id="email" data-test-id="contact-email-input"><br><br>
<label for="message">Message:</label>
<textarea id="message" data-test-id="contact-message-textarea"></textarea><br><br>
<button type="submit" data-test-id="contact-submit-button">Send Message</button>
</form>
<div class="submission-feedback" style="display: none;">
Thank you for your message!
</div>
With these basic components and routes, we have a simple Angular application ready for testing. Remember to keep ng serve running in the background!
2.1. Writing Your First Test: Page Navigation and Basic Assertions
Every Playwright test starts with test and uses the page object to interact with the browser.
testfunction: Defines a test block.pagefixture: Represents a single tab or window in the browser. It’s the primary object you’ll use to navigate, interact with elements, and take screenshots.expectfunction: Used for making assertions about the state of the page or elements. Playwright’sexpectcomes with built-in auto-waiting, making tests less flaky.
Let’s write a test to navigate to our homepage and verify its title and some content.
Code Example (e2e/home.spec.ts):
Create a new file e2e/home.spec.ts and add the following content, replacing or deleting the default example file if you wish.
import { test, expect } from '@playwright/test';
test.describe('Home Page Tests', () => {
// Before each test in this describe block, navigate to the base URL
test.beforeEach(async ({ page }) => {
await page.goto('/'); // Navigates to the baseURL configured in playwright.config.ts
});
test('should have the correct title', async ({ page }) => {
// Assert that the page title contains 'PlaywrightAngularApp'
await expect(page).toHaveTitle(/PlaywrightAngularApp/);
});
test('should display a welcome message', async ({ page }) => {
// Assert that an element with the text 'Welcome to Playwright Angular App!' is visible
const welcomeHeading = page.locator('h1', { hasText: 'Welcome to Playwright Angular App!' });
await expect(welcomeHeading).toBeVisible();
// Assert that a specific message is visible within a div with class 'welcome-message'
const welcomeMessage = page.locator('.welcome-message');
await expect(welcomeMessage).toHaveText('This is the homepage of our Playwright Angular application.');
await expect(welcomeMessage).toBeVisible();
});
test('should navigate to Products page', async ({ page }) => {
// Click the navigation link for Products using its data-test-id
await page.locator('[data-test-id="nav-products"]').click();
// Assert that the URL has changed to /products
await expect(page).toHaveURL('/products');
// Assert that the products page content is visible
await expect(page.locator('h2', { hasText: 'Our Products' })).toBeVisible();
});
test('should navigate to Contact page', async ({ page }) => {
// Click the navigation link for Contact using its data-test-id
await page.locator('[data-test-id="nav-contact"]').click();
// Assert that the URL has changed to /contact
await expect(page).toHaveURL('/contact');
// Assert that the contact page content is visible
await expect(page.locator('h2', { hasText: 'Contact Us' })).toBeVisible();
});
});
Explanation:
test.describe('Home Page Tests', () => { ... });groups related tests, making your test suite more organized.test.beforeEach(async ({ page }) => { await page.goto('/'); });is a hook that runs before each test within thisdescribeblock. It navigates the page to the base URL, ensuring a clean state for every test.await page.goto('/'): Navigates to the base URL (which ishttp://localhost:4200as configured inplaywright.config.ts).await expect(page).toHaveTitle(/PlaywrightAngularApp/);: Asserts that the page’s title contains the regular expressionPlaywrightAngularApp. Playwright waits for the title to match before failing.page.locator('h1', { hasText: '...' }): This is a locator strategy. We’ll dive deeper into locators next, but here it finds an<h1>element that contains specific text.await expect(element).toBeVisible();: Asserts that the element is visible on the page. Playwright will automatically wait for it to become visible.await expect(element).toHaveText('...');: Asserts that the element’s text content matches the provided string.
Run the tests:
npx playwright test e2e/home.spec.ts
You should see all tests passing!
Exercise 2.1.1: Verify Initial Counter Value
Modify e2e/home.spec.ts to add a new test that verifies the initial value of the counter on the homepage.
- Locate the
spanelement withdata-test-id="counter-value". - Assert that its text content is
'0'.
import { test, expect } from '@playwright/test';
test.describe('Home Page Tests', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
// ... existing tests ...
test('should display initial counter value as 0', async ({ page }) => {
const counterValue = page.locator('[data-test-id="counter-value"]');
await expect(counterValue).toHaveText('0');
});
});
2.2. Locators: Finding Elements on the Page
Locators are the heart of Playwright testing. They are used to find elements on the page that you want to interact with or assert against. Playwright encourages using “user-facing” locators that are less prone to breaking when the UI changes, mimicking how a user would naturally identify elements.
Playwright provides several powerful built-in locators:
page.getByRole(): Locates elements by their ARIA role, accessibility attributes, and optional name. This is the recommended way to locate elements. It promotes accessible web design.- Examples:
page.getByRole('button', { name: 'Submit' }),page.getByRole('textbox', { name: 'Email' }).
- Examples:
page.getByText(): Locates elements by their text content.- Example:
page.getByText('Log In').
- Example:
page.getByLabel(): Locates input elements by their associated label.- Example:
page.getByLabel('Username').
- Example:
page.getByPlaceholder(): Locates input elements by their placeholder text.- Example:
page.getByPlaceholder('Enter your password').
- Example:
page.getByAltText(): Locates images by their alt text.- Example:
page.getByAltText('Company Logo').
- Example:
page.getByTitle(): Locates elements by their title attribute.- Example:
page.getByTitle('Settings').
- Example:
page.getByTestId(): Locates elements by theirdata-test-idattribute. This is excellent for internal testing and when a user-facing locator isn’t feasible or clear. We’ve used this in our Angular app.- Example:
page.getByTestId('login-button').
- Example:
page.locator(): A more general-purpose locator that accepts CSS selectors, XPath, or text. Use this when the othergetBy*methods aren’t sufficient.- Examples:
page.locator('#my-id'),page.locator('.my-class'),page.locator('div:has-text("Important Info")').
- Examples:
Why prioritize getByRole and other user-facing locators?
They make your tests more robust. If a button’s CSS class changes, a page.locator('.my-button') test will break. But page.getByRole('button', { name: 'Submit' }) will continue to work as long as the button still functions as a “Submit” button for the user. data-test-id is a good fallback when user-facing locators are not practical.
Code Example (e2e/locators.spec.ts):
Let’s create a new test file to practice different locators on our Angular contact and product pages.
src/app/contact/contact.component.html (re-showing for convenience):
<h2>Contact Us</h2>
<form data-test-id="contact-form">
<label for="name">Name:</label>
<input type="text" id="name" data-test-id="contact-name-input"><br><br>
<label for="email">Email:</label>
<input type="email" id="email" data-test-id="contact-email-input"><br><br>
<label for="message">Message:</label>
<textarea id="message" data-test-id="contact-message-textarea"></textarea><br><br>
<button type="submit" data-test-id="contact-submit-button">Send Message</button>
</form>
<div class="submission-feedback" style="display: none;">
Thank you for your message!
</div>
src/app/products/products.component.html (re-showing for convenience):
<h2>Our Products</h2>
<ul>
<li data-test-id="product-item-1">Laptop</li>
<li data-test-id="product-item-2">Mouse</li>
<li data-test-id="product-item-3">Keyboard</li>
</ul>
<button data-test-id="add-to-cart-button">Add First Product to Cart</button>
import { test, expect } from '@playwright/test';
test.describe('Locator Practice', () => {
test.beforeEach(async ({ page }) => {
// We'll start at the contact page for these tests
await page.goto('/contact');
});
test('should find elements on Contact page using various locators', async ({ page }) => {
// 1. Get by Role (recommended for accessibility)
const nameInputByRole = page.getByRole('textbox', { name: 'Name' });
await expect(nameInputByRole).toBeVisible();
await nameInputByRole.fill('John Doe'); // Fill the input
const emailInputByRole = page.getByRole('textbox', { name: 'Email' });
await expect(emailInputByRole).toBeVisible();
await emailInputByRole.fill('john.doe@example.com');
const messageTextareaByRole = page.getByRole('textbox', { name: 'Message' });
await expect(messageTextareaByRole).toBeVisible();
await messageTextareaByRole.fill('Hello, I have a question.');
const submitButtonByRole = page.getByRole('button', { name: 'Send Message' });
await expect(submitButtonByRole).toBeVisible();
// 2. Get by Label (good for inputs with associated labels)
const nameInputByLabel = page.getByLabel('Name');
await expect(nameInputByLabel).toBeVisible();
// 3. Get by Test ID (explicit for testing, less prone to incidental changes)
const nameInputByTestId = page.getByTestId('contact-name-input');
await expect(nameInputByTestId).toBeVisible();
await expect(nameInputByTestId).toHaveValue('John Doe'); // Verify value from previous fill
const submitButtonByTestId = page.getByTestId('contact-submit-button');
await expect(submitButtonByTestId).toBeVisible();
// 4. General Locator (CSS selector - use when more specific locators are not available)
const h2Heading = page.locator('h2'); // Finds the first h2
await expect(h2Heading).toHaveText('Contact Us');
const formElement = page.locator('form[data-test-id="contact-form"]'); // CSS selector combining tag and attribute
await expect(formElement).toBeVisible();
});
test('should find elements on Products page using locators', async ({ page }) => {
await page.goto('/products'); // Navigate to products page for this test
// Get by Text
const laptopItem = page.getByText('Laptop');
await expect(laptopItem).toBeVisible();
// Get by Test ID
const mouseItem = page.getByTestId('product-item-2');
await expect(mouseItem).toHaveText('Mouse');
// Get by Role for the "Add to Cart" button
const addToCartButton = page.getByRole('button', { name: 'Add First Product to Cart' });
await expect(addToCartButton).toBeVisible();
await addToCartButton.click(); // Perform an action
});
});
Run the tests:
npx playwright test e2e/locators.spec.ts
Exercise 2.2.1: Locate and Interact with the Homepage Counter
In e2e/home.spec.ts (or a new e2e/counter.spec.ts), write a test that:
- Navigates to the home page.
- Locates the “Increment Counter” button using
getByRole. - Locates the counter value using
getByTestId. - Clicks the “Increment Counter” button three times.
- Asserts that the counter value is now
'3'.
// e2e/counter.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Counter Component Tests', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('should increment the counter when button is clicked', async ({ page }) => {
const incrementButton = page.getByRole('button', { name: 'Increment Counter' });
const counterValue = page.getByTestId('counter-value');
// Initial check (from Exercise 2.1.1)
await expect(counterValue).toHaveText('0');
// Click the button three times
await incrementButton.click();
await incrementButton.click();
await incrementButton.click();
// Assert the new value
await expect(counterValue).toHaveText('3');
});
});
2.3. Actions: Interacting with Elements
Once you’ve located an element, you’ll want to interact with it. Playwright provides a rich API for simulating user actions. All actions automatically wait for the element to be “actionable” (e.g., visible, enabled, not covered by another element), reducing test flakiness.
Common actions include:
element.click(): Clicks on an element.element.fill(value): Fills an input or textarea with a givenvalue.element.press(key): Simulates a keyboard press (e.g.,'Enter','Tab').element.check()/element.uncheck(): Checks/unchecks a checkbox or radio button.element.selectOption(value): Selects an option in a<select>element.page.hover(): Hovers over an element.page.waitForLoadState(): Waits for the page to reach a certain load state (e.g.,'networkidle','domcontentloaded','load').page.screenshot(): Takes a screenshot of the page.
Code Example (e2e/form.spec.ts):
Let’s test our contact form: filling it out and simulating submission.
src/app/contact/contact.component.html (add this JavaScript to the end of the contact.component.html file, inside a <script> tag for simplicity, or ideally, handle submission in contact.component.ts):
<!-- ... existing HTML for contact form ... -->
<script>
// Simple client-side submission for demonstration
document.querySelector('[data-test-id="contact-form"]').addEventListener('submit', function(event) {
event.preventDefault();
const feedbackDiv = document.querySelector('.submission-feedback');
feedbackDiv.style.display = 'block';
// Simulate a short delay before hiding (optional, but realistic)
setTimeout(() => {
// feedbackDiv.style.display = 'none';
// Optionally clear form
// event.target.reset();
}, 2000);
});
</script>
Note: For a real Angular application, you would handle the form submission and display the feedback within the Angular component’s TypeScript code. The above script tag is a quick hack for this demonstration without diving into Angular form handling details. In a real Angular app, Playwright would interact with the Angular-driven state changes.
import { test, expect } from '@playwright/test';
test.describe('Contact Form Interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/contact');
});
test('should fill out the contact form and show submission feedback', async ({ page }) => {
// 1. Locate and fill the Name input
const nameInput = page.getByTestId('contact-name-input');
await expect(nameInput).toBeVisible();
await nameInput.fill('Alice Smith');
await expect(nameInput).toHaveValue('Alice Smith');
// 2. Locate and fill the Email input using getByLabel
const emailInput = page.getByLabel('Email');
await expect(emailInput).toBeVisible();
await emailInput.fill('alice.smith@example.com');
await expect(emailInput).toHaveValue('alice.smith@example.com');
// 3. Locate and fill the Message textarea using getByRole
const messageTextarea = page.getByRole('textbox', { name: 'Message' });
await expect(messageTextarea).toBeVisible();
await messageTextarea.fill('I have a question about your products.');
await expect(messageTextarea).toHaveValue('I have a question about your products.');
// 4. Click the Send Message button
const submitButton = page.getByRole('button', { name: 'Send Message' });
await expect(submitButton).toBeVisible();
await submitButton.click();
// 5. Assert that the submission feedback message is visible
const feedbackMessage = page.locator('.submission-feedback');
await expect(feedbackMessage).toBeVisible();
await expect(feedbackMessage).toHaveText('Thank you for your message!');
// Optional: Take a screenshot after submission
await page.screenshot({ path: 'test-results/contact-form-submitted.png' });
});
test('should prevent submission with empty name field (example validation)', async ({ page }) => {
// We assume there's some browser-level or Angular validation
// For this example, we'll just check if the feedback doesn't show up immediately
const emailInput = page.getByLabel('Email');
await emailInput.fill('test@example.com');
const submitButton = page.getByRole('button', { name: 'Send Message' });
await submitButton.click();
const feedbackMessage = page.locator('.submission-feedback');
// We expect the feedback not to be visible immediately if validation prevents it
// In a real Angular app, you'd check for validation messages
await expect(feedbackMessage).not.toBeVisible();
});
});
Run the tests:
npx playwright test e2e/form.spec.ts
Check your test-results folder for contact-form-submitted.png.
Exercise 2.3.1: Add Product to Cart
On the products page, locate the “Add First Product to Cart” button and simulate clicking it. For this exercise, we won’t have a visual “cart” to verify, but you can assert that the button itself is still visible (or perhaps disabled if it were a single-item cart).
- Create a new test file
e2e/products.spec.ts. - Navigate to the
/productspage. - Locate the “Add First Product to Cart” button using
getByRole. - Click the button.
- (Optional but good practice) Add an assertion related to the button’s state or a non-existent success message.
// e2e/products.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Products Page Interactions', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/products');
});
test('should click the "Add First Product to Cart" button', async ({ page }) => {
const addToCartButton = page.getByRole('button', { name: 'Add First Product to Cart' });
await expect(addToCartButton).toBeVisible();
await addToCartButton.click();
// As our app doesn't have a visible cart yet, we can simply assert the button
// is still visible, or if the app logic changed, perhaps that it's now disabled.
// For now, let's just confirm it's still there after the click (or that no error occurred).
await expect(addToCartButton).toBeVisible();
// If there were a notification, we'd assert it here:
// await expect(page.getByText('Product added to cart!')).toBeVisible();
});
});
3. Intermediate Topics
Now that you have a solid grasp of Playwright fundamentals, let’s explore more advanced features that will make your tests more robust, maintainable, and efficient, especially in a dynamic Angular environment.
3.1. Page Object Model (POM)
As your application and test suite grow, simply putting all your test logic into .spec.ts files becomes unmanageable. The Page Object Model (POM) is a design pattern that helps organize your tests by abstracting page interactions into dedicated classes called “Page Objects”.
Benefits of POM:
- Reusability: Common interactions (like logging in, navigating menus) are encapsulated and can be reused across multiple tests.
- Maintainability: If the UI changes (e.g., a selector changes), you only need to update the selector in one place (the Page Object), not in every test file that uses that element.
- Readability: Tests become cleaner and more focused on the user flow, as the details of finding elements are hidden within the Page Object.
A Page Object typically:
- Represents a significant part of your application’s UI (e.g., a page, a component, a modal).
- Contains locators for elements on that UI part.
- Contains methods that represent user interactions with that UI part.
Code Example (Refactoring with POM):
Let’s refactor our home.spec.ts and contact.spec.ts tests using Page Objects.
1. Create a e2e/pages directory.
2. Create e2e/pages/home.page.ts:
import { Page, expect, Locator } from '@playwright/test';
export class HomePage {
readonly page: Page;
readonly welcomeHeading: Locator;
readonly welcomeMessageDiv: Locator;
readonly navProductsLink: Locator;
readonly navContactLink: Locator;
readonly incrementButton: Locator;
readonly counterValue: Locator;
constructor(page: Page) {
this.page = page;
this.welcomeHeading = page.locator('h1', { hasText: 'Welcome to Playwright Angular App!' });
this.welcomeMessageDiv = page.locator('.welcome-message');
this.navProductsLink = page.getByTestId('nav-products');
this.navContactLink = page.getByTestId('nav-contact');
this.incrementButton = page.getByRole('button', { name: 'Increment Counter' });
this.counterValue = page.getByTestId('counter-value');
}
async goto() {
await this.page.goto('/');
}
async getTitle() {
return this.page.title();
}
async incrementCounter() {
await this.incrementButton.click();
}
async getCounterValue() {
return this.counterValue.textContent();
}
async navigateToProducts() {
await this.navProductsLink.click();
}
async navigateToContact() {
await this.navContactLink.click();
}
}
3. Create e2e/pages/contact.page.ts:
import { Page, expect, Locator } from '@playwright/test';
export class ContactPage {
readonly page: Page;
readonly heading: Locator;
readonly nameInput: Locator;
readonly emailInput: Locator;
readonly messageTextarea: Locator;
readonly submitButton: Locator;
readonly submissionFeedback: Locator;
constructor(page: Page) {
this.page = page;
this.heading = page.locator('h2', { hasText: 'Contact Us' });
this.nameInput = page.getByLabel('Name'); // Using getByLabel
this.emailInput = page.getByTestId('contact-email-input'); // Using getByTestId
this.messageTextarea = page.getByRole('textbox', { name: 'Message' }); // Using getByRole
this.submitButton = page.getByRole('button', { name: 'Send Message' });
this.submissionFeedback = page.locator('.submission-feedback');
}
async goto() {
await this.page.goto('/contact');
}
async fillForm(name: string, email: string, message: string) {
await this.nameInput.fill(name);
await this.emailInput.fill(email);
await this.messageTextarea.fill(message);
}
async submitForm() {
await this.submitButton.click();
}
async getSubmissionFeedbackText() {
return this.submissionFeedback.textContent();
}
}
4. Update e2e/home.spec.ts to use HomePage:
import { test, expect } from '@playwright/test';
import { HomePage } from './pages/home.page'; // Import the Page Object
test.describe('Home Page Tests (with POM)', () => {
let homePage: HomePage; // Declare an instance of HomePage
test.beforeEach(async ({ page }) => {
homePage = new HomePage(page); // Initialize HomePage
await homePage.goto();
});
test('should have the correct title', async () => {
await expect(homePage.page).toHaveTitle(/PlaywrightAngularApp/); // Access page through homePage.page
});
test('should display a welcome message', async () => {
await expect(homePage.welcomeHeading).toBeVisible();
await expect(homePage.welcomeMessageDiv).toHaveText('This is the homepage of our Playwright Angular application.');
await expect(homePage.welcomeMessageDiv).toBeVisible();
});
test('should navigate to Products page', async ({ page }) => {
await homePage.navigateToProducts();
await expect(page).toHaveURL('/products'); // Verify URL using page object
});
test('should increment the counter when button is clicked', async () => {
await expect(homePage.counterValue).toHaveText('0');
await homePage.incrementCounter();
await homePage.incrementCounter();
await expect(homePage.counterValue).toHaveText('2');
});
});
5. Update e2e/form.spec.ts to use ContactPage:
import { test, expect } from '@playwright/test';
import { ContactPage } from './pages/contact.page'; // Import the Page Object
test.describe('Contact Form Interaction (with POM)', () => {
let contactPage: ContactPage;
test.beforeEach(async ({ page }) => {
contactPage = new ContactPage(page);
await contactPage.goto();
});
test('should fill out the contact form and show submission feedback', async () => {
const testName = 'Bob Johnson';
const testEmail = 'bob.johnson@example.com';
const testMessage = 'Hello, this is a test message.';
await contactPage.fillForm(testName, testEmail, testMessage);
await contactPage.submitForm();
await expect(contactPage.submissionFeedback).toBeVisible();
await expect(contactPage.submissionFeedback).toHaveText('Thank you for your message!');
await contactPage.page.screenshot({ path: 'test-results/contact-form-submitted-pom.png' });
});
});
Run the refactored tests:
npx playwright test e2e/home.spec.ts e2e/form.spec.ts
All tests should still pass, but now they are much cleaner and more maintainable!
Exercise 3.1.1: Create a ProductPage Object
Create a new Page Object e2e/pages/products.page.ts for the /products page.
- Include locators for the
h2heading and the “Add First Product to Cart” button. - Add methods for
goto()andclickAddToCart(). - Update
e2e/products.spec.tsto use this newProductPageobject.
e2e/pages/products.page.ts:
import { Page, Locator } from '@playwright/test';
export class ProductsPage {
readonly page: Page;
readonly heading: Locator;
readonly addToCartButton: Locator;
readonly productItemLaptop: Locator; // Example specific product locator
constructor(page: Page) {
this.page = page;
this.heading = page.locator('h2', { hasText: 'Our Products' });
this.addToCartButton = page.getByRole('button', { name: 'Add First Product to Cart' });
this.productItemLaptop = page.getByText('Laptop');
}
async goto() {
await this.page.goto('/products');
}
async clickAddToCart() {
await this.addToCartButton.click();
}
}
e2e/products.spec.ts (updated):
import { test, expect } from '@playwright/test';
import { ProductsPage } from './pages/products.page';
test.describe('Products Page Interactions (with POM)', () => {
let productsPage: ProductsPage;
test.beforeEach(async ({ page }) => {
productsPage = new ProductsPage(page);
await productsPage.goto();
});
test('should display product list heading', async () => {
await expect(productsPage.heading).toBeVisible();
});
test('should click the "Add First Product to Cart" button', async () => {
await expect(productsPage.addToCartButton).toBeVisible();
await productsPage.clickAddToCart();
// Assert that the button is still visible or its state changed
await expect(productsPage.addToCartButton).toBeVisible();
});
test('should display laptop product item', async () => {
await expect(productsPage.productItemLaptop).toBeVisible();
});
});
3.2. Auto-Waiting and Assertions
Playwright’s auto-waiting mechanism is one of its most powerful features, significantly reducing test flakiness. When you perform an action (like click(), fill()) or an assertion (like toBeVisible()), Playwright automatically waits for the element to be ready for that action/assertion.
What Playwright Waits For (Implicitly):
- Actions: Before clicking, Playwright waits for the element to be visible, enabled, stable (not animating), and not covered by other elements.
- Assertions: Assertions like
toBeVisible(),toHaveText(),toHaveURL()automatically retry for a default timeout (typically 5 seconds) until the condition is met.
This means you rarely need to add explicit waitForTimeout or other sleep commands, which are notorious for making tests slow and brittle.
Code Example (e2e/waiting.spec.ts):
Let’s imagine a scenario where clicking a button loads dynamic content after a short delay in our Angular app. We’ll simulate this in our home.component.ts and home.component.html.
Modify src/app/home/home.component.html:
<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()">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>
Modify src/app/home/home.component.ts:
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-home',
standalone: true,
imports: [CommonModule],
templateUrl: './home.component.html',
styleUrl: './home.component.css'
})
export class HomeComponent {
counter: number = 0;
dynamicDataLoaded: boolean = false;
dynamicData: string = '';
increment() {
this.counter++;
}
loadDynamicData() {
// Simulate an asynchronous operation (e.g., HTTP request)
setTimeout(() => {
this.dynamicData = 'This data was loaded after a delay!';
this.dynamicDataLoaded = true;
}, 1500); // 1.5 seconds delay
}
}
Now, create e2e/waiting.spec.ts to test this:
import { test, expect } from '@playwright/test';
import { HomePage } from './pages/home.page'; // Assuming HomePage has been updated for dynamic data
test.describe('Auto-Waiting and Dynamic Content', () => {
let homePage: HomePage;
// Update HomePage to include new locators and methods
// Add this to your e2e/pages/home.page.ts constructor:
// this.loadDataButton = page.getByTestId('load-data-button');
// this.dynamicDataContainer = page.getByTestId('dynamic-data-container');
// Add this method to HomePage:
// async loadDynamicData() { await this.loadDataButton.click(); }
// Let's create a temporary Page Object for this example to avoid modifying the previous one directly for now
class TempHomePage extends HomePage {
readonly loadDataButton: Locator;
readonly dynamicDataContainer: Locator;
constructor(page: Page) {
super(page);
this.loadDataButton = page.getByTestId('load-data-button');
this.dynamicDataContainer = page.getByTestId('dynamic-data-container');
}
async loadDynamicData() {
await this.loadDataButton.click();
}
}
test.beforeEach(async ({ page }) => {
homePage = new TempHomePage(page);
await homePage.goto();
});
test('should load dynamic data after clicking a button', async () => {
// Assert that the dynamic data container is NOT visible initially
await expect(homePage.dynamicDataContainer).not.toBeVisible();
// Click the button that triggers the async data load
await homePage.loadDynamicData();
// Playwright will automatically wait for the dynamicDataContainer to become visible
// and for its text to contain the expected value. No explicit waits needed!
await expect(homePage.dynamicDataContainer).toBeVisible();
await expect(homePage.dynamicDataContainer).toContainText('This data was loaded after a delay!');
// You can also assert the specific content directly
const dynamicDataParagraph = homePage.dynamicDataContainer.locator('p');
await expect(dynamicDataParagraph).toHaveText('This data was loaded after a delay!');
});
});
Run the test:
npx playwright test e2e/waiting.spec.ts
Notice how we didn’t add any await page.waitForTimeout(2000) calls. Playwright’s toBeVisible() and toContainText() assertions handle the waiting for us, making the test faster and more reliable even if the delay changes slightly.
Exercise 3.2.1: Verify Button State Change
Imagine after clicking “Load Dynamic Data”, the button itself becomes disabled to prevent multiple clicks. Modify home.component.ts and home.component.html to implement this, then update the waiting.spec.ts test to assert the button becomes disabled.
Modify src/app/home/home.component.html:
<!-- ... existing HTML ... -->
<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>
Modify src/app/home/home.component.ts:
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-home',
standalone: true,
imports: [CommonModule],
templateUrl: './home.component.html',
styleUrl: './home.component.css'
})
export class HomeComponent {
counter: number = 0;
dynamicDataLoaded: boolean = false;
dynamicData: string = '';
dynamicDataLoading: boolean = false; // New property
increment() {
this.counter++;
}
loadDynamicData() {
this.dynamicDataLoading = true; // Set to true when loading starts
setTimeout(() => {
this.dynamicData = 'This data was loaded after a delay!';
this.dynamicDataLoaded = true;
this.dynamicDataLoading = false; // Set to false when loading finishes
}, 1500);
}
}
Now, update e2e/waiting.spec.ts to assert the button’s disabled state.
import { test, expect, Page, Locator } from '@playwright/test';
import { HomePage } from './pages/home.page'; // Make sure HomePage has the new locators/methods or use TempHomePage
class TempHomePage extends HomePage { // Re-define if not modifying original HomePage
readonly loadDataButton: Locator;
readonly dynamicDataContainer: Locator;
constructor(page: Page) {
super(page);
this.loadDataButton = page.getByTestId('load-data-button');
this.dynamicDataContainer = page.getByTestId('dynamic-data-container');
}
async loadDynamicData() {
await this.loadDataButton.click();
}
}
test.describe('Auto-Waiting and Dynamic Content', () => {
let homePage: TempHomePage; // Use TempHomePage
test.beforeEach(async ({ page }) => {
homePage = new TempHomePage(page);
await homePage.goto();
});
test('should load dynamic data and disable the button', async () => {
await expect(homePage.dynamicDataContainer).not.toBeVisible();
await expect(homePage.loadDataButton).toBeEnabled(); // Initially enabled
await homePage.loadDynamicData();
await expect(homePage.dynamicDataContainer).toBeVisible();
await expect(homePage.dynamicDataContainer).toContainText('This data was loaded after a delay!');
// Assert that the button is now disabled
await expect(homePage.loadDataButton).toBeDisabled();
});
});
3.3. Fixtures: Reusable Test Setup
Playwright’s test runner comes with a powerful concept called “fixtures.” You’ve already used the built-in page fixture. Fixtures provide isolated, reusable test setups and teardowns. They allow you to define custom objects or states that tests can depend on, simplifying your test code and ensuring a consistent testing environment.
Common uses for fixtures:
- Authentication: Logging in once and reusing the authenticated state across multiple tests.
- Database setup: Seeding a database before tests run.
- Browser context configuration: Setting specific permissions or initial storage state.
We’ll focus on a simple authentication example that sets a localStorage item to simulate a logged-in user without actually going through a login form every time.
Code Example (Authentication Fixture):
Let’s assume our Angular app has a simple “logged-in” state indicated by an item in localStorage. We’ll modify app.component.ts to show a “Welcome User!” message if localStorage.getItem('isLoggedIn') is 'true'.
Modify src/app/app.component.html:
<nav>
<a routerLink="/" data-test-id="nav-home">Home</a> |
<a routerLink="/products" data-test-id="nav-products">Products</a> |
<a routerLink="/contact" data-test-id="nav-contact">Contact</a>
<span *ngIf="isLoggedIn" style="margin-left: 20px;">Welcome, {{ username }}!</span>
</nav>
<h1>Welcome to Playwright Angular App!</h1>
<router-outlet></router-outlet>
Modify src/app/app.component.ts:
import { Component, OnInit } from '@angular/core'; // Import OnInit
import { CommonModule } from '@angular/common';
import { RouterOutlet, RouterModule } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet, RouterModule],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent implements OnInit { // Implement OnInit
title = 'playwright-angular-app';
isLoggedIn: boolean = false;
username: string = '';
ngOnInit(): void {
// Check local storage on app initialization
if (localStorage.getItem('isLoggedIn') === 'true') {
this.isLoggedIn = true;
this.username = localStorage.getItem('username') || 'Guest';
}
}
}
Now, let’s create a custom fixture to simulate logging in.
1. Create e2e/my-test.ts (this will extend Playwright’s test):
import { test as base, expect } from '@playwright/test';
// Declare the types of fixtures your test will use
type MyFixtures = {
loggedInPage: Page;
username: string; // Add username to the fixture to pass it around
};
// Extend base test by providing a new fixture "loggedInPage"
// This fixture will be available to all tests that use it.
export const test = base.extend<MyFixtures>({
username: 'TestUser', // Default username, can be overridden per test or project
loggedInPage: async ({ page, username }, use) => {
// Perform login steps or set storage state
await page.goto('/'); // Start at the homepage
// Set local storage to simulate being logged in
await page.evaluate(({ user }) => {
localStorage.setItem('isLoggedIn', 'true');
localStorage.setItem('username', user);
}, { user: username }); // Pass username to the evaluate context
// Reload the page for the Angular app to pick up the localStorage change
await page.reload();
await page.waitForLoadState('domcontentloaded');
// Use the page in the test
await use(page);
// Teardown: (optional) clear local storage or logout
await page.evaluate(() => {
localStorage.removeItem('isLoggedIn');
localStorage.removeItem('username');
});
},
});
// Re-export expect for convenience if needed, or use base.expect directly
export { expect };
2. Create a new test file e2e/auth.spec.ts that uses this custom test:
// e2e/auth.spec.ts
import { test, expect } from './my-test'; // Import from our custom test file
test.describe('Authenticated User Tests', () => {
// This test will use the `loggedInPage` fixture, which ensures the user is "logged in"
test('should show welcome message for a logged-in user', async ({ loggedInPage, username }) => {
// The page fixture here is `loggedInPage`, which already has the local storage set
// and the page reloaded.
const welcomeMessage = loggedInPage.locator(`text=Welcome, ${username}!`);
await expect(welcomeMessage).toBeVisible();
// Verify some other content on the page, ensuring it's the expected logged-in state
const appTitle = loggedInPage.locator('h1', { hasText: 'Welcome to Playwright Angular App!' });
await expect(appTitle).toBeVisible();
// Try navigating to another page and ensure authentication persists
await loggedInPage.getByTestId('nav-products').click();
await expect(loggedInPage).toHaveURL('/products');
const welcomeMessageOnProducts = loggedInPage.locator(`text=Welcome, ${username}!`);
await expect(welcomeMessageOnProducts).toBeVisible(); // Welcome message should still be there
});
test('should allow overriding username in fixture', async ({ loggedInPage, username }, testInfo) => {
// This test will override the username fixture to 'Admin'
const adminUsername = 'AdminUser';
// Dynamically override the fixture for this specific test
// Note: Overriding simple values like this in runtime requires more advanced fixture setup,
// a simpler approach for per-test variations is to manually set local storage
// within the test, or define a more complex fixture.
// For this example, we'll demonstrate a direct local storage manipulation
// for overriding. In a real scenario, you'd design your fixture to accept options.
await loggedInPage.evaluate(({ user }) => {
localStorage.setItem('username', user);
}, { user: adminUsername });
await loggedInPage.reload();
await loggedInPage.waitForLoadState('domcontentloaded');
const welcomeMessage = loggedInPage.locator(`text=Welcome, ${adminUsername}!`);
await expect(welcomeMessage).toBeVisible();
});
test('should NOT show welcome message for a guest user', async ({ page }) => {
// This test uses the regular 'page' fixture, which is not logged in.
await page.goto('/');
const welcomeMessage = page.locator('text=Welcome,'); // Locate generically
await expect(welcomeMessage).not.toBeVisible();
});
});
Run the tests:
npx playwright test e2e/auth.spec.ts
Explanation:
base.extend<MyFixtures>({ ... });allows us to define custom fixtures.loggedInPage: async ({ page, username }, use) => { ... }: This defines our custom fixture.- It receives
page(the standard Playwright page) and ourusernamefixture. await page.evaluate(...): This executes JavaScript code directly in the browser context. We use it to setlocalStorageitems.await page.reload(): Important! After settinglocalStoragein the browser context, the Angular app needs to re-initialize to pick up those values.await use(page): This is where the test code runs. Thepageprovided to the test is theloggedInPagefixture.- The code after
await use(page)is the “teardown” which runs after the test completes.
- It receives
- In
auth.spec.ts, we importtestfrom./my-testinstead of@playwright/testto use our custom fixtures. - Tests now explicitly request
loggedInPage(orpagefor non-authenticated tests) as a parameter.
Exercise 3.3.1: Create a ProductData Fixture
Imagine your products page relies on some initial data. Create a fixture that sets up a mock product in sessionStorage before a test runs, and then verifies that product appears.
Modify src/app/products/products.component.html:
<p>products works!</p>
<h2>Our Products</h2>
<ul data-test-id="product-list">
<li data-test-id="product-item-1">Laptop</li>
<li data-test-id="product-item-2">Mouse</li>
<li data-test-id="product-item-3">Keyboard</li>
<!-- Display dynamic product if found -->
<li *ngIf="dynamicProduct" data-test-id="dynamic-product-item">{{ dynamicProduct.name }} ({{ dynamicProduct.price | currency:'USD' }})</li>
</ul>
<button data-test-id="add-to-cart-button">Add First Product to Cart</button>
Modify src/app/products/products.component.ts:
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common'; // Needed for *ngIf
import { CurrencyPipe } from '@angular/common'; // Needed for currency pipe
interface Product {
name: string;
price: number;
}
@Component({
selector: 'app-products',
standalone: true,
imports: [CommonModule, CurrencyPipe], // Add CurrencyPipe here
templateUrl: './products.component.html',
styleUrl: './products.component.css'
})
export class ProductsComponent implements OnInit {
dynamicProduct: Product | null = null;
ngOnInit(): void {
const productJson = sessionStorage.getItem('mockProduct');
if (productJson) {
this.dynamicProduct = JSON.parse(productJson);
}
}
}
Now, create a custom test file (e2e/product-fixtures.ts) to define a fixture mockedProductPage that sets sessionStorage and tests for the dynamic product.
e2e/product-fixtures.ts:
import { test as base, expect, Page } from '@playwright/test';
type ProductFixtures = {
mockedProductPage: Page;
productName: string;
productPrice: number;
};
export const test = base.extend<ProductFixtures>({
productName: 'Mock Product',
productPrice: 99.99,
mockedProductPage: async ({ page, productName, productPrice }, use) => {
await page.goto('/products');
await page.evaluate(({ name, price }) => {
sessionStorage.setItem('mockProduct', JSON.stringify({ name, price }));
}, { name: productName, price: productPrice });
await page.reload();
await page.waitForLoadState('domcontentloaded');
await use(page);
// Teardown
await page.evaluate(() => {
sessionStorage.removeItem('mockProduct');
});
},
});
export { expect };
e2e/mock-products.spec.ts:
import { test, expect } from './product-fixtures'; // Import from custom test file
test.describe('Products Page with Mock Data Fixture', () => {
test('should display a dynamically loaded product', async ({ mockedProductPage, productName, productPrice }) => {
// The page (mockedProductPage) is already navigated to /products and has sessionStorage set.
const dynamicProductItem = mockedProductPage.getByTestId('dynamic-product-item');
await expect(dynamicProductItem).toBeVisible();
await expect(dynamicProductItem).toHaveText(`${productName} ($${productPrice.toFixed(2)})`);
});
test('should still display static products alongside dynamic', async ({ mockedProductPage }) => {
await expect(mockedProductPage.getByText('Laptop')).toBeVisible();
await expect(mockedProductPage.getByText('Mouse')).toBeVisible();
});
});
4. Advanced Topics and Best Practices
As your Angular application and test suite become more complex, you’ll encounter scenarios requiring more advanced Playwright techniques. This section covers crucial topics for building robust, scalable, and efficient E2E tests.
4.1. Network Interception and Mocking
Many Angular applications heavily rely on API calls to fetch and send data. Testing these interactions robustly can be challenging when depending on live backend services, which can be slow, unreliable, or unavailable. Playwright’s network interception allows you to:
- Mock API responses: Provide fake data or simulate error conditions without a real backend.
- Modify requests: Change headers, body, or URL of outgoing requests.
- Wait for requests: Ensure specific API calls have completed before proceeding with assertions.
This capability is invaluable for isolated and fast E2E tests.
Code Example (Mocking API for Products Page):
Let’s modify our ProductsComponent to fetch products from an API. We’ll then use Playwright to mock this API response.
Modify src/app/products/products.component.html:
<p>products works!</p>
<h2>Our Products</h2>
<div *ngIf="loading" data-test-id="products-loading">Loading products...</div>
<ul *ngIf="!loading" data-test-id="product-list">
<li *ngFor="let product of products" data-test-id="product-item-{{ product.id }}">
{{ product.name }} ({{ product.price | currency:'USD' }})
</li>
<li *ngIf="products.length === 0 && !loading" data-test-id="no-products">No products found.</li>
</ul>
<button data-test-id="add-to-cart-button">Add First Product to Cart</button>
Modify src/app/products/products.component.ts:
import { Component, OnInit } from '@angular/core';
import { CommonModule, CurrencyPipe } from '@angular/common';
import { HttpClient, HttpClientModule } from '@angular/common/http'; // Import HttpClient
interface Product {
id: number;
name: string;
price: number;
}
@Component({
selector: 'app-products',
standalone: true,
imports: [CommonModule, CurrencyPipe, HttpClientModule], // Add HttpClientModule
templateUrl: './products.component.html',
styleUrl: './products.component.css'
})
export class ProductsComponent implements OnInit {
products: Product[] = [];
loading: boolean = true;
constructor(private http: HttpClient) { } // Inject HttpClient
ngOnInit(): void {
this.fetchProducts();
}
fetchProducts() {
this.loading = true;
this.http.get<Product[]>('/api/products').subscribe({
next: (data) => {
this.products = data;
this.loading = false;
},
error: (err) => {
console.error('Failed to fetch products', err);
this.products = []; // Ensure products array is empty on error
this.loading = false;
}
});
}
}
Important: Also add HttpClientModule to your app.component.ts imports if you intend to use HttpClient globally or if ProductsComponent isn’t standalone and needs it provided higher up. For standalone components, importing HttpClientModule directly in ProductsComponent is sufficient.
Now, let’s create e2e/network-mock.spec.ts:
import { test, expect } from '@playwright/test';
test.describe('Network Interception and Mocking', () => {
test('should display mocked products from API', async ({ page }) => {
// 1. Intercept the API call to /api/products
await page.route('/api/products', async route => {
// Respond with our mock data
const mockProducts = [
{ id: 101, name: 'Mocked Laptop', price: 1200.00 },
{ id: 102, name: 'Mocked Mouse', price: 25.50 }
];
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockProducts),
});
});
// 2. Navigate to the products page
await page.goto('/products');
// 3. Assert that loading indicator is gone and mocked products are visible
const loadingIndicator = page.getByTestId('products-loading');
await expect(loadingIndicator).not.toBeVisible(); // Playwright waits for this
const productList = page.getByTestId('product-list');
await expect(productList).toBeVisible();
await expect(page.getByText('Mocked Laptop ($1,200.00)')).toBeVisible();
await expect(page.getByText('Mocked Mouse ($25.50)')).toBeVisible();
await expect(page.locator('li')).toHaveCount(2); // Only our two mocked products
});
test('should display "No products found." when API returns empty array', async ({ page }) => {
// 1. Intercept with an empty array
await page.route('/api/products', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: '[]',
});
});
await page.goto('/products');
await expect(page.getByTestId('products-loading')).not.toBeVisible();
await expect(page.getByTestId('no-products')).toBeVisible();
await expect(page.getByTestId('no-products')).toHaveText('No products found.');
});
test('should handle API errors gracefully', async ({ page }) => {
// 1. Intercept and simulate an API error
await page.route('/api/products', async route => {
await route.fulfill({
status: 500, // Internal Server Error
contentType: 'application/json',
body: '{"message": "Internal Server Error"}',
});
});
await page.goto('/products');
await expect(page.getByTestId('products-loading')).not.toBeVisible();
await expect(page.getByTestId('no-products')).toBeVisible(); // Assuming our app shows this on error
await expect(page.getByTestId('no-products')).toHaveText('No products found.');
});
});
Run the tests:
npx playwright test e2e/network-mock.spec.ts
Explanation:
await page.route('/api/products', async route => { ... });is the core of network interception. It tells Playwright to intercept any request to/api/products.- Inside the route handler,
route.fulfill()allows you to provide a custom response, includingstatus,contentType, andbody. expect(loadingIndicator).not.toBeVisible();automatically waits for the loading indicator to disappear, which implies the API call and Angular rendering have completed.
Exercise 4.1.1: Mock a Form Submission API
Modify the ContactComponent to submit data to /api/contact and display success/error. Then, create a Playwright test that mocks a successful submission and an error submission.
Modify src/app/contact/contact.component.html:
<p>contact works!</p>
<h2>Contact Us</h2>
<form data-test-id="contact-form" (ngSubmit)="submitForm()">
<label for="name">Name:</label>
<input type="text" id="name" data-test-id="contact-name-input" [(ngModel)]="contactForm.name" name="name" required><br><br>
<label for="email">Email:</label>
<input type="email" id="email" data-test-id="contact-email-input" [(ngModel)]="contactForm.email" name="email" required><br><br>
<label for="message">Message:</label>
<textarea id="message" data-test-id="contact-message-textarea" [(ngModel)]="contactForm.message" name="message"></textarea><br><br>
<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>
Modify src/app/contact/contact.component.ts:
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; // Import FormsModule
import { HttpClient, HttpClientModule } from '@angular/common/http'; // Import HttpClient
@Component({
selector: 'app-contact',
standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule], // Add FormsModule and HttpClientModule
templateUrl: './contact.component.html',
styleUrl: './contact.component.css'
})
export class ContactComponent {
contactForm = {
name: '',
email: '',
message: ''
};
loading: boolean = false;
submissionFeedback: string = '';
isSuccess: boolean = false;
constructor(private http: HttpClient) { }
async submitForm() {
if (!this.contactForm.name || !this.contactForm.email) {
this.submissionFeedback = 'Name and Email are required.';
this.isSuccess = false;
return;
}
this.loading = true;
this.submissionFeedback = '';
try {
await this.http.post('/api/contact', this.contactForm).toPromise();
this.submissionFeedback = 'Thank you for your message! We will get back to you soon.';
this.isSuccess = true;
this.contactForm = { name: '', email: '', message: '' }; // Clear form
} catch (error) {
this.submissionFeedback = 'Failed to send message. Please try again later.';
this.isSuccess = false;
} finally {
this.loading = false;
}
}
}
Now, create e2e/contact-api-mock.spec.ts.
// e2e/contact-api-mock.spec.ts
import { test, expect } from '@playwright/test';
import { ContactPage } from './pages/contact.page'; // Use our existing ContactPage
test.describe('Contact Form API Mocking', () => {
let contactPage: ContactPage;
test.beforeEach(async ({ page }) => {
contactPage = new ContactPage(page);
await contactPage.goto();
});
test('should display success message on successful API submission', async ({ page }) => {
// Mock a successful POST request
await page.route('**/api/contact', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ message: 'Success' }),
});
});
await contactPage.fillForm('John Doe', 'john.doe@example.com', 'Test success');
await contactPage.submitForm();
const feedback = page.getByTestId('submission-feedback');
await expect(feedback).toBeVisible();
await expect(feedback).toHaveText('Thank you for your message! We will get back to you soon.');
await expect(feedback).toHaveClass(/success/);
});
test('should display error message on API submission failure', async ({ page }) => {
// Mock a failed POST request
await page.route('**/api/contact', async route => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ message: 'Server Error' }),
});
});
await contactPage.fillForm('Jane Doe', 'jane.doe@example.com', 'Test error');
await contactPage.submitForm();
const feedback = page.getByTestId('submission-feedback');
await expect(feedback).toBeVisible();
await expect(feedback).toHaveText('Failed to send message. Please try again later.');
await expect(feedback).toHaveClass(/error/);
});
});
4.2. Visual Regression Testing
Beyond functional correctness, ensuring your Angular application looks correct is equally important. UI changes, even minor ones, can significantly impact user experience. Visual regression testing (VRT) automatically compares screenshots of your application against baseline images to detect unintended visual changes.
Playwright provides excellent built-in capabilities for VRT using toMatchSnapshot().
How it works:
- Baseline Image Generation: The first time you run a test with
toMatchSnapshot(), Playwright takes a screenshot and saves it as a “baseline” image (e.g.,my-element-1.png). - Subsequent Runs: On subsequent runs, Playwright takes another screenshot and compares it pixel-by-pixel with the baseline.
- Difference Detection: If there’s a difference, the test fails, and Playwright generates a “diff” image highlighting the changes.
- Updating Baselines: If a visual change is intentional, you run the tests with
npx playwright test --update-snapshotsto update the baseline.
Code Example (Visual Regression for Home Page):
Let’s add a visual regression test for our homepage.
1. Update e2e/home.spec.ts (or create a new e2e/visual.spec.ts):
// e2e/visual.spec.ts
import { test, expect } from '@playwright/test';
import { HomePage } from './pages/home.page';
test.describe('Visual Regression Tests', () => {
let homePage: HomePage;
test.beforeEach(async ({ page }) => {
homePage = new HomePage(page);
await homePage.goto();
});
test('homepage layout should match snapshot', async ({ page }) => {
// Take a full page screenshot and compare it to a baseline.
// The name 'homepage-full-page.png' will be used for the baseline.
// The test will fail if there are visual differences.
await expect(page).toHaveScreenshot('homepage-full-page.png');
});
test('welcome message section should match snapshot', async () => {
// Take a screenshot of a specific element (the welcome message div)
// This is often more stable than full page screenshots, as changes elsewhere won't break it.
await expect(homePage.welcomeMessageDiv).toHaveScreenshot('welcome-message-div.png');
});
test('navigation bar should match snapshot', async () => {
const navBar = homePage.page.locator('nav');
await expect(navBar).toHaveScreenshot('navigation-bar.png');
});
});
2. Initial Run (Generate Baselines):
Run the tests. Since no baselines exist, Playwright will create them and report them as “passed” (as there’s nothing to compare against yet).
npx playwright test e2e/visual.spec.ts
You’ll see messages like: A snapshot was generated for homepage-full-page.png.
Look in e2e/visual.spec.ts-snapshots (or test-results/snapshots depending on config) for the generated .png files.
3. Simulate a Visual Change:
Let’s intentionally change the background color of our h1 on the homepage to simulate an accidental styling change.
Modify src/app/app.component.css:
/* Add this to your app.component.css */
h1 {
background-color: lightblue; /* New background color */
padding: 10px;
}
4. Run Again (Observe Failure):
Run the tests again without --update-snapshots:
npx playwright test e2e/visual.spec.ts
You should now see failures! Playwright will generate diff.png and expected.png (the baseline) in your test-results folder, helping you visualize the changes.
5. Update Baselines (if change is intentional):
If the lightblue background was an intentional design change, you’d update the baselines:
npx playwright test e2e/visual.spec.ts --update-snapshots
The tests will pass, and the new lightblue image will become the new baseline.
Best Practices for VRT:
- Target specific components/sections: Instead of always taking full-page screenshots, focus on critical UI components. This makes tests more robust to unrelated changes elsewhere on the page.
- Handle dynamic content: If parts of your UI change (e.g., dates, user names, ads), either exclude them from the screenshot area or mock them to be static. Playwright allows masking areas of the screenshot.
- Use CSS to hide fluctuating elements: Temporarily hide non-essential dynamic elements or animations during screenshot captures.
- Run on a consistent environment: VRT can be sensitive to operating system, browser version, and even screen resolution. Run VRT in a consistent CI/CD environment.
Exercise 4.2.1: Visual Test for Contact Form
Add a visual regression test for the initial state of the contact-form section on the contact page.
- Create a new test within
e2e/visual.spec.ts(ore2e/contact-visual.spec.ts). - Navigate to the contact page.
- Locate the form element (e.g., using
page.getByTestId('contact-form')). - Use
toHaveScreenshot()on that locator. - Run to generate baseline, then try changing a style (e.g., label color) in
contact.component.cssand observe the failure.
// e2e/visual.spec.ts
import { test, expect } from '@playwright/test';
import { HomePage } from './pages/home.page';
import { ContactPage } from './pages/contact.page'; // Import ContactPage
test.describe('Visual Regression Tests', () => {
// ... existing tests for HomePage visual regressions ...
test('contact form layout should match snapshot', async ({ page }) => {
const contactPage = new ContactPage(page);
await contactPage.goto();
const contactForm = page.getByTestId('contact-form');
await expect(contactForm).toBeVisible(); // Ensure it's visible before screenshot
await expect(contactForm).toHaveScreenshot('contact-form-initial.png');
});
});
To test the failure: add label { color: red; } to src/app/contact/contact.component.css and run without --update-snapshots.
5. Guided Projects
These guided projects will help you integrate the concepts learned into practical, real-world scenarios. Each project builds incrementally, encouraging you to apply your knowledge at each step.
For these projects, we’ll continue using our playwright-angular-app.
Project 1: User Registration Flow
Objective: Implement E2E tests for a user registration form, covering successful registration and validation errors.
Scenario: We want to create a /register page with fields for Username, Email, Password, and Confirm Password. The form will submit to a mock API endpoint.
Steps:
Step 1: Create the Register Component and Route
Generate a new Angular component for registration.
ng generate component register
Update src/app/app.routes.ts (or app-routing.module.ts) to include a route for /register:
// ... existing routes ...
import { RegisterComponent } from './register/register.component';
export const routes: Routes = [
// ... existing routes ...
{ path: 'register', component: RegisterComponent },
// ...
];
Also, add a navigation link in src/app/app.component.html:
<nav>
<a routerLink="/" data-test-id="nav-home">Home</a> |
<a routerLink="/products" data-test-id="nav-products">Products</a> |
<a routerLink="/contact" data-test-id="nav-contact">Contact</a> |
<a routerLink="/register" data-test-id="nav-register">Register</a> <!-- New link -->
<span *ngIf="isLoggedIn" style="margin-left: 20px;">Welcome, {{ username }}!</span>
</nav>
Step 2: Implement the Register Form (Angular Side)
Populate src/app/register/register.component.html and src/app/register/register.component.ts with a simple registration form. We’ll use template-driven forms for simplicity, but reactive forms would also work.
src/app/register/register.component.html:
<h2>Register for an Account</h2>
<form data-test-id="registration-form" (ngSubmit)="onSubmit()">
<div>
<label for="username">Username:</label>
<input type="text" id="username" data-test-id="register-username-input" [(ngModel)]="user.username" name="username" required minlength="3">
<div *ngIf="usernameTouched && user.username.length < 3" class="error-message">Username must be at least 3 characters.</div>
</div>
<div>
<label for="email">Email:</label>
<input type="email" id="email" data-test-id="register-email-input" [(ngModel)]="user.email" name="email" required email>
<div *ngIf="emailTouched && !user.email.includes('@')" class="error-message">Valid email is required.</div>
</div>
<div>
<label for="password">Password:</label>
<input type="password" id="password" data-test-id="register-password-input" [(ngModel)]="user.password" name="password" required minlength="6">
<div *ngIf="passwordTouched && user.password.length < 6" class="error-message">Password must be at least 6 characters.</div>
</div>
<div>
<label for="confirmPassword">Confirm Password:</label>
<input type="password" id="confirmPassword" data-test-id="register-confirm-password-input" [(ngModel)]="user.confirmPassword" name="confirmPassword" required>
<div *ngIf="confirmPasswordTouched && user.password !== user.confirmPassword" class="error-message">Passwords do not match.</div>
</div>
<button type="submit" data-test-id="register-submit-button" [disabled]="loading">Register</button>
</form>
<div data-test-id="registration-feedback" *ngIf="feedbackMessage" [ngClass]="{'success': isSuccess, 'error': !isSuccess}">
{{ feedbackMessage }}
</div>
<style>
.error-message {
color: red;
font-size: 0.8em;
}
.success { color: green; }
.error { color: red; }
</style>
src/app/register/register.component.ts:
import { Component } from '@angular/core';
import { CommonModule, NgClass } from '@angular/common'; // Add NgClass
import { FormsModule } from '@angular/forms'; // Import FormsModule
import { HttpClient, HttpClientModule } from '@angular/common/http';
@Component({
selector: 'app-register',
standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule, NgClass], // Add FormsModule, HttpClientModule, NgClass
templateUrl: './register.component.html',
styleUrl: './register.component.css'
})
export class RegisterComponent {
user = {
username: '',
email: '',
password: '',
confirmPassword: ''
};
loading: boolean = false;
feedbackMessage: string = '';
isSuccess: boolean = false;
// Track touched state for basic template-driven validation display
usernameTouched: boolean = false;
emailTouched: boolean = false;
passwordTouched: boolean = false;
confirmPasswordTouched: boolean = false;
constructor(private http: HttpClient) {}
async onSubmit() {
this.usernameTouched = true;
this.emailTouched = true;
this.passwordTouched = true;
this.confirmPasswordTouched = true;
// Basic client-side validation
if (this.user.username.length < 3 ||
!this.user.email.includes('@') ||
this.user.password.length < 6 ||
this.user.password !== this.user.confirmPassword) {
this.feedbackMessage = 'Please correct the form errors.';
this.isSuccess = false;
return;
}
this.loading = true;
this.feedbackMessage = '';
try {
// Simulate API call to /api/register
await this.http.post('/api/register', {
username: this.user.username,
email: this.user.email,
password: this.user.password
}).toPromise();
this.feedbackMessage = `Registration successful for ${this.user.username}!`;
this.isSuccess = true;
// Clear form
this.user = { username: '', email: '', password: '', confirmPassword: '' };
this.resetTouchedState();
} catch (error: any) {
console.error('Registration failed', error);
this.feedbackMessage = error.error?.message || 'Registration failed. Please try a different email or username.';
this.isSuccess = false;
} finally {
this.loading = false;
}
}
resetTouchedState() {
this.usernameTouched = false;
this.emailTouched = false;
this.passwordTouched = false;
this.confirmPasswordTouched = false;
}
}
Step 3: Create a Page Object for the Register Page (e2e/pages/register.page.ts)
Encapsulate the locators and actions for the registration page.
import { Page, Locator } from '@playwright/test';
export class RegisterPage {
readonly page: Page;
readonly usernameInput: Locator;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly confirmPasswordInput: Locator;
readonly submitButton: Locator;
readonly feedbackMessage: Locator;
readonly usernameError: Locator;
readonly emailError: Locator;
readonly passwordError: Locator;
readonly confirmPasswordError: Locator;
constructor(page: Page) {
this.page = page;
this.usernameInput = page.getByTestId('register-username-input');
this.emailInput = page.getByTestId('register-email-input');
this.passwordInput = page.getByTestId('register-password-input');
this.confirmPasswordInput = page.getByTestId('register-confirm-password-input');
this.submitButton = page.getByTestId('register-submit-button');
this.feedbackMessage = page.getByTestId('registration-feedback');
this.usernameError = page.locator('.error-message', { hasText: 'Username must be at least 3 characters.' });
this.emailError = page.locator('.error-message', { hasText: 'Valid email is required.' });
this.passwordError = page.locator('.error-message', { hasText: 'Password must be at least 6 characters.' });
this.confirmPasswordError = page.locator('.error-message', { hasText: 'Passwords do not match.' });
}
async goto() {
await this.page.goto('/register');
}
async fillRegistrationForm(username: string, email: string, password: string, confirmPassword: string) {
await this.usernameInput.fill(username);
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.confirmPasswordInput.fill(confirmPassword);
}
async submitRegistrationForm() {
await this.submitButton.click();
}
async getFeedbackMessage() {
return this.feedbackMessage.textContent();
}
}
Step 4: Implement Playwright Tests for Registration Flow (e2e/register.spec.ts)
Create tests for success, invalid input, and API error scenarios.
import { test, expect } from '@playwright/test';
import { RegisterPage } from './pages/register.page';
test.describe('User Registration Flow', () => {
let registerPage: RegisterPage;
test.beforeEach(async ({ page }) => {
registerPage = new RegisterPage(page);
await registerPage.goto();
});
test('should register a new user successfully', async ({ page }) => {
// Mock the successful API response
await page.route('**/api/register', async route => {
await route.fulfill({
status: 201, // Created
contentType: 'application/json',
body: JSON.stringify({ userId: 'abc-123', message: 'Registration successful' }),
});
});
await registerPage.fillRegistrationForm('testuser', 'test@example.com', 'Password123!', 'Password123!');
await registerPage.submitRegistrationForm();
await expect(registerPage.feedbackMessage).toBeVisible();
await expect(registerPage.feedbackMessage).toContainText('Registration successful for testuser!');
await expect(registerPage.feedbackMessage).toHaveClass(/success/);
// Verify form fields are cleared
await expect(registerPage.usernameInput).toHaveValue('');
await expect(registerPage.emailInput).toHaveValue('');
});
test('should display validation errors for empty fields', async () => {
await registerPage.usernameInput.focus(); // Focus to trigger validation
await registerPage.emailInput.focus();
await registerPage.passwordInput.focus();
await registerPage.confirmPasswordInput.focus();
await registerPage.usernameInput.blur(); // Blur to show errors
// Try to submit with empty fields
await registerPage.submitRegistrationForm();
await expect(registerPage.feedbackMessage).toBeVisible();
await expect(registerPage.feedbackMessage).toContainText('Please correct the form errors.');
await expect(registerPage.feedbackMessage).toHaveClass(/error/);
await expect(registerPage.usernameError).toBeVisible();
await expect(registerPage.emailError).toBeVisible();
await expect(registerPage.passwordError).toBeVisible();
});
test('should display error if passwords do not match', async () => {
await registerPage.fillRegistrationForm('user123', 'user@example.com', 'Pass123', 'WrongPass');
await registerPage.submitRegistrationForm();
await expect(registerPage.feedbackMessage).toBeVisible();
await expect(registerPage.feedbackMessage).toContainText('Please correct the form errors.');
await expect(registerPage.confirmPasswordError).toBeVisible();
});
test('should display API error message on registration failure', async ({ page }) => {
// Mock an API error response (e.g., username already exists)
await page.route('**/api/register', async route => {
await route.fulfill({
status: 409, // Conflict
contentType: 'application/json',
body: JSON.stringify({ message: 'Username already taken.' }),
});
});
await registerPage.fillRegistrationForm('existinguser', 'existing@example.com', 'Pass123!', 'Pass123!');
await registerPage.submitRegistrationForm();
await expect(registerPage.feedbackMessage).toBeVisible();
await expect(registerPage.feedbackMessage).toContainText('Username already taken.');
await expect(registerPage.feedbackMessage).toHaveClass(/error/);
});
});
Step 5: Run the Registration Tests
npx playwright test e2e/register.spec.ts
Encourage Independent Problem-Solving:
- Challenge: Add a visual regression test to
e2e/register.spec.tsthat captures the initial state of the registration form. - Challenge: Modify the form to include a “Terms and Conditions” checkbox. Add a test to ensure registration fails if the checkbox is not checked. You’ll need to update the Angular component, Page Object, and test.
Project 2: Data Table with Pagination and Filtering
Objective: Test a data table that fetches data from an API, supports pagination, and client-side filtering.
Scenario: We’ll create a /dashboard page that displays a list of users in a table. The table will have “Next” and “Previous” buttons for pagination and an input field to filter users by name.
Steps:
Step 1: Create the Dashboard Component and Route
Generate the dashboard component.
ng generate component dashboard
Update src/app/app.routes.ts and src/app/app.component.html for the new route and navigation link.
src/app/app.routes.ts:
// ... existing routes ...
import { DashboardComponent } from './dashboard/dashboard.component';
export const routes: Routes = [
// ... existing routes ...
{ path: 'dashboard', component: DashboardComponent },
// ...
];
src/app/app.component.html:
<nav>
<a routerLink="/" data-test-id="nav-home">Home</a> |
<a routerLink="/products" data-test-id="nav-products">Products</a> |
<a routerLink="/contact" data-test-id="nav-contact">Contact</a> |
<a routerLink="/register" data-test-id="nav-register">Register</a> |
<a routerLink="/dashboard" data-test-id="nav-dashboard">Dashboard</a> <!-- New link -->
<span *ngIf="isLoggedIn" style="margin-left: 20px;">Welcome, {{ username }}!</span>
</nav>
Step 2: Implement the Dashboard Component (Angular Side)
This component will fetch user data, handle pagination state, and filter the displayed data.
src/app/dashboard/dashboard.component.html:
<h2>User Dashboard</h2>
<div class="controls">
<label for="filter">Filter by Name:</label>
<input type="text" id="filter" data-test-id="user-filter-input" [(ngModel)]="filterText" (ngModelChange)="applyFilter()">
</div>
<div data-test-id="loading-indicator" *ngIf="loading">Loading users...</div>
<table data-test-id="user-table" *ngIf="!loading && paginatedUsers.length > 0">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let user of paginatedUsers" data-test-id="user-row-{{ user.id }}">
<td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
</tr>
</tbody>
</table>
<div data-test-id="no-users-message" *ngIf="!loading && paginatedUsers.length === 0">No users found.</div>
<div class="pagination-controls" *ngIf="!loading && filteredUsers.length > pageSize">
<button data-test-id="prev-page-button" (click)="prevPage()" [disabled]="currentPage === 1">Previous</button>
<span data-test-id="current-page-info">Page {{ currentPage }} of {{ totalPages }}</span>
<button data-test-id="next-page-button" (click)="nextPage()" [disabled]="currentPage === totalPages">Next</button>
</div>
<style>
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
.controls { margin-bottom: 15px; }
.pagination-controls { margin-top: 20px; text-align: center; }
.pagination-controls button { padding: 8px 15px; margin: 0 5px; cursor: pointer; }
.pagination-controls button:disabled { background-color: #eee; cursor: not-allowed; }
</style>
src/app/dashboard/dashboard.component.ts:
import { Component, OnInit } from '@angular/core';
import { CommonModule, NgFor, NgIf } from '@angular/common'; // NgFor and NgIf needed
import { FormsModule } from '@angular/forms'; // FormsModule for ngModel
import { HttpClient, HttpClientModule } from '@angular/common/http';
interface User {
id: number;
name: string;
email: string;
}
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule, NgFor, NgIf],
templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.css'
})
export class DashboardComponent implements OnInit {
allUsers: User[] = [];
filteredUsers: User[] = [];
paginatedUsers: User[] = [];
loading: boolean = true;
filterText: string = '';
currentPage: number = 1;
pageSize: number = 5; // Display 5 users per page
totalPages: number = 1;
constructor(private http: HttpClient) { }
ngOnInit(): void {
this.fetchUsers();
}
fetchUsers() {
this.loading = true;
this.http.get<User[]>('/api/users').subscribe({
next: (data) => {
this.allUsers = data;
this.applyFilter(); // Apply filter initially (which also applies pagination)
},
error: (err) => {
console.error('Failed to fetch users', err);
this.allUsers = [];
this.applyFilter();
}
}).add(() => {
this.loading = false;
});
}
applyFilter() {
this.filteredUsers = this.allUsers.filter(user =>
user.name.toLowerCase().includes(this.filterText.toLowerCase())
);
this.currentPage = 1; // Reset to first page after filtering
this.updatePagination();
}
updatePagination() {
this.totalPages = Math.ceil(this.filteredUsers.length / this.pageSize);
const startIndex = (this.currentPage - 1) * this.pageSize;
const endIndex = startIndex + this.pageSize;
this.paginatedUsers = this.filteredUsers.slice(startIndex, endIndex);
}
nextPage() {
if (this.currentPage < this.totalPages) {
this.currentPage++;
this.updatePagination();
}
}
prevPage() {
if (this.currentPage > 1) {
this.currentPage--;
this.updatePagination();
}
}
}
Step 3: Create a Page Object for the Dashboard Page (e2e/pages/dashboard.page.ts)
import { Page, Locator } from '@playwright/test';
export class DashboardPage {
readonly page: Page;
readonly heading: Locator;
readonly loadingIndicator: Locator;
readonly userTable: Locator;
readonly userFilterInput: Locator;
readonly noUsersMessage: Locator;
readonly prevPageButton: Locator;
readonly nextPageButton: Locator;
readonly currentPageInfo: Locator;
constructor(page: Page) {
this.page = page;
this.heading = page.locator('h2', { hasText: 'User Dashboard' });
this.loadingIndicator = page.getByTestId('loading-indicator');
this.userTable = page.getByTestId('user-table');
this.userFilterInput = page.getByTestId('user-filter-input');
this.noUsersMessage = page.getByTestId('no-users-message');
this.prevPageButton = page.getByTestId('prev-page-button');
this.nextPageButton = page.getByTestId('next-page-button');
this.currentPageInfo = page.getByTestId('current-page-info');
}
async goto() {
await this.page.goto('/dashboard');
}
async getUserRows() {
return this.userTable.locator('tbody tr');
}
async filterUsers(name: string) {
await this.userFilterInput.fill(name);
}
async clickNextPage() {
await this.nextPageButton.click();
}
async clickPrevPage() {
await this.prevPageButton.click();
}
async getCurrentPageInfo() {
return this.currentPageInfo.textContent();
}
}
Step 4: Implement Playwright Tests for Dashboard (e2e/dashboard.spec.ts)
import { test, expect } from '@playwright/test';
import { DashboardPage } from './pages/dashboard.page';
test.describe('User Dashboard - Pagination and Filtering', () => {
let dashboardPage: DashboardPage;
// Mock data for the /api/users endpoint
const mockUsers = [
{ id: 1, name: 'Alice Smith', email: 'alice@example.com' },
{ id: 2, name: 'Bob Johnson', email: 'bob@example.com' },
{ id: 3, name: 'Charlie Brown', email: 'charlie@example.com' },
{ id: 4, name: 'David Lee', email: 'david@example.com' },
{ id: 5, name: 'Eve Davis', email: 'eve@example.com' },
{ id: 6, name: 'Frank White', email: 'frank@example.com' },
{ id: 7, name: 'Grace Taylor', email: 'grace@example.com' },
{ id: 8, name: 'Henry Green', email: 'henry@example.com' },
{ id: 9, name: 'Ivy King', email: 'ivy@example.com' },
{ id: 10, name: 'Jack Hall', email: 'jack@example.com' },
];
test.beforeEach(async ({ page }) => {
// Intercept the API call and provide mock data before each test
await page.route('**/api/users', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockUsers),
});
});
dashboardPage = new DashboardPage(page);
await dashboardPage.goto();
// Wait for the loading indicator to disappear, ensuring data is loaded
await expect(dashboardPage.loadingIndicator).not.toBeVisible();
});
test('should display the initial page of users', async () => {
await expect(dashboardPage.userTable).toBeVisible();
const rows = await dashboardPage.getUserRows();
await expect(rows).toHaveCount(5); // Assuming pageSize is 5
await expect(rows.first()).toContainText('Alice Smith');
await expect(rows.last()).toContainText('Eve Davis');
await expect(dashboardPage.currentPageInfo).toHaveText('Page 1 of 2'); // 10 users / 5 pageSize = 2 pages
});
test('should navigate to the next page of users', async () => {
await dashboardPage.clickNextPage(); // Go to page 2
await expect(dashboardPage.currentPageInfo).toHaveText('Page 2 of 2');
const rows = await dashboardPage.getUserRows();
await expect(rows).toHaveCount(5);
await expect(rows.first()).toContainText('Frank White');
await expect(rows.last()).toContainText('Jack Hall');
await expect(dashboardPage.nextPageButton).toBeDisabled(); // Should be disabled on the last page
await expect(dashboardPage.prevPageButton).toBeEnabled();
});
test('should navigate back to the previous page', async () => {
await dashboardPage.clickNextPage(); // Page 2
await expect(dashboardPage.currentPageInfo).toHaveText('Page 2 of 2');
await dashboardPage.clickPrevPage(); // Back to page 1
await expect(dashboardPage.currentPageInfo).toHaveText('Page 1 of 2');
const rows = await dashboardPage.getUserRows();
await expect(rows).toHaveCount(5);
await expect(rows.first()).toContainText('Alice Smith');
await expect(rows.last()).toContainText('Eve Davis');
await expect(dashboardPage.prevPageButton).toBeDisabled(); // Should be disabled on first page
await expect(dashboardPage.nextPageButton).toBeEnabled();
});
test('should filter users by name', async () => {
await dashboardPage.filterUsers('alic'); // Filter for "Alice"
await expect(dashboardPage.userTable).toBeVisible();
const rows = await dashboardPage.getUserRows();
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText('Alice Smith');
await expect(dashboardPage.currentPageInfo).toHaveText('Page 1 of 1'); // Filtered down to 1 user, 1 page
});
test('should show "No users found." when filter yields no results', async () => {
await dashboardPage.filterUsers('xyz'); // Filter for something non-existent
await expect(dashboardPage.userTable).not.toBeVisible();
await expect(dashboardPage.noUsersMessage).toBeVisible();
await expect(dashboardPage.noUsersMessage).toHaveText('No users found.');
await expect(dashboardPage.currentPageInfo).toHaveText('Page 1 of 1');
});
test('should maintain filter when navigating pages (if filter applied before pagination)', async () => {
// Filter first, then navigate. The Angular component handles resetting to page 1 on filter change.
await dashboardPage.filterUsers('e'); // Users with 'e': Alice, Eve, Steve, Grace, Henry (5 users)
await expect(dashboardPage.currentPageInfo).toHaveText('Page 1 of 1'); // If page size is 5, they should fit on one page
const rows = await dashboardPage.getUserRows();
await expect(rows).toHaveCount(5);
});
});
Step 5: Run the Dashboard Tests
npx playwright test e2e/dashboard.spec.ts
Encourage Independent Problem-Solving:
- Challenge: Implement sorting for the user table (e.g., by name or ID) in the Angular component. Then, add Playwright tests to verify the sorting functionality.
- Challenge: Add an API endpoint for fetching a single user (
/api/users/:id). When a user’s row in the table is clicked, navigate to/users/:idto display their details. Write tests for this navigation and detail display, including mocking the single-user API.
6. Bonus Section: Further Learning and Resources
You’ve covered a lot of ground, from Playwright basics to advanced techniques and practical projects with Angular. To continue your journey and stay updated with the ever-evolving world of web testing, here are some excellent resources:
Recommended Online Courses/Tutorials:
- Playwright Official Docs - Getting Started: Always the first stop. The documentation is incredibly thorough and well-maintained.
- Playwright Testing in JavaScript/TypeScript (Various Platforms): Look for courses on platforms like Udemy, Coursera, Pluralsight, or LinkedIn Learning. Search for “Playwright E2E Testing” or “Playwright with TypeScript.”
- Angular.love - Modern E2E Testing for Angular Apps with Playwright: A highly relevant and up-to-date article covering similar ground as this guide.
- Testing Angular in 2025 (YouTube): Look for recent talks or tutorials on YouTube discussing modern Angular testing strategies, often including Playwright.
- Example search: “High ROI Testing in Angular with Playwright (Testing Angular in 2025)” or “Angular Playwright Series”
Official Documentation:
- Playwright Documentation: The absolute best resource for anything Playwright-related. Includes API references, guides, and examples.
- Angular Documentation: Essential for understanding your Angular application’s structure and best practices.
Blogs and Articles:
- Medium & Dev.to: Search for “Playwright Angular E2E” or “Playwright Best Practices.” Many developers share their experiences and solutions.
- Autify Blog: Often publishes articles on Playwright best practices and general E2E testing strategies.
YouTube Channels:
- Playwright Official Channel: While not explicitly a tutorial channel, it often features updates and talks.
- Automation Step by Step (Rahul Shetty): Has a comprehensive Playwright playlist.
- Fireship (often covers quick tech overviews): While not deep dives, they sometimes feature modern testing tools.
- Individual developers and Angular educators: Search for channels dedicated to Angular development that also cover testing.
Community Forums/Groups:
- Stack Overflow: The go-to place for specific questions and error troubleshooting. Tag your questions with
playwrightandangular. - Playwright GitHub Discussions: Engage directly with the Playwright team and community for feature requests, issues, and discussions.
- Angular Discord/Reddit Communities: Participate in broader Angular discussions where testing topics frequently arise.
- reddit.com/r/Angular/
- Look for official Angular Discord servers.
Next Steps/Advanced Topics:
After mastering the content in this document, consider exploring these advanced topics:
- CI/CD Integration: Integrate your Playwright tests into your continuous integration and continuous delivery pipeline (e.g., GitHub Actions, GitLab CI, Jenkins) to run tests automatically with every code change.
- Parallel Test Execution: Learn how to optimize your
playwright.config.tsto run tests across multiple workers and projects simultaneously, drastically reducing test execution time for large suites. - Component Testing with Playwright (Experimental/Community): While Playwright primarily focuses on E2E, there’s ongoing work and community efforts to enable component testing. Keep an eye on the Playwright documentation and community for updates.
- Visual Testing Beyond Basic Snapshots: Explore advanced visual testing tools that integrate with Playwright for more robust comparisons and reporting (e.g., Applitools Eyes, Storybook Chromatic).
- Accessibility Testing: Use Playwright’s integration with tools like Axe-core to include automated accessibility checks in your E2E tests.
- Performance Testing (Lighthouse with Playwright): Learn how to integrate Lighthouse audits into your Playwright tests to measure and monitor application performance.
- Advanced Fixtures: Create more complex custom fixtures for setting up intricate test data or managing browser context more dynamically.
- Testing Authentication Flows (JWT, Session Management): Dive deeper into more realistic authentication scenarios beyond simple
localStoragemocks.
Keep practicing, experimenting, and exploring! The world of test automation is dynamic, and continuous learning is key to becoming a proficient and valuable contributor. Happy testing!