dotnet-playwright浏览器自动化测试技能 dotnet-playwright

这个技能用于在.NET环境中使用Playwright进行浏览器自动化和端到端测试,支持CI浏览器缓存、跟踪查看器和代码生成,提高测试效率和可靠性。关键词:.NET, Playwright, 浏览器自动化, E2E测试, CI/CD, 测试框架, 端到端测试, 测试自动化。

测试 0 次安装 0 次浏览 更新于 3/6/2026

名称: dotnet-playwright 描述: “在.NET中自动化浏览器测试。Playwright端到端测试,CI浏览器缓存,跟踪查看器,代码生成。” 用户可调用: false

dotnet-playwright

Playwright for .NET:浏览器自动化和端到端测试。涵盖浏览器生命周期管理、页面交互、断言、CI缓存浏览器二进制文件、跟踪查看器用于调试失败,以及代码生成用于快速测试脚手架。

版本假设: Playwright 1.40+ for .NET,.NET 8.0+ 基线。Playwright支持Chromium、Firefox和WebKit浏览器。

范围

  • 浏览器生命周期管理(Chromium、Firefox、WebKit)
  • 页面交互和基于定位器的断言
  • CI缓存浏览器二进制文件
  • 跟踪查看器用于调试测试失败
  • 代码生成用于快速测试脚手架

超出范围

  • 共享UI测试模式(页面对象模型、选择器、等待策略)——参见 [技能:dotnet-ui-testing-core]
  • 测试策略(何时使用E2E vs 单元 vs 集成)——参见 [技能:dotnet-testing-strategy]
  • 测试项目脚手架——参见 [技能:dotnet-add-testing]

先决条件: 通过 [技能:dotnet-add-testing] 搭建的测试项目,并引用Playwright包。通过 pwsh bin/Debug/net8.0/playwright.ps1 installdotnet tool run playwright install 安装浏览器。

交叉引用: [技能:dotnet-ui-testing-core] 用于页面对象模型和选择器策略,[技能:dotnet-testing-strategy] 用于决定何时E2E测试合适。


包设置

<PackageReference Include="Microsoft.Playwright" Version="1.*" />
<!-- For xUnit integration: -->
<PackageReference Include="Microsoft.Playwright.Xunit" Version="1.*" />
<!-- For NUnit integration: -->
<!-- <PackageReference Include="Microsoft.Playwright.NUnit" Version="1.*" /> -->

安装浏览器

Playwright需要下载浏览器二进制文件才能运行测试:

# After building the test project:
pwsh bin/Debug/net8.0/playwright.ps1 install

# Or install specific browsers:
pwsh bin/Debug/net8.0/playwright.ps1 install chromium
pwsh bin/Debug/net8.0/playwright.ps1 install firefox

# Using dotnet tool:
dotnet tool install --global Microsoft.Playwright.CLI
playwright install

基本测试结构

使用Playwright xUnit基类

using Microsoft.Playwright;
using Microsoft.Playwright.Xunit;

// PageTest provides Page, Browser, BrowserContext, and Playwright properties
public class HomePageTests : PageTest
{
    [Fact]
    public async Task HomePage_Title_ContainsAppName()
    {
        await Page.GotoAsync("https://localhost:5001");

        await Expect(Page).ToHaveTitleAsync(new Regex("My App"));
    }

    [Fact]
    public async Task HomePage_NavLinks_AreVisible()
    {
        await Page.GotoAsync("https://localhost:5001");

        var nav = Page.Locator("nav");
        await Expect(nav.GetByRole(AriaRole.Link, new() { Name = "Home" }))
            .ToBeVisibleAsync();
        await Expect(nav.GetByRole(AriaRole.Link, new() { Name = "About" }))
            .ToBeVisibleAsync();
    }
}

手动设置(无基类)

public class ManualSetupTests : IAsyncLifetime
{
    private IPlaywright _playwright = null!;
    private IBrowser _browser = null!;
    private IBrowserContext _context = null!;
    private IPage _page = null!;

    public async ValueTask InitializeAsync()
    {
        _playwright = await Playwright.CreateAsync();
        _browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
        {
            Headless = true
        });
        _context = await _browser.NewContextAsync(new BrowserNewContextOptions
        {
            ViewportSize = new ViewportSize { Width = 1280, Height = 720 },
            Locale = "en-US"
        });
        _page = await _context.NewPageAsync();
    }

    public async ValueTask DisposeAsync()
    {
        await _page.CloseAsync();
        await _context.CloseAsync();
        await _browser.CloseAsync();
        _playwright.Dispose();
    }

    [Fact]
    public async Task Login_ValidUser_RedirectsToDashboard()
    {
        await _page.GotoAsync("https://localhost:5001/login");

        await _page.FillAsync("[data-testid='email']", "user@example.com");
        await _page.FillAsync("[data-testid='password']", "P@ssw0rd!");
        await _page.ClickAsync("[data-testid='login-btn']");

        await Expect(_page).ToHaveURLAsync(new Regex("/dashboard"));
    }
}

定位器和交互

推荐的定位器策略

// BEST: Role-based (accessible and semantic)
var submitBtn = Page.GetByRole(AriaRole.Button, new() { Name = "Submit Order" });

// GOOD: Test ID (stable, explicit)
var emailInput = Page.Locator("[data-testid='email-input']");

// GOOD: Label text (user-visible, accessible)
var nameField = Page.GetByLabel("Full Name");

// GOOD: Placeholder (user-visible)
var searchBox = Page.GetByPlaceholder("Search products...");

// AVOID: CSS class (fragile, changes with styling)
var card = Page.Locator(".card-primary");

// AVOID: XPath (brittle, hard to read)
var cell = Page.Locator("//table/tbody/tr[1]/td[2]");

常见交互

// Text input
await Page.FillAsync("[data-testid='name']", "Alice Johnson");

// Click
await Page.ClickAsync("[data-testid='submit']");

// Select dropdown
await Page.SelectOptionAsync("[data-testid='country']", "US");

// Checkbox / radio
await Page.CheckAsync("[data-testid='agree-terms']");

// File upload
await Page.SetInputFilesAsync("[data-testid='avatar']", "testdata/photo.jpg");

// Keyboard
await Page.Keyboard.PressAsync("Enter");
await Page.Keyboard.TypeAsync("search query");

// Hover (for dropdowns, tooltips)
await Page.HoverAsync("[data-testid='user-menu']");

断言(Expect API)

Playwright断言自动重试,直到条件满足或超时到期:

// Element visibility
await Expect(Page.Locator("[data-testid='success']")).ToBeVisibleAsync();
await Expect(Page.Locator("[data-testid='spinner']")).ToBeHiddenAsync();

// Text content
await Expect(Page.Locator("[data-testid='total']")).ToHaveTextAsync("$99.99");
await Expect(Page.Locator("[data-testid='status']")).ToContainTextAsync("Completed");

// Attribute
await Expect(Page.Locator("[data-testid='submit']")).ToBeEnabledAsync();
await Expect(Page.Locator("[data-testid='email']")).ToHaveValueAsync("user@example.com");

// Page-level
await Expect(Page).ToHaveURLAsync(new Regex("/orders/\\d+"));
await Expect(Page).ToHaveTitleAsync("Order Details - My App");

// Count
await Expect(Page.Locator("[data-testid='order-row']")).ToHaveCountAsync(5);

网络拦截

模拟API响应

[Fact]
public async Task OrderList_WithMockedApi_DisplaysOrders()
{
    // Intercept API calls and return mock data
    await Page.RouteAsync("**/api/orders", async route =>
    {
        var json = JsonSerializer.Serialize(new[]
        {
            new { Id = 1, CustomerName = "Alice", Total = 99.99 },
            new { Id = 2, CustomerName = "Bob", Total = 149.50 }
        });
        await route.FulfillAsync(new RouteFulfillOptions
        {
            Status = 200,
            ContentType = "application/json",
            Body = json
        });
    });

    await Page.GotoAsync("https://localhost:5001/orders");

    await Expect(Page.Locator("[data-testid='order-row']")).ToHaveCountAsync(2);
}

等待网络请求

[Fact]
public async Task CreateOrder_SubmitForm_WaitsForApiResponse()
{
    await Page.GotoAsync("https://localhost:5001/orders/new");

    await Page.FillAsync("[data-testid='customer']", "Alice");
    await Page.FillAsync("[data-testid='amount']", "99.99");

    // Wait for the API call triggered by form submission
    var responseTask = Page.WaitForResponseAsync(
        response => response.Url.Contains("/api/orders") && response.Status == 201);

    await Page.ClickAsync("[data-testid='submit']");

    var response = await responseTask;
    Assert.Equal(201, response.Status);
}

CI浏览器缓存

每次CI运行下载浏览器二进制文件很慢(500MB+)。缓存它们以加速构建。

GitHub Actions缓存

# .github/workflows/e2e-tests.yml
jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      - name: Build
        run: dotnet build tests/MyApp.E2E/

      - name: Cache Playwright browsers
        id: playwright-cache
        uses: actions/cache@v4
        with:
          path: ~/.cache/ms-playwright
          key: playwright-${{ runner.os }}-${{ hashFiles('tests/MyApp.E2E/MyApp.E2E.csproj') }}

      - name: Install Playwright browsers
        if: steps.playwright-cache.outputs.cache-hit != 'true'
        run: pwsh tests/MyApp.E2E/bin/Debug/net8.0/playwright.ps1 install --with-deps

      - name: Install Playwright system deps
        if: steps.playwright-cache.outputs.cache-hit == 'true'
        run: pwsh tests/MyApp.E2E/bin/Debug/net8.0/playwright.ps1 install-deps

      - name: Run E2E tests
        run: dotnet test tests/MyApp.E2E/

Azure DevOps缓存

# azure-pipelines.yml
steps:
  - task: Cache@2
    inputs:
      key: 'playwright | "$(Agent.OS)" | tests/MyApp.E2E/MyApp.E2E.csproj'
      path: $(HOME)/.cache/ms-playwright
      restoreKeys: |
        playwright | "$(Agent.OS)"
      cacheHitVar: PLAYWRIGHT_CACHE_RESTORED
    displayName: Cache Playwright browsers

  - script: pwsh tests/MyApp.E2E/bin/Debug/net8.0/playwright.ps1 install --with-deps
    condition: ne(variables.PLAYWRIGHT_CACHE_RESTORED, 'true')
    displayName: Install Playwright browsers

  - script: pwsh tests/MyApp.E2E/bin/Debug/net8.0/playwright.ps1 install-deps
    condition: eq(variables.PLAYWRIGHT_CACHE_RESTORED, 'true')
    displayName: Install Playwright system deps (cached browsers)

  - script: dotnet test tests/MyApp.E2E/
    displayName: Run E2E tests

缓存键策略

缓存键应包括:

  • OS: 浏览器二进制文件是平台特定的
  • 项目文件哈希: Playwright版本决定浏览器版本;更改包版本会使缓存失效
  • 回退键: 当项目文件更改时允许部分缓存恢复

跟踪查看器

Playwright的跟踪查看器捕获测试执行的完整记录用于调试失败。每个跟踪包括屏幕截图、DOM快照、网络日志和控制台输出。

启用跟踪

public class TracedTests : IAsyncLifetime
{
    private IPlaywright _playwright = null!;
    private IBrowser _browser = null!;
    private IBrowserContext _context = null!;

    public IPage Page { get; private set; } = null!;

    public async ValueTask InitializeAsync()
    {
        _playwright = await Playwright.CreateAsync();
        _browser = await _playwright.Chromium.LaunchAsync();
        _context = await _browser.NewContextAsync();

        // Start tracing before each test
        await _context.Tracing.StartAsync(new TracingStartOptions
        {
            Screenshots = true,
            Snapshots = true,
            Sources = true
        });

        Page = await _context.NewPageAsync();
    }

    public async ValueTask DisposeAsync()
    {
        // Save trace on failure (check test result in xUnit requires custom wrapper)
        await _context.Tracing.StopAsync(new TracingStopOptions
        {
            Path = Path.Combine("test-results", "traces",
                $"trace-{DateTime.UtcNow:yyyyMMdd-HHmmss}.zip")
        });

        await Page.CloseAsync();
        await _context.CloseAsync();
        await _browser.CloseAsync();
        _playwright.Dispose();
    }
}

查看跟踪

# Open trace file in browser
pwsh bin/Debug/net8.0/playwright.ps1 show-trace test-results/traces/trace-20260101-120000.zip

# Or use the online trace viewer
# Upload the .zip to https://trace.playwright.dev/

仅在失败时保存跟踪

仅在测试失败时保存跟踪以减少存储:

// In a custom test class or middleware
public async Task RunWithTrace(Func<IPage, Task> testAction, string testName)
{
    await _context.Tracing.StartAsync(new TracingStartOptions
    {
        Screenshots = true,
        Snapshots = true,
        Sources = true
    });

    try
    {
        await testAction(Page);
        // Test passed -- discard trace
        await _context.Tracing.StopAsync();
    }
    catch
    {
        // Test failed -- save trace for debugging
        await _context.Tracing.StopAsync(new TracingStopOptions
        {
            Path = $"test-results/traces/{testName}.zip"
        });
        throw;
    }
}

代码生成

Playwright的代码生成器记录浏览器交互并生成测试代码。用它快速搭建测试,然后优化生成的代码。

运行代码生成

# Open codegen with your app URL
pwsh bin/Debug/net8.0/playwright.ps1 codegen https://localhost:5001

# With specific browser
pwsh bin/Debug/net8.0/playwright.ps1 codegen --browser firefox https://localhost:5001

# With device emulation
pwsh bin/Debug/net8.0/playwright.ps1 codegen --device "iPhone 15" https://localhost:5001

# With saved authentication state
pwsh bin/Debug/net8.0/playwright.ps1 codegen --save-storage auth.json https://localhost:5001

代码生成最佳实践

  1. 使用代码生成作为起点, 而不是最终测试。生成的代码通常使用脆弱的选择器且缺乏适当的断言。
  2. 替换生成的选择器data-testid 或基于角色的定位器,生成后立即进行。
  3. 添加有意义的断言。 代码生成记录动作但不知道要验证什么。为预期结果添加 Expect() 调用。
  4. 从生成的代码中提取页面对象。 将相关交互分组到页面对象方法中。

代码生成优化前后对比

// GENERATED by codegen (fragile, no assertions):
await page.GotoAsync("https://localhost:5001/orders");
await page.Locator("#root > div > main > div:nth-child(2) > button").ClickAsync();
await page.GetByPlaceholder("Customer name").FillAsync("Alice");
await page.GetByPlaceholder("Amount").FillAsync("99.99");
await page.Locator("form > button[type='submit']").ClickAsync();

// REFINED (stable selectors, proper assertions):
await Page.GotoAsync("https://localhost:5001/orders");
await Page.ClickAsync("[data-testid='new-order-btn']");
await Page.FillAsync("[data-testid='customer-name']", "Alice");
await Page.FillAsync("[data-testid='amount']", "99.99");
await Page.ClickAsync("[data-testid='submit-order']");

await Expect(Page.Locator("[data-testid='success-toast']"))
    .ToBeVisibleAsync();
await Expect(Page).ToHaveURLAsync(new Regex("/orders/\\d+"));

多浏览器测试

跨浏览器运行测试

// Using Playwright xUnit base class with environment variable
// Set BROWSER=chromium|firefox|webkit via CLI or CI config
public class CrossBrowserTests : PageTest
{
    [Fact]
    public async Task OrderFlow_WorksAcrossBrowsers()
    {
        // This test runs in whichever browser BROWSER env var specifies
        await Page.GotoAsync("https://localhost:5001/orders/new");
        await Page.FillAsync("[data-testid='customer']", "Alice");
        await Page.ClickAsync("[data-testid='submit']");

        await Expect(Page.Locator("[data-testid='success']")).ToBeVisibleAsync();
    }
}
# Run tests in each browser
BROWSER=chromium dotnet test
BROWSER=firefox dotnet test
BROWSER=webkit dotnet test

CI矩阵策略

# GitHub Actions matrix for multi-browser
strategy:
  matrix:
    browser: [chromium, firefox, webkit]
steps:
  - name: Run E2E tests
    run: dotnet test tests/MyApp.E2E/
    env:
      BROWSER: ${{ matrix.browser }}

关键原则

  • 使用Playwright断言(Expect)而不是原始xUnit Assert Playwright断言使用可配置的超时自动重试,消除不稳定的时间问题。
  • 在CI中缓存浏览器二进制文件。 每次运行下载500MB+的浏览器浪费时间和带宽。按操作系统 + Playwright版本缓存。
  • 启用跟踪查看器用于调试CI失败。 跟踪捕获重现失败所需的一切,而无需重新运行测试。
  • 使用代码生成引导测试,然后优化。 生成的代码让你快速入门;手动优化使测试可维护。
  • 优先使用基于角色或 data-testid 的定位器 而不是CSS类或XPath。参见 [技能:dotnet-ui-testing-core] 获取完整选择器优先级指南。

代理陷阱

  1. 添加Playwright包后不要忘记安装浏览器。 NuGet包不包含浏览器二进制文件。构建后运行安装脚本。
  2. 不要使用 Task.Delay 进行等待。 Playwright的自动等待和 Expect 断言自动处理时间。添加延迟使测试变慢且仍不稳定。
  3. 不要硬编码 localhost 端口。 使用配置或环境变量设置基础URL。CI环境可能使用与本地开发不同的端口。
  4. 不要在第一次CI安装时跳过 --with-deps Playwright浏览器在Linux上需要系统库(如libgbm、libasound等)。--with-deps 标志安装它们。后续缓存运行仅需要 install-deps
  5. 不要将跟踪文件存储在仓库中。 跟踪是大型二进制文件。将它们写入被git忽略的 test-results/ 目录,并作为CI工件上传。
  6. 不要为每个测试创建新的浏览器实例。 浏览器启动是昂贵的。使用 IClassFixture 或Playwright xUnit基类在类中跨测试共享浏览器。为隔离,每个测试创建一个新的 BrowserContext

参考