# Web Testing with Playwright

Learn how to write browser-based end-to-end tests using Playwright with Aspire's `DistributedApplicationTestingBuilder`.

[TOC]

## 📋 Overview

| Property          | Value                                                     |
| ----------------- | --------------------------------------------------------- |
| **Goal**          | Test web applications using Playwright browser automation |
| **Prerequisites** | .NET 10 SDK, Aspire project with AppHost, Node.js         |
| **Time**          | 20 minutes                                                |

This guide demonstrates how to create end-to-end tests for your web application using Playwright and Aspire's distributed testing framework.

## 🔗 Related Resources

- [Aspire Testing Documentation](https://learn.microsoft.com/en-us/dotnet/aspire/testing/overview)
- [Playwright .NET Documentation](https://playwright.dev/dotnet/)
- [API Integration Testing](./api-integration-testing.md)

---

## 📦 Required Packages

Add the following NuGet packages to your test project:

```xml
<PackageReference Include="Aspire.Hosting.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.Playwright" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
    <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    <PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
    <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    <PrivateAssets>all</PrivateAssets>
</PackageReference>
```

Also add a project reference to your AppHost:

```xml
<ProjectReference Include="..\YourAppHost\YourAppHost.csproj" />
```

---

## 🧪 Test Structure

### Namespaces

```csharp
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Playwright;
```

### Test Class with Shared Lifecycle

Use `IAsyncLifetime` to manage the distributed application and browser lifecycle across tests:

```csharp
public class WebTests : IAsyncLifetime
{
    private DistributedApplication? _app;
    private IPlaywright? _playwright;
    private IBrowser? _browser;
    private string? _frontendUrl;

    public async Task InitializeAsync()
    {
        // Build and start the distributed application
        var appHost = await DistributedApplicationTestingBuilder
            .CreateAsync<Projects.YourAppHost>();

        // Configure services for testing
        appHost.Services.ConfigureHttpClientDefaults(clientBuilder =>
        {
            clientBuilder.AddStandardResilienceHandler();
        });

        _app = await appHost.BuildAsync();
        await _app.StartAsync();

        // Wait for resources to be ready
        var resourceNotificationService = _app.Services
            .GetRequiredService<ResourceNotificationService>();
        await resourceNotificationService
            .WaitForResourceAsync("frontend", KnownResourceStates.Running)
            .WaitAsync(TimeSpan.FromSeconds(60));

        // Get the frontend URL
        var httpClient = _app.CreateHttpClient("frontend");
        _frontendUrl = httpClient.BaseAddress?.ToString();

        // Initialize Playwright
        _playwright = await Playwright.CreateAsync();
        _browser = await _playwright.Chromium.LaunchAsync(
            new BrowserTypeLaunchOptions { Headless = true });
    }

    public async Task DisposeAsync()
    {
        if (_browser is not null) await _browser.CloseAsync();
        _playwright?.Dispose();

        if (_app is not null)
        {
            await _app.StopAsync();
            await _app.DisposeAsync();
        }
    }
}
```

### Playwright Browser Installation

Add a module initializer to automatically install browsers:

```csharp
using System.Runtime.CompilerServices;

public static class PlaywrightSetup
{
    [ModuleInitializer]
    public static void Initialize()
    {
        // Install Playwright browsers on first run
        Microsoft.Playwright.Program.Main(["install", "--with-deps", "chromium"]);
    }
}
```

---

## 📝 Example: Testing Page Content

This test verifies that the weather table displays the expected number of records.

### Steps

1. **Arrange**: Use the shared browser from the test class
2. **Act**: Navigate to the page and wait for data to load
3. **Assert**: Verify the page content

### Code

```csharp
[Fact]
public async Task WeatherTable_Should_Display_5_Records()
{
    // Arrange
    var page = await _browser!.NewPageAsync();

    try
    {
        // Act
        await page.GotoAsync(_frontendUrl!, new PageGotoOptions
        {
            WaitUntil = WaitUntilState.NetworkIdle,
            Timeout = 30000
        });

        // Wait for the weather table to load
        await page.WaitForSelectorAsync("[data-testid='weather-table']",
            new PageWaitForSelectorOptions { Timeout = 10000 });

        // Assert - Check that there are exactly 5 weather records
        var rows = await page.Locator("[data-testid='weather-tbody'] tr").AllAsync();
        Assert.Equal(5, rows.Count);

        // Verify the record count display
        var recordCountText = await page.Locator("[data-testid='record-count']").TextContentAsync();
        Assert.Contains("5", recordCountText);
    }
    finally
    {
        await page.CloseAsync();
    }
}
```

### Example: Testing User Interactions

```csharp
[Fact]
public async Task RefreshButton_Should_Update_Weather_Data()
{
    var page = await _browser!.NewPageAsync();

    try
    {
        await page.GotoAsync(_frontendUrl!, new PageGotoOptions
        {
            WaitUntil = WaitUntilState.NetworkIdle,
            Timeout = 30000
        });

        await page.WaitForSelectorAsync("[data-testid='weather-table']",
            new PageWaitForSelectorOptions { Timeout = 10000 });

        // Capture initial data
        var initialRows = await page.Locator("[data-testid='weather-tbody'] tr").AllAsync();
        var initialData = new List<string>();
        foreach (var row in initialRows)
        {
            initialData.Add(await row.TextContentAsync() ?? string.Empty);
        }

        // Click the refresh button
        await page.Locator("[data-testid='refresh-button']").ClickAsync();
        await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
        await Task.Delay(500); // Allow React to update DOM

        // Verify refresh count increased
        var refreshCountText = await page.Locator("[data-testid='refresh-count']").TextContentAsync();
        Assert.Contains("1", refreshCountText);
    }
    finally
    {
        await page.CloseAsync();
    }
}
```

---

## 🎬 Code Generation with Playwright

Playwright CLI can generate test code by recording your browser actions, making it faster to develop end-to-end tests.

### Steps

1. **Install Playwright CLI**:

   ```bash
   npm install -g playwright@latest
   ```

2. **Start your AppHost**:

   ```bash
   dotnet run --project YourAppHost
   ```

3. **Start a recording session**:

   ```bash
   npx playwright codegen http://localhost:5000/
   ```

   A Playwright Inspector window and browser will open.

4. **Record your actions**:
   - Perform the actions you want to test
   - Use the movable menu bar for assertions

5. **Copy the generated code**:
   - In the Playwright Inspector, change `Target` to `NUnit`
   - Copy the generated code to your test file

6. **Adapt for xUnit**:

   > ⚠️ **Note**: The Playwright Inspector does not currently support xUnit as a target. You'll need to adapt the NUnit code:

   ```csharp
   // NUnit generated code
   await Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync("Hello");

   // Adapted for xUnit
   await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync("Hello");
   ```

7. **Close the browser** to end the recording session.

---

## 🔑 Key Concepts

### Browser Launch Options

Configure how Playwright launches the browser:

```csharp
var browser = await Playwright.Chromium.LaunchAsync(
    new BrowserTypeLaunchOptions
    {
        Headless = true,      // Run without visible browser window
        SlowMo = 100          // Slow down actions for debugging
    });
```

### Page Locators

Use Playwright's built-in locators for reliable element selection:

```csharp
// By role (recommended)
page.GetByRole(AriaRole.Button, new() { Name = "Submit" })

// By text
page.GetByText("Hello World")

// By test ID
page.GetByTestId("submit-button")

// By CSS selector
page.Locator("[data-testid='weather-table']")
```

### Assertions

Playwright provides built-in assertions that auto-wait:

```csharp
// Text content
await Assertions.Expect(page.GetByRole(AriaRole.Main))
    .ToContainTextAsync("Expected text");

// Visibility
await Assertions.Expect(page.Locator("#element"))
    .ToBeVisibleAsync();

// Count
await Assertions.Expect(page.Locator("tr"))
    .ToHaveCountAsync(5);
```

---

## 💡 Best Practices

1. **Use `IAsyncLifetime`**: Share browser and app lifecycle across tests for efficiency
2. **Wait for resources**: Use `ResourceNotificationService` to ensure services are ready
3. **Use `data-testid` attributes**: Add test IDs to elements for stable selectors
4. **Use `try/finally`**: Always close pages in a `finally` block to ensure cleanup
5. **Run headless in CI**: Set `Headless = true` for CI/CD pipelines
6. **Use `WaitForSelectorAsync`**: Wait for elements before interacting with them
7. **Module initializer for browsers**: Use `[ModuleInitializer]` to install browsers automatically

---

## 🔗 Related Documentation

- [Aspire Overview](../../../reference/tools/aspire.md)
- [API Integration Testing](./api-integration-testing.md)
- [Integration Test Environment Variables](./integration-test-environment-variables.md)
- [Aspire Playwright Foundry Example](../../../foundry/aspire-playwright.md)
