End-to-End Testing with Playwright

A comprehensive guide to building robust, maintainable E2E tests with Playwright in .NET 8, covering patterns and practices for stable test automation.

Author Avatar

Fernando

  ·  12 min read

The Problem with E2E Testing #

After maintaining test suites across enterprise applications, I’ve seen the same pattern repeat: teams start with enthusiasm for E2E testing, then slowly abandon it as flakiness erodes trust. Tests pass on your machine, fail in CI with no code changes, and leave developers hunting ghosts. Race conditions appear intermittently. Test maintenance consumes hours each week chasing false positives.

Playwright for .NET solves these problems through architectural design: auto-waiting eliminates race conditions, built-in retry logic handles transient failures, and browser context isolation prevents state pollution between tests. In my experience implementing these patterns across production applications, we reduced our flaky test rate from 40% to under 5% across a suite of 230 tests—saving roughly 6 hours per week in test maintenance and debugging.

What Makes Playwright Different #

Playwright inverts the traditional testing mindset. In Selenium, you spend half your test code managing waits. In Playwright, waiting happens automatically. Here’s a complete interaction:

1await page.GetByRole(AriaRole.Button, new() { Name = "Login" }).ClickAsync();

That single line handles what traditionally required explicit wait logic. Compare the Selenium equivalent:

1// Selenium approach - manual wait management
2var wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10));
3var button = wait.Until(d => d.FindElement(By.CssSelector(".login-button")));
4wait.Until(d => button.Displayed && button.Enabled);
5button.Click();

No Thread.Sleep(), no WebDriverWait, no polling loops checking if elements are ready. Playwright automatically waits for the button to be:

  • Attached to the DOM
  • Visible on screen (not display: none or visibility: hidden)
  • Stable (not animating—position hasn’t changed for 100ms)
  • Enabled (not disabled attribute or CSS)
  • Not obscured by other elements (no overlays, modals, or z-index conflicts)

This intelligent waiting is built into every action—clicks, fills, assertions—eliminating the most common source of test flakiness. According to Playwright’s official documentation, these actionability checks happen automatically before every action with a default 30-second timeout. Where Selenium tests fail with “element not found” or “element not clickable”, Playwright tests wait intelligently and succeed.

Setting Up Your Test Infrastructure #

A well-structured test project is essential for maintainability and reliability. This section covers the foundational setup patterns for production-ready Playwright tests.

Step 1: Project Setup and Dependencies #

1# Create a new test project
2dotnet new nunit -n MyApp.E2ETests
3cd MyApp.E2ETests
4
5# Install Playwright
6dotnet add package Microsoft.Playwright.NUnit

Install the Microsoft.Playwright.NUnit package, then install the browser binaries:

1# Install browsers (Chromium, Firefox, WebKit)
2pwsh bin/Debug/net8.0/playwright.ps1 install

Step 2: Base Test Class - Your Testing Foundation #

This base class serves as the foundation for test projects, handling setup, teardown, and providing common functionality. The Parallelizable attribute enables parallel test execution:

 1using Microsoft.Playwright;
 2using Microsoft.Playwright.NUnit;
 3
 4namespace MyApp.E2ETests;
 5
 6[Parallelizable(ParallelScope.Self)]
 7public class PlaywrightTest : PageTest
 8{
 9    protected string BaseUrl { get; private set; } = "https://localhost:5001";
10
11    [SetUp]
12    public async Task BaseSetUp()
13    {
14        // Configure default timeouts
15        Page.SetDefaultTimeout(30_000); // 30 seconds
16
17        // Configure viewport for consistent testing
18        await Page.SetViewportSizeAsync(1920, 1080);
19
20        // Optionally enable video recording for failed tests
21        // await Context.Tracing.StartAsync(new() {
22        //     Screenshots = true,
23        //     Snapshots = true
24        // });
25    }
26
27    [TearDown]
28    public async Task BaseTearDown()
29    {
30        // Capture screenshot on test failure
31        if (TestContext.CurrentContext.Result.Outcome.Status == NUnit.Framework.Interfaces.TestStatus.Failed)
32        {
33            var screenshot = await Page.ScreenshotAsync();
34            await File.WriteAllBytesAsync(
35                $"screenshots/{TestContext.CurrentContext.Test.Name}_{DateTime.UtcNow:yyyyMMdd_HHmmss}.png",
36                screenshot
37            );
38
39            // Save trace for debugging
40            // await Context.Tracing.StopAsync(new() {
41            //     Path = $"traces/{TestContext.CurrentContext.Test.Name}.zip"
42            // });
43        }
44    }
45
46    // Helper method for common navigation patterns
47    protected async Task NavigateAndWaitForLoadAsync(string path)
48    {
49        await Page.GotoAsync($"{BaseUrl}{path}");
50        await Page.WaitForLoadStateAsync(LoadState.NetworkIdle);
51    }
52}

Step 3: Configuration File #

Create playwright.config.json in your test project:

 1{
 2  "timeout": 30000,
 3  "expect": {
 4    "timeout": 5000
 5  },
 6  "testConfig": {
 7    "workers": 4,
 8    "retries": 2,
 9    "trace": "on-first-retry",
10    "screenshot": "only-on-failure",
11    "video": "retain-on-failure"
12  },
13  "projects": [
14    {
15      "name": "chromium",
16      "use": {
17        "browserName": "chromium",
18        "viewport": { "width": 1920, "height": 1080 }
19      }
20    },
21    {
22      "name": "firefox",
23      "use": {
24        "browserName": "firefox"
25      }
26    }
27  ]
28}

Why Do Selector Strategies Matter? #

Your selector strategy determines whether tests survive UI refactoring or break with every CSS change. CSS selectors like .btn-primary or #submit-btn are fragile—they couple tests to implementation details. When your CSS class names change during a redesign, every test using those selectors breaks.

Semantic selectors based on user-visible roles and labels remain stable because they mirror how users actually interact with your application. They represent the contract between your application and its users—a contract that rarely changes even during major refactoring.

The Selector Hierarchy #

Playwright recommends a specific priority order for selectors. Following this hierarchy improves test reliability:

1. Role-Based Selectors (Best) - Mirrors how users and assistive technologies see your page:

 1// Find by semantic role
 2await page.GetByRole(AriaRole.Button, new() { Name = "Sign in" }).ClickAsync();
 3
 4// Find by role with additional filters
 5await page.GetByRole(AriaRole.Textbox, new() { Name = "Email" }).FillAsync("user@example.com");
 6
 7// Find headings
 8await page.GetByRole(AriaRole.Heading, new() { Name = "Dashboard" }).IsVisibleAsync();
 9
10// Find links
11await page.GetByRole(AriaRole.Link, new() { Name = "Learn more" }).ClickAsync();

Why role-based selectors matter: When a role selector breaks, it’s often not the test that’s broken—it’s your accessibility. If Playwright can’t find GetByRole(AriaRole.Button), screen readers can’t either. Tests using role selectors double as accessibility validators, catching issues that affect real users with assistive technologies.

2. Label-Based Selectors - For form controls:

1await page.GetByLabel("Password").FillAsync("secretpassword");
2await page.GetByLabel("Email").FillAsync("test@example.com");

3. Test ID Selectors - For dynamic content where semantic selectors aren’t available:

1await page.GetByTestId("submit-button").ClickAsync();
2// In your component: <button data-testid="submit-button">Submit</button>

Real-World Selector Example: Login Form #

This example demonstrates the selector hierarchy in practice:

 1[Test]
 2public async Task UserCanLoginSuccessfully()
 3{
 4    await NavigateAndWaitForLoadAsync("/login");
 5
 6    // BAD: Fragile CSS selectors
 7    // await page.Locator("#email-input").FillAsync("user@example.com");
 8    // await page.Locator(".password-field").FillAsync("password123");
 9    // await page.Locator("button.btn-submit").ClickAsync();
10
11    // BETTER: Use labels and roles
12    await page.GetByLabel("Email").FillAsync("user@example.com");
13    await page.GetByLabel("Password").FillAsync("password123");
14    await page.GetByRole(AriaRole.Button, new() { Name = "Sign in" }).ClickAsync();
15
16    // Verify success - check for specific page element
17    await Expect(page.GetByRole(AriaRole.Heading, new() { Name = "Dashboard" }))
18        .ToBeVisibleAsync();
19
20    // Alternative: Check URL changed
21    await Expect(page).ToHaveURLAsync(new Regex(".*/dashboard"));
22}

Anti-Flaky Patterns #

These patterns help eliminate common sources of test flakiness:

Pattern 1: Wait for Specific Conditions, Not Time

1// BAD: Arbitrary waits
2await Task.Delay(2000);
3
4// GOOD: Wait for specific state
5await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
6
7// BETTER: Wait for specific element
8await page.GetByRole(AriaRole.Button, new() { Name = "Submit" })
9    .WaitForAsync(new() { State = WaitForSelectorState.Visible });

Pattern 2: Use Playwright Assertions for Auto-Retry

1// BAD: Assertion without retry
2Assert.IsTrue(await page.GetByText("Success").IsVisibleAsync());
3
4// GOOD: Playwright assertions retry until timeout
5await Expect(page.GetByText("Success")).ToBeVisibleAsync();
6
7// Also works for counts, text content, etc.
8await Expect(page.GetByRole(AriaRole.Listitem)).ToHaveCountAsync(5);
9await Expect(page.GetByRole(AriaRole.Heading)).ToHaveTextAsync("Dashboard");

Pattern 3: Handle Dynamic Content Properly

 1[Test]
 2public async Task ShouldHandleAsyncDataLoading()
 3{
 4    await NavigateAndWaitForLoadAsync("/products");
 5
 6    // Wait for loading spinner to disappear
 7    await Expect(page.GetByTestId("loading-spinner"))
 8        .Not.ToBeVisibleAsync();
 9
10    // Now verify content loaded
11    await Expect(page.GetByRole(AriaRole.Listitem))
12        .ToHaveCountAsync(10);
13
14    // Verify specific data appears
15    await Expect(page.GetByText("Product Name"))
16        .ToBeVisibleAsync();
17}

How Do You Organize Tests That Scale? #

As test suites grow, raw Playwright calls scattered across test files become unmaintainable. Consider a team with 200+ tests: when a button ID changes, you’re updating selectors in dozens of test files. One team I worked with spent 8 hours tracking down a single selector change across 43 test files.

The Page Object pattern encapsulates page interactions into reusable classes, improving organization and reducing duplication. When implemented properly, that same selector change becomes a single-line update in one file, taking 30 seconds instead of 8 hours.

Basic Page Object #

 1public class LoginPage
 2{
 3    private readonly IPage _page;
 4    private readonly string _url;
 5
 6    // Locators as properties - evaluated lazily
 7    private ILocator EmailInput => _page.GetByLabel("Email");
 8    private ILocator PasswordInput => _page.GetByLabel("Password");
 9    private ILocator SignInButton => _page.GetByRole(AriaRole.Button, new() { Name = "Sign in" });
10    private ILocator ErrorMessage => _page.GetByTestId("error-message");
11
12    public LoginPage(IPage page, string baseUrl)
13    {
14        _page = page;
15        _url = $"{baseUrl}/login";
16    }
17
18    // Actions
19    public async Task NavigateAsync()
20    {
21        await _page.GotoAsync(_url);
22        await _page.WaitForLoadStateAsync(LoadState.NetworkIdle);
23    }
24
25    public async Task LoginAsync(string email, string password)
26    {
27        await EmailInput.FillAsync(email);
28        await PasswordInput.FillAsync(password);
29        await SignInButton.ClickAsync();
30    }
31
32    // Assertions
33    public async Task AssertErrorMessageVisibleAsync(string expectedMessage)
34    {
35        await Expect(ErrorMessage).ToBeVisibleAsync();
36        await Expect(ErrorMessage).ToHaveTextAsync(expectedMessage);
37    }
38}

Using the Page Object #

 1[Test]
 2public async Task ShouldShowErrorForInvalidCredentials()
 3{
 4    var loginPage = new LoginPage(Page, BaseUrl);
 5
 6    await loginPage.NavigateAsync();
 7    await loginPage.LoginAsync("invalid@example.com", "wrongpassword");
 8    await loginPage.AssertErrorMessageVisibleAsync("Invalid credentials");
 9}
10
11[Test]
12public async Task ShouldRedirectToDashboardAfterSuccessfulLogin()
13{
14    var loginPage = new LoginPage(Page, BaseUrl);
15
16    await loginPage.NavigateAsync();
17    await loginPage.LoginAsync("valid@example.com", "correctpassword");
18
19    // Verify redirect
20    await Expect(Page).ToHaveURLAsync(new Regex(".*/dashboard"));
21}

Advanced Page Object with Fluent API #

For complex user journeys, fluent interfaces provide better readability:

 1public class CheckoutFlow
 2{
 3    private readonly IPage _page;
 4    private readonly string _baseUrl;
 5
 6    public CheckoutFlow(IPage page, string baseUrl)
 7    {
 8        _page = page;
 9        _baseUrl = baseUrl;
10    }
11
12    public async Task<CheckoutFlow> NavigateToProductAsync(string productName)
13    {
14        await _page.GotoAsync($"{_baseUrl}/products");
15        await _page.GetByRole(AriaRole.Link, new() { Name = productName }).ClickAsync();
16        return this;
17    }
18
19    public async Task<CheckoutFlow> AddToCartAsync()
20    {
21        await _page.GetByRole(AriaRole.Button, new() { Name = "Add to Cart" }).ClickAsync();
22
23        // Wait for confirmation
24        await Expect(_page.GetByText("Added to cart")).ToBeVisibleAsync();
25        return this;
26    }
27
28    public async Task<CheckoutFlow> ProceedToCheckoutAsync()
29    {
30        await _page.GetByRole(AriaRole.Link, new() { Name = "Cart" }).ClickAsync();
31        await _page.GetByRole(AriaRole.Button, new() { Name = "Checkout" }).ClickAsync();
32        return this;
33    }
34
35    public async Task<CheckoutFlow> FillShippingDetailsAsync(ShippingInfo info)
36    {
37        await _page.GetByLabel("Full Name").FillAsync(info.FullName);
38        await _page.GetByLabel("Address").FillAsync(info.Address);
39        await _page.GetByLabel("City").FillAsync(info.City);
40        await _page.GetByLabel("Postal Code").FillAsync(info.PostalCode);
41        return this;
42    }
43
44    public async Task<CheckoutFlow> FillPaymentDetailsAsync(PaymentInfo info)
45    {
46        await _page.GetByLabel("Card Number").FillAsync(info.CardNumber);
47        await _page.GetByLabel("Expiry Date").FillAsync(info.ExpiryDate);
48        await _page.GetByLabel("CVV").FillAsync(info.CVV);
49        return this;
50    }
51
52    public async Task CompleteOrderAsync()
53    {
54        await _page.GetByRole(AriaRole.Button, new() { Name = "Place Order" }).ClickAsync();
55
56        // Wait for confirmation page
57        await Expect(_page.GetByRole(AriaRole.Heading, new() { Name = "Order Confirmed" }))
58            .ToBeVisibleAsync();
59    }
60}

Using the Fluent Page Object #

 1[Test]
 2public async Task ShouldCompleteFullCheckoutJourney()
 3{
 4    var checkout = new CheckoutFlow(Page, BaseUrl);
 5
 6    await checkout
 7        .NavigateToProductAsync("Premium Widget")
 8        .AddToCartAsync()
 9        .ProceedToCheckoutAsync()
10        .FillShippingDetailsAsync(new ShippingInfo
11        {
12            FullName = "John Doe",
13            Address = "123 Main St",
14            City = "Springfield",
15            PostalCode = "12345"
16        })
17        .FillPaymentDetailsAsync(new PaymentInfo
18        {
19            CardNumber = "4111111111111111",
20            ExpiryDate = "12/25",
21            CVV = "123"
22        })
23        .CompleteOrderAsync();
24
25    // Verify order number appears
26    await Expect(Page.GetByTestId("order-number"))
27        .ToBeVisibleAsync();
28}

Cross-Browser Testing #

Playwright provides a consistent API across Chromium, Firefox, and WebKit—write tests once, run them everywhere. The built-in BrowserTest base classes handle browser lifecycle automatically.

For most teams, testing in Chromium covers 80% of browser issues. Add Firefox and WebKit when you need coverage for browser-specific rendering or JavaScript engine differences. Configure multiple browser contexts in your test setup, or use NUnit’s TestFixture parameterization to run the same test logic across browser types. See the official Playwright .NET documentation for implementation details.

Test Data Management: Keeping Tests Independent #

Tests that share data create intermittent failures—one test modifies state, another test expects pristine data, and you get unpredictable failures that disappear when you run tests individually. I’ve debugged test suites where 20% of failures were caused by shared test data conflicts, not actual bugs.

Independent test data ensures reliable, repeatable test execution.

Generate unique test data for each test run to avoid conflicts:

1var testUser = new TestUser
2{
3    Email = $"test_{Guid.NewGuid()}@example.com",
4    Password = "TestPass123!",
5    Role = UserRole.Admin
6};

Set up data via API instead of UI for speed. Creating a user through the UI might take 15 seconds (navigation, form fills, validations). The same operation via API takes 200ms—a 75x speedup. For a test suite with 100 tests each creating a user, that’s 25 minutes of saved execution time. Create test data through HTTP endpoints, then validate behavior through the UI:

 1[Test]
 2public async Task ShouldDisplayUserOrders()
 3{
 4    // Setup via API (fast)
 5    var user = await CreateUserViaApiAsync();
 6    await CreateOrderViaApiAsync(user.Id, "Widget", quantity: 2);
 7
 8    // Test via UI
 9    await LoginAsUserAsync(user);
10    await Page.GetByRole(AriaRole.Link, new() { Name = "My Orders" }).ClickAsync();
11    await Expect(Page.GetByText("Widget")).ToBeVisibleAsync();
12}

Debugging Failed Tests #

Playwright provides comprehensive debugging tools for analyzing test failures:

Trace Viewer - Captures screenshots, snapshots, and network activity:

 1[SetUp]
 2public async Task Setup()
 3{
 4    await Context.Tracing.StartAsync(new()
 5    {
 6        Screenshots = true,
 7        Snapshots = true,
 8        Sources = true
 9    });
10}
11
12[TearDown]
13public async Task Teardown()
14{
15    if (TestContext.CurrentContext.Result.Outcome.Status == TestStatus.Failed)
16    {
17        await Context.Tracing.StopAsync(new()
18        {
19            Path = $"traces/{TestContext.CurrentContext.Test.Name}.zip"
20        });
21    }
22}

View traces with: pwsh bin/Debug/net8.0/playwright.ps1 show-trace traces/test.zip

Other Debugging Options:

  • Slow-Mo Mode: Add SlowMo = 1000 to LaunchAsync() to slow down operations visually
  • Debug Inspector: Set environment variable PWDEBUG=1 or call await Page.PauseAsync() to pause execution

CI/CD Integration: Making It Production-Ready #

Running Playwright tests in your CI/CD pipeline is essential for catching issues before they reach production. For a comprehensive guide on integrating Playwright with Azure DevOps pipelines, including parallel execution strategies, sharding, and best practices for production environments, check out my detailed article: Playwright in CI/CD: Automated Testing for .NET Applications in Azure DevOps.

References and Further Reading #

Official Documentation #

Design Patterns #

Testing Best Practices #


Building Tests That Last #

Playwright for .NET solves the fundamental problems that make E2E testing painful: race conditions, brittle selectors, and test interdependencies. The patterns in this guide—Page Objects, API-based test data, semantic selectors—transform test suites from brittle maintenance burdens into reliable safety nets.

Start with these patterns on day one. Your future self, debugging a flaky CI pipeline at 10pm, will thank you.