End-to-End Testing with Playwright
Overview
End-to-end (E2E) testing verifies complete user workflows by automating browser interactions. Playwright is a modern browser automation framework developed by Microsoft that supports Chromium, Firefox, and WebKit with a single API.
Table of Contents
- Why Playwright
- Setup and Installation
- Basic Concepts
- Selectors and Locators
- Actions and Interactions
- Assertions
- Page Object Model
- Handling Async Operations
- Screenshots and Videos
- Cross-Browser Testing
- CI/CD Integration
- Comparison with Selenium
- Best Practices
- Interview Questions
Why Playwright
Playwright vs Other Tools
| Feature | Playwright | Selenium | Cypress |
|---|---|---|---|
| Language Support | C#, JS, Python, Java | Multiple | JS only |
| Browser Support | Chromium, Firefox, WebKit | All major browsers | Chromium, Firefox, Edge |
| Auto-Wait | Built-in | Manual | Built-in |
| Network Interception | Yes | Limited | Yes |
| Parallel Execution | Native | Selenium Grid | Paid feature |
| Iframes/Shadow DOM | Excellent | Manual handling | Limited |
| Mobile Emulation | Yes | Appium needed | Viewport only |
| Trace Viewer | Yes | No | Limited |
Key Advantages
- Auto-waiting: Automatically waits for elements to be actionable
- Reliable selectors: Built-in retry logic for flaky tests
- Cross-browser: Single API for all browsers
- Parallel execution: Built-in parallelization
- Debugging tools: Trace viewer, codegen, inspector
Setup and Installation
Create Test Project
# Create test project
dotnet new mstest -n PlaywrightTests
cd PlaywrightTests
# Add Playwright packages
dotnet add package Microsoft.Playwright
dotnet add package Microsoft.Playwright.MSTest
# Or for NUnit
dotnet add package Microsoft.Playwright.NUnit
# Build and install browsers
dotnet build
pwsh bin/Debug/net8.0/playwright.ps1 install
Project Structure
PlaywrightTests/
βββ PlaywrightTests.csproj
βββ GlobalUsings.cs
βββ PageObjects/
β βββ BasePage.cs
β βββ LoginPage.cs
β βββ DashboardPage.cs
βββ Tests/
β βββ LoginTests.cs
β βββ DashboardTests.cs
βββ Fixtures/
βββ TestFixture.cs
Basic Configuration
// GlobalUsings.cs
global using Microsoft.Playwright;
global using Microsoft.Playwright.MSTest;
global using Microsoft.VisualStudio.TestTools.UnitTesting;
<!-- PlaywrightTests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Microsoft.Playwright" Version="1.40.0" />
<PackageReference Include="Microsoft.Playwright.MSTest" Version="1.40.0" />
</ItemGroup>
</Project>
Basic Concepts
Browser, Context, and Page
public class BrowserConcepts
{
[TestMethod]
public async Task UnderstandingHierarchy()
{
// Browser - Single browser instance (Chromium, Firefox, WebKit)
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync();
// Context - Isolated browser session (cookies, storage)
await using var context = await browser.NewContextAsync();
// Page - Single tab/window
var page = await context.NewPageAsync();
await page.GotoAsync("https://example.com");
}
[TestMethod]
public async Task MultiplePagesInContext()
{
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync();
await using var context = await browser.NewContextAsync();
// Multiple pages share context (cookies, storage)
var page1 = await context.NewPageAsync();
var page2 = await context.NewPageAsync();
await page1.GotoAsync("https://example.com/login");
// Login on page1...
await page2.GotoAsync("https://example.com/dashboard");
// page2 is already logged in (shares cookies)
}
[TestMethod]
public async Task IsolatedContexts()
{
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync();
// Each context is isolated
await using var context1 = await browser.NewContextAsync();
await using var context2 = await browser.NewContextAsync();
var page1 = await context1.NewPageAsync();
var page2 = await context2.NewPageAsync();
// Login on page1 doesn't affect page2
}
}
MSTest Integration
// Inheriting from PageTest provides Page property
[TestClass]
public class BasicTests : PageTest
{
[TestMethod]
public async Task NavigateToPage()
{
await Page.GotoAsync("https://playwright.dev");
await Expect(Page).ToHaveTitleAsync(new Regex("Playwright"));
}
[TestMethod]
public async Task ClickAndVerify()
{
await Page.GotoAsync("https://playwright.dev");
await Page.GetByRole(AriaRole.Link, new() { Name = "Get started" }).ClickAsync();
await Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "Installation" })).ToBeVisibleAsync();
}
}
NUnit Integration
[TestFixture]
public class BasicTests : PageTest
{
[Test]
public async Task NavigateToPage()
{
await Page.GotoAsync("https://playwright.dev");
await Expect(Page).ToHaveTitleAsync(new Regex("Playwright"));
}
}
Selectors and Locators
Modern Locator Strategies (Recommended)
public class LocatorStrategies
{
[TestMethod]
public async Task ModernLocators()
{
// Role-based (most reliable)
var submitButton = Page.GetByRole(AriaRole.Button, new() { Name = "Submit" });
var heading = Page.GetByRole(AriaRole.Heading, new() { Level = 1 });
var checkbox = Page.GetByRole(AriaRole.Checkbox, new() { Name = "Accept terms" });
// Text-based
var exactText = Page.GetByText("Welcome to our site");
var partialText = Page.GetByText("Welcome", new() { Exact = false });
// Label-based (for form elements)
var emailInput = Page.GetByLabel("Email address");
var passwordInput = Page.GetByLabel("Password");
// Placeholder-based
var searchInput = Page.GetByPlaceholder("Search...");
// Alt text (for images)
var logo = Page.GetByAltText("Company Logo");
// Title attribute
var tooltip = Page.GetByTitle("Click for more info");
// Test ID (explicitly set in HTML)
var element = Page.GetByTestId("submit-button");
}
}
CSS and XPath Selectors
public class CssXpathSelectors
{
[TestMethod]
public async Task CssSelectors()
{
// ID
var byId = Page.Locator("#username");
// Class
var byClass = Page.Locator(".btn-primary");
// Attribute
var byAttribute = Page.Locator("[data-testid='login-form']");
var byPartialAttribute = Page.Locator("[href*='login']");
// Combinations
var combined = Page.Locator("form.login-form input[type='email']");
// Pseudo-selectors
var firstItem = Page.Locator("ul.menu li:first-child");
var nthItem = Page.Locator("ul.menu li:nth-child(3)");
}
[TestMethod]
public async Task XPathSelectors()
{
// Basic XPath
var byXPath = Page.Locator("//button[@type='submit']");
// Text content
var byText = Page.Locator("//button[text()='Login']");
var byContains = Page.Locator("//button[contains(text(), 'Log')]");
// Parent/sibling navigation
var parentDiv = Page.Locator("//input[@id='email']/parent::div");
var followingSibling = Page.Locator("//label[text()='Email']//following-sibling::input");
}
}
Filtering and Chaining
public class LocatorFiltering
{
[TestMethod]
public async Task FilterLocators()
{
// Filter by text
var activeTab = Page.GetByRole(AriaRole.Tab).Filter(new() { HasText = "Active" });
// Filter by another locator
var row = Page.GetByRole(AriaRole.Row).Filter(new()
{
Has = Page.GetByText("John Doe")
});
// Get specific occurrence
var secondButton = Page.GetByRole(AriaRole.Button).Nth(1);
var firstButton = Page.GetByRole(AriaRole.Button).First;
var lastButton = Page.GetByRole(AriaRole.Button).Last;
// Chain locators
var emailInForm = Page.Locator("form.login").GetByLabel("Email");
// Within specific container
var container = Page.Locator("#user-section");
var buttonInContainer = container.GetByRole(AriaRole.Button, new() { Name = "Edit" });
}
}
Actions and Interactions
Basic Actions
public class BasicActions
{
[TestMethod]
public async Task CommonActions()
{
await Page.GotoAsync("https://example.com/form");
// Click
await Page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).ClickAsync();
// Double click
await Page.Locator("#element").DblClickAsync();
// Right click
await Page.Locator("#element").ClickAsync(new() { Button = MouseButton.Right });
// Type text
await Page.GetByLabel("Username").FillAsync("john_doe");
// Type with key events
await Page.GetByLabel("Search").TypeAsync("search term", new() { Delay = 100 });
// Clear and type
await Page.GetByLabel("Email").ClearAsync();
await Page.GetByLabel("Email").FillAsync("new@email.com");
// Press keys
await Page.Keyboard.PressAsync("Enter");
await Page.Keyboard.PressAsync("Control+A");
await Page.Keyboard.TypeAsync("Hello World");
// Check/uncheck
await Page.GetByRole(AriaRole.Checkbox, new() { Name = "Accept terms" }).CheckAsync();
await Page.GetByRole(AriaRole.Checkbox, new() { Name = "Newsletter" }).UncheckAsync();
// Select dropdown
await Page.GetByRole(AriaRole.Combobox).SelectOptionAsync("value1");
await Page.GetByLabel("Country").SelectOptionAsync(new[] { "USA", "Canada" });
// Hover
await Page.GetByRole(AriaRole.Button, new() { Name = "Menu" }).HoverAsync();
// Focus
await Page.GetByLabel("Email").FocusAsync();
}
}
File Upload
public class FileUpload
{
[TestMethod]
public async Task UploadFile()
{
await Page.GotoAsync("https://example.com/upload");
// Single file
await Page.GetByLabel("Upload file").SetInputFilesAsync("document.pdf");
// Multiple files
await Page.GetByLabel("Upload files").SetInputFilesAsync(new[]
{
"document1.pdf",
"document2.pdf"
});
// File with buffer
await Page.GetByLabel("Upload").SetInputFilesAsync(new FilePayload
{
Name = "test.txt",
MimeType = "text/plain",
Buffer = System.Text.Encoding.UTF8.GetBytes("Hello World")
});
// Clear files
await Page.GetByLabel("Upload file").SetInputFilesAsync(Array.Empty<string>());
}
}
Drag and Drop
public class DragDrop
{
[TestMethod]
public async Task DragAndDrop()
{
await Page.GotoAsync("https://example.com/dnd");
// Simple drag and drop
await Page.Locator("#source").DragToAsync(Page.Locator("#target"));
// With options
await Page.Locator("#source").DragToAsync(
Page.Locator("#target"),
new()
{
SourcePosition = new Position { X = 10, Y = 10 },
TargetPosition = new Position { X = 50, Y = 50 }
});
}
}
Frames and Iframes
public class FrameHandling
{
[TestMethod]
public async Task WorkWithFrames()
{
await Page.GotoAsync("https://example.com/iframe-page");
// By frame name or URL
var frame = Page.Frame("frame-name");
var frameByUrl = Page.FrameByUrl("**/iframe-content.html");
// Using FrameLocator (recommended)
var frameLocator = Page.FrameLocator("iframe[name='content']");
await frameLocator.GetByRole(AriaRole.Button, new() { Name = "Submit" }).ClickAsync();
// Nested frames
var nestedFrame = Page.FrameLocator("#outer-frame")
.FrameLocator("#inner-frame");
await nestedFrame.GetByText("Click me").ClickAsync();
}
}
Dialogs
public class DialogHandling
{
[TestMethod]
public async Task HandleDialogs()
{
// Listen for dialogs
Page.Dialog += async (_, dialog) =>
{
Console.WriteLine($"Dialog message: {dialog.Message}");
if (dialog.Type == "confirm")
{
await dialog.AcceptAsync();
}
else if (dialog.Type == "prompt")
{
await dialog.AcceptAsync("My input");
}
else
{
await dialog.DismissAsync();
}
};
// Trigger dialog
await Page.GetByRole(AriaRole.Button, new() { Name = "Delete" }).ClickAsync();
// One-time dialog handler
void HandleDialog(object? sender, IDialog dialog)
{
dialog.AcceptAsync();
Page.Dialog -= HandleDialog;
}
Page.Dialog += HandleDialog;
}
}
Assertions
Web-First Assertions
public class Assertions
{
[TestMethod]
public async Task WebFirstAssertions()
{
await Page.GotoAsync("https://example.com");
// Page assertions
await Expect(Page).ToHaveTitleAsync("Example Domain");
await Expect(Page).ToHaveTitleAsync(new Regex("Example.*"));
await Expect(Page).ToHaveURLAsync("https://example.com/");
await Expect(Page).ToHaveURLAsync(new Regex(".*/dashboard"));
// Locator assertions
var button = Page.GetByRole(AriaRole.Button, new() { Name = "Submit" });
await Expect(button).ToBeVisibleAsync();
await Expect(button).ToBeEnabledAsync();
await Expect(button).ToBeDisabledAsync();
await Expect(button).ToHaveTextAsync("Submit");
await Expect(button).ToHaveTextAsync(new Regex("Sub.*"));
await Expect(button).ToContainTextAsync("Sub");
await Expect(button).ToHaveAttributeAsync("type", "submit");
await Expect(button).ToHaveClassAsync("btn-primary");
await Expect(button).ToHaveCSSAsync("color", "rgb(255, 255, 255)");
await Expect(button).ToBeFocusedAsync();
// Form element assertions
var input = Page.GetByLabel("Email");
await Expect(input).ToHaveValueAsync("user@example.com");
await Expect(input).ToBeEditableAsync();
var checkbox = Page.GetByRole(AriaRole.Checkbox);
await Expect(checkbox).ToBeCheckedAsync();
await Expect(checkbox).Not.ToBeCheckedAsync();
// Collection assertions
var items = Page.Locator("ul.menu li");
await Expect(items).ToHaveCountAsync(5);
await Expect(items).ToHaveTextAsync(new[] { "Home", "About", "Services", "Blog", "Contact" });
// Negative assertions
await Expect(button).Not.ToBeVisibleAsync();
await Expect(Page.Locator("#loading")).Not.ToBeAttachedAsync();
}
}
Custom Timeout
public class AssertionTimeouts
{
[TestMethod]
public async Task CustomTimeouts()
{
// Per-assertion timeout
await Expect(Page.GetByText("Loaded"))
.ToBeVisibleAsync(new() { Timeout = 10000 });
// Soft assertions (don't stop test on failure)
await Expect(Page.GetByText("Optional element"))
.ToBeVisibleAsync(new() { Timeout = 1000 });
}
}
Page Object Model
Base Page
// PageObjects/BasePage.cs
public abstract class BasePage
{
protected readonly IPage Page;
protected BasePage(IPage page)
{
Page = page;
}
// Common elements
protected ILocator Header => Page.GetByRole(AriaRole.Banner);
protected ILocator Footer => Page.GetByRole(AriaRole.Contentinfo);
protected ILocator LoadingSpinner => Page.Locator("[data-testid='loading']");
// Common methods
public async Task WaitForLoadAsync()
{
await Expect(LoadingSpinner).Not.ToBeVisibleAsync();
}
public async Task<string> GetPageTitleAsync()
{
return await Page.TitleAsync();
}
public async Task NavigateToAsync(string path)
{
await Page.GotoAsync($"{BaseUrl}{path}");
await WaitForLoadAsync();
}
protected virtual string BaseUrl => "https://example.com";
}
Login Page
// PageObjects/LoginPage.cs
public class LoginPage : BasePage
{
public LoginPage(IPage page) : base(page) { }
// Locators
private ILocator EmailInput => Page.GetByLabel("Email");
private ILocator PasswordInput => Page.GetByLabel("Password");
private ILocator LoginButton => Page.GetByRole(AriaRole.Button, new() { Name = "Login" });
private ILocator ErrorMessage => Page.GetByRole(AriaRole.Alert);
private ILocator ForgotPasswordLink => Page.GetByRole(AriaRole.Link, new() { Name = "Forgot password?" });
// Actions
public async Task<LoginPage> NavigateAsync()
{
await Page.GotoAsync($"{BaseUrl}/login");
await WaitForLoadAsync();
return this;
}
public async Task EnterEmailAsync(string email)
{
await EmailInput.FillAsync(email);
}
public async Task EnterPasswordAsync(string password)
{
await PasswordInput.FillAsync(password);
}
public async Task ClickLoginAsync()
{
await LoginButton.ClickAsync();
}
public async Task<DashboardPage> LoginAsAsync(string email, string password)
{
await EnterEmailAsync(email);
await EnterPasswordAsync(password);
await ClickLoginAsync();
await WaitForLoadAsync();
return new DashboardPage(Page);
}
// Assertions
public async Task AssertOnLoginPageAsync()
{
await Expect(Page).ToHaveURLAsync(new Regex(".*/login"));
await Expect(LoginButton).ToBeVisibleAsync();
}
public async Task AssertErrorMessageAsync(string expectedMessage)
{
await Expect(ErrorMessage).ToBeVisibleAsync();
await Expect(ErrorMessage).ToHaveTextAsync(expectedMessage);
}
}
Dashboard Page
// PageObjects/DashboardPage.cs
public class DashboardPage : BasePage
{
public DashboardPage(IPage page) : base(page) { }
// Locators
private ILocator WelcomeMessage => Page.GetByRole(AriaRole.Heading, new() { Level = 1 });
private ILocator UserMenu => Page.GetByTestId("user-menu");
private ILocator LogoutButton => Page.GetByRole(AriaRole.Button, new() { Name = "Logout" });
private ILocator OrdersTable => Page.GetByRole(AriaRole.Table);
// Actions
public async Task<LoginPage> LogoutAsync()
{
await UserMenu.ClickAsync();
await LogoutButton.ClickAsync();
return new LoginPage(Page);
}
public async Task<int> GetOrderCountAsync()
{
var rows = OrdersTable.Locator("tbody tr");
return await rows.CountAsync();
}
// Assertions
public async Task AssertOnDashboardAsync()
{
await Expect(Page).ToHaveURLAsync(new Regex(".*/dashboard"));
await Expect(WelcomeMessage).ToBeVisibleAsync();
}
public async Task AssertWelcomeMessageAsync(string username)
{
await Expect(WelcomeMessage).ToHaveTextAsync($"Welcome, {username}");
}
}
Test Using Page Objects
// Tests/LoginTests.cs
[TestClass]
public class LoginTests : PageTest
{
private LoginPage _loginPage = null!;
[TestInitialize]
public async Task Setup()
{
_loginPage = new LoginPage(Page);
await _loginPage.NavigateAsync();
}
[TestMethod]
public async Task Login_WithValidCredentials_NavigatesToDashboard()
{
var dashboard = await _loginPage.LoginAsAsync("user@example.com", "password123");
await dashboard.AssertOnDashboardAsync();
await dashboard.AssertWelcomeMessageAsync("John Doe");
}
[TestMethod]
public async Task Login_WithInvalidCredentials_ShowsError()
{
await _loginPage.LoginAsAsync("invalid@example.com", "wrongpassword");
await _loginPage.AssertErrorMessageAsync("Invalid email or password");
}
[TestMethod]
[DataRow("", "password", "Email is required")]
[DataRow("user@example.com", "", "Password is required")]
public async Task Login_WithMissingFields_ShowsValidationError(
string email, string password, string expectedError)
{
await _loginPage.EnterEmailAsync(email);
await _loginPage.EnterPasswordAsync(password);
await _loginPage.ClickLoginAsync();
await _loginPage.AssertErrorMessageAsync(expectedError);
}
}
Handling Async Operations
Waiting Strategies
public class WaitingStrategies
{
[TestMethod]
public async Task WaitForElements()
{
await Page.GotoAsync("https://example.com/async-page");
// Wait for element to be visible
await Page.GetByTestId("dynamic-content").WaitForAsync();
// Wait for element to be hidden
await Page.Locator("#loading").WaitForAsync(new() { State = WaitForSelectorState.Hidden });
// Wait for element to be attached to DOM
await Page.Locator("#new-element").WaitForAsync(new() { State = WaitForSelectorState.Attached });
// Wait for element to be detached
await Page.Locator("#removed-element").WaitForAsync(new() { State = WaitForSelectorState.Detached });
}
[TestMethod]
public async Task WaitForNetwork()
{
// Wait for navigation
await Page.GotoAsync("https://example.com");
await Page.GetByRole(AriaRole.Link, new() { Name = "Dashboard" }).ClickAsync();
await Page.WaitForURLAsync("**/dashboard");
// Wait for specific request
var request = await Page.WaitForRequestAsync("**/api/users");
Console.WriteLine($"Request URL: {request.Url}");
// Wait for response
var response = await Page.WaitForResponseAsync(r =>
r.Url.Contains("/api/data") && r.Status == 200);
var json = await response.JsonAsync();
// Wait for request and click simultaneously
await Task.WhenAll(
Page.WaitForRequestAsync("**/api/submit"),
Page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).ClickAsync()
);
}
[TestMethod]
public async Task WaitForLoadState()
{
await Page.GotoAsync("https://example.com");
// Wait for DOM content loaded
await Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
// Wait for all resources loaded
await Page.WaitForLoadStateAsync(LoadState.Load);
// Wait for network idle
await Page.WaitForLoadStateAsync(LoadState.NetworkIdle);
}
[TestMethod]
public async Task WaitForFunction()
{
await Page.GotoAsync("https://example.com/spa");
// Wait for JavaScript condition
await Page.WaitForFunctionAsync("() => window.appReady === true");
// Wait for element count
await Page.WaitForFunctionAsync("() => document.querySelectorAll('.item').length > 5");
}
}
Network Interception
public class NetworkInterception
{
[TestMethod]
public async Task MockApiResponse()
{
// Intercept API call and return mock data
await Page.RouteAsync("**/api/users", async route =>
{
await route.FulfillAsync(new()
{
Status = 200,
ContentType = "application/json",
Body = JsonSerializer.Serialize(new[]
{
new { Id = 1, Name = "John" },
new { Id = 2, Name = "Jane" }
})
});
});
await Page.GotoAsync("https://example.com/users");
await Expect(Page.GetByText("John")).ToBeVisibleAsync();
}
[TestMethod]
public async Task ModifyRequest()
{
await Page.RouteAsync("**/api/**", async route =>
{
var headers = new Dictionary<string, string>(route.Request.Headers)
{
["Authorization"] = "Bearer test-token"
};
await route.ContinueAsync(new() { Headers = headers });
});
}
[TestMethod]
public async Task SimulateNetworkError()
{
await Page.RouteAsync("**/api/submit", async route =>
{
await route.AbortAsync("failed");
});
await Page.GotoAsync("https://example.com/form");
await Page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).ClickAsync();
await Expect(Page.GetByText("Network error")).ToBeVisibleAsync();
}
}
Screenshots and Videos
Screenshots
public class ScreenshotCapture
{
[TestMethod]
public async Task CaptureScreenshots()
{
await Page.GotoAsync("https://example.com");
// Full page screenshot
await Page.ScreenshotAsync(new()
{
Path = "screenshots/fullpage.png",
FullPage = true
});
// Viewport screenshot
await Page.ScreenshotAsync(new()
{
Path = "screenshots/viewport.png"
});
// Element screenshot
await Page.Locator("#header").ScreenshotAsync(new()
{
Path = "screenshots/header.png"
});
// Screenshot as bytes (for comparison)
var bytes = await Page.ScreenshotAsync();
File.WriteAllBytes("screenshot.png", bytes);
// Screenshot options
await Page.ScreenshotAsync(new()
{
Path = "screenshots/custom.png",
Type = ScreenshotType.Jpeg,
Quality = 80,
Clip = new Clip { X = 0, Y = 0, Width = 500, Height = 300 }
});
}
}
Video Recording
// Configure video in browser context
[TestClass]
public class VideoRecording
{
private IBrowser _browser = null!;
private IBrowserContext _context = null!;
private IPage _page = null!;
[TestInitialize]
public async Task Setup()
{
var playwright = await Playwright.CreateAsync();
_browser = await playwright.Chromium.LaunchAsync();
_context = await _browser.NewContextAsync(new()
{
RecordVideoDir = "videos/",
RecordVideoSize = new RecordVideoSize { Width = 1280, Height = 720 }
});
_page = await _context.NewPageAsync();
}
[TestMethod]
public async Task TestWithVideoRecording()
{
await _page.GotoAsync("https://example.com");
// Perform actions...
}
[TestCleanup]
public async Task Teardown()
{
// Must close context to save video
await _context.CloseAsync();
// Get video path
var video = _page.Video;
if (video != null)
{
var path = await video.PathAsync();
Console.WriteLine($"Video saved to: {path}");
}
await _browser.CloseAsync();
}
}
Trace Viewer
public class TraceCapture
{
[TestMethod]
public async Task CaptureTrace()
{
var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.LaunchAsync();
var context = await browser.NewContextAsync();
// Start tracing
await context.Tracing.StartAsync(new()
{
Screenshots = true,
Snapshots = true,
Sources = true
});
var page = await context.NewPageAsync();
await page.GotoAsync("https://example.com");
// Perform test actions...
// Stop and save trace
await context.Tracing.StopAsync(new()
{
Path = "traces/test-trace.zip"
});
// View trace: npx playwright show-trace traces/test-trace.zip
await browser.CloseAsync();
}
}
Cross-Browser Testing
Configuration
// runsettings file for cross-browser
// test.runsettings
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<Playwright>
<BrowserName>chromium</BrowserName>
<LaunchOptions>
<Headless>true</Headless>
</LaunchOptions>
</Playwright>
</RunSettings>
Programmatic Browser Selection
[TestClass]
public class CrossBrowserTests
{
private static IPlaywright _playwright = null!;
private IBrowser _browser = null!;
private IPage _page = null!;
[TestInitialize]
public async Task Setup()
{
_playwright = await Playwright.CreateAsync();
// Get browser from environment variable or default
var browserName = Environment.GetEnvironmentVariable("BROWSER") ?? "chromium";
_browser = browserName switch
{
"firefox" => await _playwright.Firefox.LaunchAsync(),
"webkit" => await _playwright.Webkit.LaunchAsync(),
_ => await _playwright.Chromium.LaunchAsync()
};
_page = await _browser.NewPageAsync();
}
[TestMethod]
public async Task TestAcrossBrowsers()
{
await _page.GotoAsync("https://example.com");
await Expect(_page).ToHaveTitleAsync("Example Domain");
}
[TestCleanup]
public async Task Teardown()
{
await _browser.CloseAsync();
}
}
Device Emulation
public class DeviceEmulation
{
[TestMethod]
public async Task TestOnMobile()
{
var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.LaunchAsync();
// Use predefined device
var iPhone = playwright.Devices["iPhone 13"];
var context = await browser.NewContextAsync(iPhone);
var page = await context.NewPageAsync();
await page.GotoAsync("https://example.com");
// Test mobile-specific elements
await Expect(page.GetByRole(AriaRole.Button, new() { Name = "Menu" })).ToBeVisibleAsync();
}
[TestMethod]
public async Task CustomViewport()
{
var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.LaunchAsync();
var context = await browser.NewContextAsync(new()
{
ViewportSize = new ViewportSize { Width = 375, Height = 812 },
DeviceScaleFactor = 3,
IsMobile = true,
HasTouch = true,
UserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)..."
});
var page = await context.NewPageAsync();
// Test with custom viewport
}
}
CI/CD Integration
GitHub Actions
# .github/workflows/playwright-tests.yml
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Install dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Install Playwright browsers
run: pwsh ./bin/Debug/net8.0/playwright.ps1 install --with-deps
- name: Run tests
run: dotnet test --no-build --logger trx --results-directory TestResults
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: TestResults/
- name: Upload traces
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-traces
path: traces/
Azure DevOps
# azure-pipelines.yml
trigger:
- main
pool:
vmImage: 'ubuntu-latest'
steps:
- task: UseDotNet@2
inputs:
version: '8.0.x'
- script: dotnet restore
displayName: 'Restore dependencies'
- script: dotnet build --no-restore
displayName: 'Build'
- script: pwsh ./bin/Debug/net8.0/playwright.ps1 install --with-deps
displayName: 'Install Playwright browsers'
- script: dotnet test --no-build --logger trx
displayName: 'Run tests'
- task: PublishTestResults@2
inputs:
testResultsFormat: 'VSTest'
testResultsFiles: '**/*.trx'
condition: always()
Comparison with Selenium
Feature Comparison
| Aspect | Playwright | Selenium |
|---|---|---|
| Auto-waiting | Built-in | Manual WebDriverWait |
| Browser download | Automatic | Manual WebDriver management |
| Network mocking | Native API | Browser-specific extensions |
| Multiple contexts | Yes | New driver instance |
| Shadow DOM | Full support | Limited |
| Mobile emulation | Built-in | Appium integration |
| Debugging | Trace viewer, codegen | Browser DevTools |
| Performance | Faster (no HTTP overhead) | WebDriver wire protocol |
Code Comparison
// Selenium
var driver = new ChromeDriver();
var wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10));
driver.Navigate().GoToUrl("https://example.com");
var element = wait.Until(d => d.FindElement(By.Id("username")));
element.SendKeys("user@example.com");
driver.FindElement(By.Id("password")).SendKeys("password");
driver.FindElement(By.CssSelector("button[type='submit']")).Click();
wait.Until(d => d.Url.Contains("/dashboard"));
// Playwright (equivalent)
await Page.GotoAsync("https://example.com");
await Page.Locator("#username").FillAsync("user@example.com");
await Page.Locator("#password").FillAsync("password");
await Page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).ClickAsync();
await Page.WaitForURLAsync("**/dashboard");
Best Practices
1. Use Web-First Assertions
// GOOD: Auto-waits and retries
await Expect(Page.GetByText("Success")).ToBeVisibleAsync();
// AVOID: Manual waits
await Task.Delay(1000); // Don't do this
Assert.IsTrue(Page.GetByText("Success").IsVisibleAsync().Result);
2. Prefer User-Facing Locators
// GOOD: How users find elements
Page.GetByRole(AriaRole.Button, new() { Name = "Submit" });
Page.GetByLabel("Email address");
Page.GetByPlaceholder("Search...");
Page.GetByTestId("checkout-button");
// AVOID: Implementation details
Page.Locator("#btn-xyz123");
Page.Locator(".MuiButton-root-234");
Page.Locator("div > form > div:nth-child(3) > button");
3. Use Page Objects
// GOOD: Encapsulated, reusable
var loginPage = new LoginPage(Page);
await loginPage.LoginAsAsync("user", "pass");
// AVOID: Inline selectors everywhere
await Page.Locator("#email").FillAsync("user");
await Page.Locator("#password").FillAsync("pass");
await Page.Locator("button[type=submit]").ClickAsync();
4. Add Test IDs for Dynamic Elements
<!-- Add data-testid for elements without stable accessible names -->
<button data-testid="add-to-cart-123">Add</button>
5. Run Tests in Parallel
// MSTest - tests run in parallel by default
[TestClass]
public class TestClass1 { }
[TestClass]
public class TestClass2 { }
// Configure max parallelism in .runsettings
Interview Questions
1. What are the advantages of Playwright over Selenium?
Answer:
- Auto-waiting: Automatically waits for elements to be actionable
- Built-in assertions: Web-first assertions with retry logic
- Browser management: Automatic browser download and management
- Network interception: Native API for mocking/modifying requests
- Multiple contexts: Isolated browser sessions in single browser instance
- Debugging tools: Trace viewer, codegen, inspector
- Performance: Direct browser protocol communication (no HTTP overhead)
2. What is the Page Object Model and why is it used?
Answer: Page Object Model (POM) is a design pattern that:
- Encapsulates page elements and actions in dedicated classes
- Reduces code duplication
- Improves test maintainability
- Makes tests more readable
- Localizes changes when UI changes
Example: If a buttonβs selector changes, you only update the Page Object, not every test.
3. How does Playwright handle flaky tests?
Answer: Playwright addresses flakiness through:
- Auto-waiting: Waits for elements to be actionable before interactions
- Web-first assertions: Built-in retry with timeout for assertions
- Stable locators: Role-based and user-facing locators less prone to change
- Trace viewer: Captures screenshots/snapshots for debugging failures
- Network interception: Mock external dependencies for consistency
4. How do you handle authentication in Playwright tests?
Answer:
- Storage state: Save and reuse authentication cookies/storage
- API login: Authenticate via API before UI tests
- Browser context: Share auth state across pages in context
// Save auth state
await context.StorageStateAsync(new() { Path = "auth.json" });
// Reuse in new context
var context = await browser.NewContextAsync(new() { StorageStatePath = "auth.json" });
5. Whatβs the difference between locator.click() and page.click()?
Answer:
locator.click(): Method on Locator object, more composable and chainablepage.click(selector): Convenience method that creates locator internally
Prefer locator.click() for:
- Better IDE support and autocomplete
- Easier to store and reuse locators
- More explicit about what element is being acted upon
Key Takeaways
- Modern locators: Use role-based, label-based, and test-id locators
- Auto-waiting: Playwright handles timing automatically
- Page Object Model: Essential for maintainable tests
- Web-first assertions: Use
Expect()for reliable assertions - Cross-browser: Test on Chromium, Firefox, and WebKit with single API
- Debugging: Use trace viewer and codegen for development