πŸ§ͺ

End-to-End Testing with Playwright

Testing Intermediate 4 min read 800 words

Comprehensive guide to browser automation and E2E testing with Playwright for .NET

Testing

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

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

  1. Auto-waiting: Automatically waits for elements to be actionable
  2. Reliable selectors: Built-in retry logic for flaky tests
  3. Cross-browser: Single API for all browsers
  4. Parallel execution: Built-in parallelization
  5. 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:

  1. Storage state: Save and reuse authentication cookies/storage
  2. API login: Authenticate via API before UI tests
  3. 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 chainable
  • page.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

  1. Modern locators: Use role-based, label-based, and test-id locators
  2. Auto-waiting: Playwright handles timing automatically
  3. Page Object Model: Essential for maintainable tests
  4. Web-first assertions: Use Expect() for reliable assertions
  5. Cross-browser: Test on Chromium, Firefox, and WebKit with single API
  6. Debugging: Use trace viewer and codegen for development

Further Reading

πŸ“š Related Articles