现代 E2E (End- to-End, 使用您的网站,像用户一样使用您的网站) 测试不必是痛苦的。 这个全面的指南展示了您如何使用 Putopiteer Sharp 来快速、可靠的浏览器自动化. NET 覆盖从基本测试到 PDF 生成和网络切除等所有内容。 尽管微软的Playwright 是更现代的多浏览器解决方案, 我选择了 Putopiteeer Sharp 来使用这个博客, 因为它是我所知道的, 而只用 Chrome 测试足以满足我的需求。 如果您需要 Firefox 和 Safari 支持, 请查看我的博客。 剧人指南 取而代之。
如果你曾经合作过 对于端到端的测试, 你会知道这可能是一个正确的问题。 在与驱动程序版本摔跤, 处理对机器有效但无其它功能的片面测试之间, 以及 WebDriver 协议的总体缓慢性, 足以让你想要将它全部扔进车内, 然后手动测试。
输入 夏尔普(Sharp) - 谷歌网网港 木偶剧 这就像Selenium的更年轻、更快的表弟, 他实际上忙着准时出现, 不需要你下载17个不同的浏览器驱动器。
在本篇文章中,我将介绍我是如何在这个博客上应用 Putppieer Sharp 来进行E2E测试的, 完整地用回购中的真实代码示例来完成。 我们将覆盖测试、 PDF 生成、 网络报废, 并将它与替代品进行比较 。
夏尔普(Sharp) 是一个.NET 库,它提供一个高级 API ,用于控制使用该设备控制铬或铬浏览器的铬或铬浏览器。 《铬化脱贫工具议定书》不同 ,其中使用 WebDriver 协议 木偶夏尔普通过DevTools直接与浏览器交谈。
这样想吧:
graph LR
A[Test Code] -->|WebDriver Protocol| B[Selenium]
B -->|JSON Wire Protocol| C[Browser Driver]
C -->|Commands| D[Browser]
E[Test Code] -->|DevTools Protocol| F[PuppeteerSharp]
F -->|Direct Connection| G[Chrome/Chromium]
style A stroke:#333,stroke-width:2px
style E stroke:#333,stroke-width:2px
style F stroke:#0066cc,stroke-width:3px
style G stroke:#0066cc,stroke-width:3px
在潜深之前,让我们来谈谈 E2E 测试在大计划中的位置。你可能听说过这个测试金字塔-- 下面是它的实际运作方式:
graph TB
subgraph "Testing Pyramid"
E2E[E2E Tests<br/>Few, Slow, High Confidence<br/>Test full user journeys]
INT[Integration Tests<br/>Medium number, Medium speed<br/>Test component interactions]
UNIT[Unit Tests<br/>Many, Fast, Low Cost<br/>Test individual functions]
end
subgraph "Trade-offs"
SPEED[Speed]
CONF[Confidence]
COST[Cost]
end
subgraph "When to Use E2E"
W1[Critical user journeys<br/>e.g. checkout, login]
W2[Cross-browser compatibility]
W3[JavaScript-heavy UIs]
W4[Complex user interactions]
end
E2E -.->|Slow but high confidence| CONF
INT -.->|Balanced| SPEED
UNIT -.->|Fast and cheap| SPEED
E2E -.->|Expensive to run| COST
UNIT -.->|Cheap to run| COST
style E2E stroke:#cc0000,stroke-width:3px
style INT stroke:#ff9900,stroke-width:2px
style UNIT stroke:#00aa00,stroke-width:2px
style CONF stroke:#0066cc,stroke-width:2px
style SPEED stroke:#00aa00,stroke-width:2px
style COST stroke:#cc0000,stroke-width:2px
现实检查:
当你需要E2E测试时:
当你不需要E2E测试时:
让我计算一下方法:
无司机管理 Faff: PutopieerSharp 下载并管理您的铬浏览器。不再用与您安装的铬版本不匹配的铬版本的铬Driver版本来搅拌 。
更快执行: DevTools 协议比WebDriver要快得多。你的测试会更快进行, 你会花更少的时间等待事情的发生。
更好的APIAPI更现代,更直观。它是Async/await all the way down, 它与现代.NET的开发非常吻合。
内置屏幕截图和 PDF 生成测试失败时要截图吗?
拦截网络请求:您可以轻松地拦截、修改或封锁网络请求----在测试离线情景或模拟 API 响应方面非常出色。
正确的 JavaScript 执行:在页面的上下文中执行 JavaScript,然后以不会让你想哭的方式获得结果。
第一,增加 夏尔普(Sharp) NuGet 软件包 :
dotnet add package PuppeteerSharp
以下是我的测试工程配置(Mostlylucid.Test/Mostlylucid.Test.csproj:23):
<PackageReference Include="PuppeteerSharp" Version="20.2.4" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
我在用 x 单位 (ASP.NET Core的违约),但 " 傀儡夏尔普 " 与 N单位单位 或 微测试.
与其在每次测试中重复设置/降低代码,Mostlylucid.Test/E2E/E2ETestBase.cs:12处理浏览器生命周期管理:
using PuppeteerSharp;
using Xunit.Abstractions;
namespace Mostlylucid.Test.E2E;
public abstract class E2ETestBase : IAsyncLifetime
{
protected readonly ITestOutputHelper Output;
protected IBrowser Browser = null!;
protected IPage Page = null!;
protected const string BaseUrl = "http://localhost:8080";
protected const int DefaultTimeout = 30000;
protected E2ETestBase(ITestOutputHelper output)
{
Output = output;
}
我们执行 IAsync 生命时间 x United , 它提供同步设置/ 离线 。 与传统构建器不同, 这让我们可以适当等待浏览器初始化 。
public async Task InitializeAsync()
{
// Download Chromium on first run
var browserFetcher = new BrowserFetcher();
await browserFetcher.DownloadAsync();
// Launch browser with sensible defaults
Browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true, // Set false for debugging
DefaultViewport = new ViewPortOptions
{
Width = 1400,
Height = 900
},
Args = new[]
{
"--no-sandbox",
"--disable-setuid-sandbox"
}
});
Page = await Browser.NewPageAsync();
Page.DefaultTimeout = DefaultTimeout;
}
缩略 BrowserFetcher 自动在第一次运行时下载兼容的铬版本 - 不需要手动驱动程序管理 。 --no-sandbox Docker/CI 环境需要悬挂旗帜。
public async Task DisposeAsync()
{
if (Page != null) await Page.CloseAsync();
if (Browser != null) await Browser.CloseAsync();
}
}
正确处置对于避免内存泄漏至关重要。 每个浏览器实例都使用100 - 200MB的内存管理 。
基本等级包括削减锅炉板的助手方法(Mostlylucid.Test/E2E/E2ETestBase.cs:72-172):
// Navigation with automatic network idle waiting
protected async Task NavigateAsync(string path)
{
var url = path.StartsWith("http") ? path : $"{BaseUrl}{path}";
await Page.GoToAsync(url, new NavigationOptions
{
WaitUntil = new[] { WaitUntilNavigation.Networkidle2 }
});
}
// Safe element waiting with timeout handling
protected async Task<IElementHandle?> WaitForSelectorAsync(string selector, int timeout = 5000)
{
try
{
return await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions
{
Timeout = timeout,
Visible = true
});
}
catch (WaitTaskTimeoutException)
{
return null; // Graceful degradation
}
}
// Common element operations
protected async Task<bool> ElementExistsAsync(string selector) =>
await Page.QuerySelectorAsync(selector) != null;
protected async Task<string?> GetTextContentAsync(string selector)
{
var element = await Page.QuerySelectorAsync(selector);
return element == null ? null :
await Page.EvaluateFunctionAsync<string>("el => el.textContent", element);
}
protected async Task TypeAsync(string selector, string text, int delay = 50)
{
await Page.WaitForSelectorAsync(selector);
await Page.TypeAsync(selector, text, new TypeOptions { Delay = delay });
}
protected async Task ClickAsync(string selector)
{
await Page.WaitForSelectorAsync(selector);
await Page.ClickAsync(selector);
}
它们处理乏味的比特-- 等待元素的存在, 优雅的超时处理, 和自动记录 当测试失败在 CI 。
对,让我们来谈谈好东西-- 写实际的测试。这是我博客的过滤栏功能(这是真实的测试) 。Mostlylucid.Test/E2E/FilterBarTests.cs:20-50):
[Fact(Skip = "Local E2E test - requires site to be running on localhost:8080")]
public async Task FilterBar_LanguageDropdown_ShowsLanguages()
{
// Arrange
await NavigateAsync("/blog");
// Act - Click the language dropdown button
var dropdownButton = await WaitForSelectorAsync("#LanguageDropDown button");
Assert.NotNull(dropdownButton);
await ClickAsync("#LanguageDropDown button");
await WaitAsync(300);
// Assert - Dropdown menu should be visible with language options
var dropdownOpen = await EvaluateFunctionAsync<bool>(@"() => {
const dropdown = document.querySelector('#LanguageDropDown div[x-show]');
if (!dropdown) return false;
const style = window.getComputedStyle(dropdown);
return style.display !== 'none';
}");
Assert.True(dropdownOpen, "Language dropdown should be open");
// Check that English option exists
var hasEnglish = await EvaluateFunctionAsync<bool>(@"() => {
const options = document.querySelectorAll('#LanguageDropDown li a');
return Array.from(options).some(opt => opt.textContent.toLowerCase().includes('english'));
}");
Assert.True(hasEnglish, "Language dropdown should contain English option");
Output.WriteLine("✅ Language dropdown shows languages correctly");
}
这个测试是测试我语言的下调是否正常。 让我们看看是什么让它选中 :
[Fact(Skip = "Local E2E test - requires site to be running on localhost:8080")]
我默认地跳过这个测试, 因为它要求网站在本地运行 。 E2E 测试中, 您通常想要按需运行, 而不是每栋建筑都运行。 您可以在您准备运行时卸载它们, 或者在您已经启动网站的地方运行它们 。
var dropdownOpen = await EvaluateFunctionAsync<bool>(@"() => {
const dropdown = document.querySelector('#LanguageDropDown div[x-show]');
if (!dropdown) return false;
const style = window.getComputedStyle(dropdown);
return style.display !== 'none';
}");
这是"傀儡夏尔普"绝对闪耀的地方之一。 EvaluateFunctionAsync 方法可以让您在浏览器上下文中运行 JavaScript , 并将结果作为适当的.NET 类型返回。 在这种情况下, 我通过查看其计算样式来检查下调是否实际可见( 不只是在 DOM 中存在) 。
将这个比作 , 在那里你需要:
我的博客使用 HTMX 宽度( 服务器侧面布局, 不写入 JavaScript) 。 这是用来检查排序功能的测试 (JavaScript) 。Mostlylucid.Test/E2E/FilterBarTests.cs:98-126):
[Fact(Skip = "Local E2E test - requires site to be running on localhost:8080")]
public async Task FilterBar_SortOrder_ChangesPostOrder()
{
// Arrange
await NavigateAsync("/blog");
// Get the first post title before sorting
var firstPostBefore = await EvaluateFunctionAsync<string>(@"() => {
const postLink = document.querySelector('.post-title, article h2 a, #contentcontainer article a');
return postLink?.textContent?.trim() || '';
}");
Output.WriteLine($"First post before sort: {firstPostBefore}");
// Act - Change sort order to "Oldest first"
await Page.SelectAsync("#orderSelect", "date_asc");
await WaitAsync(1000); // Wait for HTMX to update
// Assert - Post order should have changed
var firstPostAfter = await EvaluateFunctionAsync<string>(@"() => {
const postLink = document.querySelector('.post-title, article h2 a, #contentcontainer article a');
return postLink?.textContent?.trim() || '';
}");
Output.WriteLine($"First post after sort: {firstPostAfter}");
var selectValue = await EvaluateFunctionAsync<string>("() => document.querySelector('#orderSelect')?.value");
Assert.Equal("date_asc", selectValue);
Output.WriteLine("✅ Sort order selection works correctly");
}
关键是 await WaitAsync(1000) 在修改选定值后, HTMX 需要一点时间提出请求并更新 DOM 。 在完美的世界中, 我们会等待特定的网络请求完成, 但对于简单案例, 短暂的延迟是正常的 。
检查我的过滤栏是否妥善隐藏在移动设备上(请查看InfoFinland上的 ) 。Mostlylucid.Test/E2E/FilterBarTests.cs:216-245):
[Fact(Skip = "Local E2E test - requires site to be running on localhost:8080")]
public async Task FilterBar_ResponsiveDesign_HiddenOnMobile()
{
// Arrange - Set mobile viewport
await Page.SetViewportAsync(new ViewPortOptions
{
Width = 375,
Height = 667
});
await NavigateAsync("/blog");
await WaitAsync(500);
// Assert - Filter bar should be hidden on mobile
var filterBarVisible = await EvaluateFunctionAsync<bool>(@"() => {
const filterBar = document.querySelector('.hidden.lg\\:flex');
if (!filterBar) return true;
const rect = filterBar.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}");
Assert.False(filterBarVisible, "Filter bar should be hidden on mobile viewport");
Output.WriteLine("✅ Filter bar correctly hidden on mobile");
// Reset viewport
await Page.SetViewportAsync(new ViewPortOptions
{
Width = 1400,
Height = 900
});
}
您可以随时更改视图站, 它对于测试响应性布局非常出色。 比手动调整浏览器窗口大小要容易得多 !
我最喜欢的特征之一是能够拦截和修改网络请求。这对于测试错误状态或离线情景非常宝贵:
await Page.SetRequestInterceptionAsync(true);
Page.Request += async (sender, e) =>
{
// Block all image requests to speed up tests
if (e.Request.ResourceType == ResourceType.Image)
{
await e.Request.AbortAsync();
}
// Mock API responses
else if (e.Request.Url.Contains("/api/posts"))
{
await e.Request.RespondAsync(new ResponseData
{
Status = HttpStatusCode.OK,
ContentType = "application/json",
Body = "{\"posts\": []}"
});
}
else
{
await e.Request.ContinueAsync();
}
};
当测试失败时, 截图值为千条日志消息 :
try
{
// Your test code here
await Page.ClickAsync("#someButton");
}
catch (Exception)
{
// Take a screenshot on failure
await Page.ScreenshotAsync("test-failure.png");
throw; // Re-throw to fail the test
}
您甚至可以生成 PDFs 页面, 用于测试服务器侧面显示或打印样式表 :
await Page.PdfAsync("page.pdf", new PdfOptions
{
Format = PaperFormat.A4,
PrintBackground = true
});
PutcheeerSharp甚至可以收集 JavaScript 代码覆盖数据:
await Page.Coverage.StartJSCoverageAsync();
await Page.GoToAsync("http://localhost:8080");
var coverage = await Page.Coverage.StopJSCoverageAsync();
var totalBytes = coverage.Sum(c => c.Text.Length);
var usedBytes = coverage.Sum(c => c.Ranges.Sum(r => r.End - r.Start));
var percentUsed = usedBytes / (double)totalBytes * 100;
Output.WriteLine($"JavaScript coverage: {percentUsed:F2}%");
让我们好好看看PutopieeerSharp的堆叠是如何与其他E2E测试工具相抗衡的:
graph TD
A[E2E Testing Tools] --> B[Selenium WebDriver]
A --> C[PuppeteerSharp]
A --> D[Playwright]
A --> E[Cypress]
B --> B1[❌ Slow WebDriver protocol]
B --> B2[❌ Driver management hassle]
B --> B3[✅ Multi-browser support]
B --> B4[✅ Mature ecosystem]
C --> C1[✅ Fast DevTools protocol]
C --> C2[✅ Auto browser management]
C --> C3[❌ Chrome/Chromium only]
C --> C4[✅ Great .NET integration]
D --> D1[✅ Fast DevTools protocol]
D --> D2[✅ Auto browser management]
D --> D3[✅ Multi-browser support]
D --> D4[⚠️ Newer to .NET ecosystem]
E --> E1[✅ Great developer experience]
E --> E2[❌ JavaScript only]
E --> E3[❌ Not for .NET]
E --> E4[✅ Excellent documentation]
style C stroke:#0066cc,stroke-width:3px
style C1 stroke:#00aa00,stroke-width:2px
style C2 stroke:#00aa00,stroke-width:2px
style C4 stroke:#00aa00,stroke-width:2px
旧卫队
从2004年起就一直存在,它显示出来。它是成熟的,有详细记录, 并支撑着太阳下的所有浏览器。 但是它也显示了它的年龄:
专业:
关节 :
何时使用: 当您绝对需要通过多个浏览器测试时, 或者当您已经投入到 银河生态系统中时。
街上的新小子
lay人赖 是 微软对 Putputeer 的回答 . NET 支持 从一开始就被烤熟了。 它基本上是 Putputeeer Sharp , 但有多个浏览器支持 :
专业:
关节 :
何时使用: 当您需要多浏览器支持但需要现代 API 时。 如果您正在启动一个新项目, 需要交叉浏览器测试, 玩家可能是您的最佳赌注 。
JavaScript 开发者亲爱的
在JavaScript/TypeScript工作的话, Cypress是辉煌的,
专业:
关节 :
何时使用: 不,你在写.NET代码 坚持一些与你的技术堆叠结合的东西
这是我的拿下:
graph TD
A[What E2E tool?] --> B{Need multi-browser testing?}
B -->|Yes| C{Starting new project?}
B -->|No| D[PuppeteerSharp]
C -->|Yes| E[Playwright]
C -->|No| F{Invested in Selenium?}
F -->|Yes| G[Stick with Selenium]
F -->|No| E
D --> H[✅ Fast, simple, reliable]
E --> I[✅ Modern, flexible]
G --> J[⚠️ Consider migrating]
style D stroke:#0066cc,stroke-width:3px
style H stroke:#00aa00,stroke-width:2px
对于大多数网络开发商来说,
E2E测试在你的本地机器上 都很好,很好, 但是它们也需要在 CI/CD 管道中运行。 GitHub 行动组织:
name: E2E Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '9.0.x'
- name: Install dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Start application
run: |
dotnet run --project Mostlylucid/Mostlylucid.csproj &
echo $! > app.pid
- name: Wait for application to start
run: |
timeout 60 bash -c 'until curl -f http://localhost:8080/health; do sleep 2; done'
- name: Run E2E tests
run: |
dotnet test Mostlylucid.Test/Mostlylucid.Test.csproj \
--filter "Category=E2E" \
--logger "console;verbosity=detailed"
- name: Upload screenshots on failure
if: failure()
uses: actions/upload-artifact@v3
with:
name: test-screenshots
path: '**/test-failure-*.png'
- name: Stop application
if: always()
run: |
kill $(cat app.pid) || true
关键字位 :
E2E 测试可能是片面的 - 有时会通过, 但有时会失败。 这通常取决于时间问题。 下面是如何避免它们 :
坏 :
await Page.ClickAsync("#button");
var text = await GetTextContentAsync("#result");
Assert.Equal("Success", text);
好样的:
await Page.ClickAsync("#button");
await Page.WaitForSelectorAsync("#result");
var text = await GetTextContentAsync("#result");
Assert.Equal("Success", text);
总是等待你将要与它互动的元素存在 并且成为可见的。
每个测试都应该是完全独立的。 不要依赖以前测试的状态 :
坏 :
[Fact]
public async Task Test1_Login()
{
await LoginAsync("user", "password");
// User is now logged in for subsequent tests
}
[Fact]
public async Task Test2_ViewDashboard()
{
// Assumes user is still logged in from Test1
await NavigateAsync("/dashboard");
}
好样的:
[Fact]
public async Task Test1_Login()
{
await LoginAsync("user", "password");
await LogoutAsync(); // Clean up
}
[Fact]
public async Task Test2_ViewDashboard()
{
await LoginAsync("user", "password"); // Set up needed state
await NavigateAsync("/dashboard");
await LogoutAsync(); // Clean up
}
对于复杂的页面, 请使用“ 页面对象” 模式来维持您的测试 :
public class BlogPageObject
{
private readonly IPage _page;
public BlogPageObject(IPage page)
{
_page = page;
}
public async Task SelectLanguageAsync(string language)
{
await _page.ClickAsync("#LanguageDropDown button");
await _page.WaitAsync(300);
await _page.ClickAsync($"#LanguageDropDown a:has-text('{language}')");
}
public async Task<string[]> GetPostTitlesAsync()
{
return await _page.EvaluateFunctionAsync<string[]>(@"() => {
return Array.from(document.querySelectorAll('.post-title'))
.map(el => el.textContent.trim());
}");
}
}
// Usage in tests
[Fact]
public async Task Can_Filter_By_Language()
{
var blogPage = new BlogPageObject(Page);
await NavigateAsync("/blog");
await blogPage.SelectLanguageAsync("Spanish");
var titles = await blogPage.GetPostTitlesAsync();
Assert.All(titles, title => Assert.NotEmpty(title));
}
E2E测试慢于单位测试,无法绕过它。但你可以让它们更快:
x unit 在默认情况下平行运行测试, 但是您需要谨慎对待共享状态 :
[Collection("E2E Tests")] // Tests in same collection run sequentially
public class FilterBarTests : E2ETestBase
{
// Tests here share resources
}
[Collection("Blog Tests")] // Different collection runs in parallel
public class BlogTests : E2ETestBase
{
// Tests here run in parallel with FilterBarTests
}
使用禁用功能加速测试, 您不需要 :
Browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true,
Args = new[]
{
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage", // Overcome limited resource problems
"--disable-accelerated-2d-canvas",
"--disable-gpu", // Not needed for headless
"--disable-images", // Don't load images if you don't need them
"--disable-javascript", // Only if testing static content
}
});
屏蔽不必要资源以加速 :
await Page.SetRequestInterceptionAsync(true);
Page.Request += async (sender, e) =>
{
var blockedResourceTypes = new[]
{
ResourceType.Image,
ResourceType.Media,
ResourceType.Font,
ResourceType.StyleSheet // If you don't need to test styling
};
if (blockedResourceTypes.Contains(e.Request.ResourceType))
{
await e.Request.AbortAsync();
}
else
{
await e.Request.ContinueAsync();
}
};
测试失败( 失败) 时, 您需要调试它们 。 以下是一些技术 :
套套套套设 Headless = false 要在操作中查看浏览器:
Browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = false,
SlowMo = 100, // Slow down by 100ms to see what's happening
});
您可以在程序上打开 DevTools :
Browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = false,
Devtools = true, // Auto-open DevTools
});
从浏览器获取控制台信息 :
Page.Console += (sender, e) =>
{
Output.WriteLine($"Browser console: {e.Message.Text}");
};
记录所有网络请求 :
Page.Request += (sender, e) =>
{
Output.WriteLine($"Request: {e.Request.Method} {e.Request.Url}");
};
Page.Response += (sender, e) =>
{
Output.WriteLine($"Response: {e.Response.Status} {e.Response.Url}");
};
以下是我在E2E测试中经常使用的一些模式:
[Fact]
public async Task Can_Submit_Comment()
{
await NavigateAsync("/blog/some-post");
// Fill in the comment form
await TypeAsync("#comment-name", "Test User");
await TypeAsync("#comment-email", "[email protected]");
await TypeAsync("#comment-content", "This is a test comment");
// Submit the form
await ClickAsync("#comment-submit");
// Wait for success message
await WaitForSelectorAsync(".comment-success");
// Verify the comment appears
var commentText = await GetTextContentAsync(".comment-list .comment:last-child .comment-content");
Assert.Contains("test comment", commentText.ToLower());
}
[Fact]
public async Task Can_Navigate_With_Keyboard()
{
await NavigateAsync("/blog");
// Focus the search box
await Page.FocusAsync("#search");
// Type a search query
await Page.Keyboard.TypeAsync("testing");
// Press arrow down to select first result
await Page.Keyboard.PressAsync("ArrowDown");
// Press enter to navigate
await Page.Keyboard.PressAsync("Enter");
// Verify we navigated to the right page
await WaitAsync(1000);
Assert.Contains("/blog/", Page.Url);
}
[Fact]
public async Task Can_Upload_Image()
{
await NavigateAsync("/admin/upload");
// Create a test file
var testFilePath = Path.Combine(Path.GetTempPath(), "test-image.jpg");
File.WriteAllBytes(testFilePath, new byte[] { 0xFF, 0xD8, 0xFF }); // JPEG header
// Upload the file
var fileInput = await Page.QuerySelectorAsync("input[type=file]");
await fileInput.UploadFileAsync(testFilePath);
await ClickAsync("#upload-submit");
// Verify upload succeeded
await WaitForSelectorAsync(".upload-success");
// Clean up
File.Delete(testFilePath);
}
[Fact]
public async Task Can_teAsync("/admin/posts");
var dragSource = await Page.QuerySelectorAsync(".post-item[data-id='1']");
var dropTarget = await Page.QuerySelectorAsync(".post-item[data-id='3']");
var sourceBox = await dragSource.BoundingBoxAsync();
var targetBox = await dropTarget.BoundingBoxAsync();
// Perform drag and drop
await Page.Mouse.MoveAsync(sourceBox.X + sourceBox.Width / 2, sourceBox.Y + sourceBox.Height / 2);
await Page.Mouse.DownAsync();
await Page.Mouse.MoveAsync(targetBox.X + targetBox.Width / 2, targetBox.Y + targetBox.Height / 2);
await Page.Mouse.UpAsync();
await WaitAsync(500);
// Verify new order
var firstItemId = await Page.EvaluateFunctionAsync<string>(
"() => document.querySelector('.post-item').dataset.id"
);
Assert.Equal("1", firstItemId);
}
您可以将 Putputeeer Sharp 和 ASP.NET 核心网络应用工具 更综合的测试经验:
public class E2EWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseUrls("http://localhost:5050");
builder.ConfigureServices(services =>
{
// Override services for testing
// For example, use in-memory database
services.RemoveAll<DbContextOptions<MostlylucidDbContext>>();
services.AddDbContext<MostlylucidDbContext>(options =>
{
options.UseInMemoryDatabase("TestDb");
});
});
}
}
public abstract class IntegratedE2ETestBase : E2ETestBase, IClassFixture<E2EWebApplicationFactory>
{
protected E2EWebApplicationFactory Factory { get; }
protected IntegratedE2ETestBase(E2EWebApplicationFactory factory, ITestOutputHelper output)
: base(output)
{
Factory = factory;
}
public override async Task InitializeAsync()
{
await base.InitializeAsync();
// Application is automatically started by WebApplicationFactory
// Override BaseUrl to use the factory's address
BaseUrl = "http://localhost:5050";
}
}
虽然E2E测试非常出色,但傀儡夏尔普(Putputeeer Sharp)是瑞士军队的一把刀,可以做得更多。它最常用的用途之一是从网络内容中生成PDF(PDF) — — 它对此非常有用,尽管不是没有它。如果你正在建造发票、报告或任何文件生成系统,本节将节省您调试的时间。
想法很简单:以铬格式创建网页并保存为PDF格式。 完美地生成发票、报告、证明或需要以PDF格式发布的任何动态内容。
以下是基本方针:
public class PdfGeneratorService
{
public async Task<byte[]> GeneratePdfFromUrlAsync(string url)
{
await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true,
Args = new[] { "--no-sandbox", "--disable-setuid-sandbox" }
});
await using var page = await browser.NewPageAsync();
await page.GoToAsync(url, new NavigationOptions
{
WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
});
var pdfData = await page.PdfDataAsync(new PdfOptions
{
Format = PaperFormat.A4,
PrintBackground = true,
MarginOptions = new MarginOptions
{
Top = "20mm",
Right = "20mm",
Bottom = "20mm",
Left = "20mm"
}
});
return pdfData;
}
}
看上去很简单,对吧?
问题: 您美丽的自定义字体不会出现在 PDF, 或者更糟糕的是,他们在那里,但看起来绝对垃圾。
为什么它发生: PDF 生成期间, 铬需要访问字体文件 。 如果您的字体是通过外部 CDN 装入的, Chrome 无法到达这些字体文件( 防火墙、 网络问题、 时间) , 您会被填充 。
解决方案:
await page.GoToAsync(url, new NavigationOptions
{
WaitUntil = new[]
{
WaitUntilNavigation.Networkidle0, // Wait for network to be idle
WaitUntilNavigation.Load // Wait for fonts to load
},
Timeout = 60000 // Give it time to load fonts
});
// Extra insurance - wait for fonts to actually load
await page.EvaluateFunctionAsync(@"async () => {
await document.fonts.ready;
}");
var pdfData = await page.PdfDataAsync(new PdfOptions
{
Format = PaperFormat.A4,
PrintBackground = true // CRUCIAL for @font-face fonts
});
更好的是,在本地安放您的字体, 或者在您的 CSS 中以 base64 嵌入字体 。 是的, 它是一个假的, 但它是可靠的 。
问题: 您的 PDF 与您的网页完全不一样, 因为 Chrome 应用印刷媒体查询 。
这实际上是 右对右行为 PDF是印刷媒体 但它第一次抓住所有人
解决方案:
使用使用 @media print CSS 规则适当:
/* Show on screen, hide in PDF */
.no-print {
display: block;
}
@media print {
.no-print {
display: none !important;
}
/* Prevent page breaks inside elements */
.keep-together {
page-break-inside: avoid;
break-inside: avoid;
}
/* Force page breaks */
.page-break {
page-break-before: always;
}
}
或者,如果您想要您的 PDF 中的屏幕版本( 用于生成 PDF 的“ 屏幕截图 ” ) :
await page.EmulateMediaTypeAsync(MediaType.Screen); // Force screen media
var pdfData = await page.PdfDataAsync();
问题: 你的内容被尴尬地分成各页, 标题在底部成为孤儿或表格被切成两半。
现实: 你正在和Chrome的 内部人造算法抗争 而且它会赢得大部分时间
你们能做的是:
@media print {
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
break-after: avoid;
}
table, figure, img {
page-break-inside: avoid;
break-inside: avoid;
}
/* Force specific breaks */
.new-page {
page-break-before: always;
}
}
在你的开胃菜代码中:
var pdfData = await page.PdfDataAsync(new PdfOptions
{
Format = PaperFormat.A4,
PrintBackground = true,
PreferCSSPageSize = true, // Respect CSS @page rules
DisplayHeaderFooter = false
});
Pro Tip : 对于复杂的布局来说,有时更容易用明确的分页符来构建您的 HTML 结构,而不是对抗浏览器:
<div class="page">
<!-- First page content -->
</div>
<div class="page-break"></div>
<div class="page">
<!-- Second page content -->
</div>
您可以添加页眉和页脚, 但是 API 有点偏斜 :
var pdfData = await page.PdfDataAsync(new PdfOptions
{
Format = PaperFormat.A4,
DisplayHeaderFooter = true,
HeaderTemplate = @"
<div style='font-size: 10px; text-align: center; width: 100%;'>
<span class='title'></span>
</div>
",
FooterTemplate = @"
<div style='font-size: 10px; text-align: center; width: 100%;'>
Page <span class='pageNumber'></span> of <span class='totalPages'></span>
</div>
",
MarginOptions = new MarginOptions
{
Top = "30mm", // Must be larger to accommodate header
Bottom = "25mm" // Must be larger to accommodate footer
}
});
积分 :
date, title, url, pageNumber, totalPages默认情况下, 铬不会打印背景图像或颜色( 这是保存墨水的浏览器默认值 ) 。 您 必须 启用该功能:
var pdfData = await page.PdfDataAsync(new PdfOptions
{
PrintBackground = true // Without this, your beautiful backgrounds vanish
});
问题: 生成大量 PDF 导致您的应用程序内存为气球, 并最终崩溃 。
为什么: 每个浏览器实例都使用重要的内存(100-200MB),如果你处理不当,它们就会堆积起来。
解决方案:
总是使用 await using 或适当处置:
// Good - automatic disposal
await using var browser = await Puppeteer.LaunchAsync(options);
await using var page = await browser.NewPageAsync();
// Or manually
IBrowser? browser = null;
try
{
browser = await Puppeteer.LaunchAsync(options);
// ... use browser
}
finally
{
if (browser != null)
{
await browser.CloseAsync();
await browser.DisposeAsync();
}
}
对于大容量的 PDF 生成, 考虑重新使用浏览器实例 :
public class PdfGeneratorService : IDisposable
{
private IBrowser? _browser;
private readonly SemaphoreSlim _semaphore = new(1, 1);
public async Task<byte[]> GeneratePdfAsync(string url)
{
await _semaphore.WaitAsync();
try
{
// Reuse browser instance
_browser ??= await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true
});
await using var page = await _browser.NewPageAsync();
await page.GoToAsync(url);
return await page.PdfDataAsync();
}
finally
{
_semaphore.Release();
}
}
public async ValueTask DisposeAsync()
{
if (_browser != null)
{
await _browser.CloseAsync();
await _browser.DisposeAsync();
}
_semaphore.Dispose();
}
public void Dispose()
{
DisposeAsync().AsTask().Wait();
}
}
有时您需要在页面上添加更多内容 :
var pdfData = await page.PdfDataAsync(new PdfOptions
{
Format = PaperFormat.A4,
Scale = 0.8m, // 80% scale - fits more content
PrintBackground = true
});
但要小心 - 太小了,它无法读取。
以下是我制作PDF的制作方式:
public class InvoicePdfGenerator
{
private readonly ILogger<InvoicePdfGenerator> _logger;
public InvoicePdfGenerator(ILogger<InvoicePdfGenerator> logger)
{
_logger = logger;
}
public async Task<byte[]> GenerateInvoicePdfAsync(Invoice invoice)
{
await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true,
Args = new[]
{
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage" // Overcome limited resource problems
}
});
await using var page = await browser.NewPageAsync();
// Set up console logging to debug issues
page.Console += (_, e) =>
{
_logger.LogInformation("Browser console: {Message}", e.Message.Text);
};
try
{
// Generate HTML content (using Razor, or however you do it)
var htmlContent = await GenerateInvoiceHtmlAsync(invoice);
// Set content directly rather than navigating to URL
await page.SetContentAsync(htmlContent, new NavigationOptions
{
WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
});
// Wait for fonts to load
await page.EvaluateFunctionAsync("() => document.fonts.ready");
// Force screen media type to avoid print media queries changing layout
await page.EmulateMediaTypeAsync(MediaType.Screen);
// Generate PDF
var pdfData = await page.PdfDataAsync(new PdfOptions
{
Format = PaperFormat.A4,
PrintBackground = true,
MarginOptions = new MarginOptions
{
Top = "10mm",
Right = "10mm",
Bottom = "10mm",
Left = "10mm"
},
PreferCSSPageSize = false
});
_logger.LogInformation("Generated PDF for invoice {InvoiceId}, size: {Size} bytes",
invoice.Id, pdfData.Length);
return pdfData;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to generate PDF for invoice {InvoiceId}", invoice.Id);
// Take a screenshot for debugging
try
{
var screenshot = await page.ScreenshotDataAsync();
_logger.LogWarning("Captured screenshot of failed PDF generation: {Size} bytes",
screenshot.Length);
// Could save this to blob storage for debugging
}
catch
{
// Swallow screenshot errors
}
throw;
}
}
private async Task<string> GenerateInvoiceHtmlAsync(Invoice invoice)
{
// Your HTML generation logic here
// Could use Razor views, or any templating engine
return $@"
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
body {{
font-family: 'Inter', sans-serif;
margin: 0;
padding: 20px;
color: #333;
}}
@media print {{
.page-break {{
page-break-before: always;
}}
.no-break {{
page-break-inside: avoid;
}}
}}
</style>
</head>
<body>
<div class='no-break'>
<h1>Invoice #{invoice.Number}</h1>
<p>Date: {invoice.Date:yyyy-MM-dd}</p>
</div>
<!-- Invoice content -->
</body>
</html>";
}
}
简单但经常需要:
var pdfData = await page.PdfDataAsync(new PdfOptions
{
Format = PaperFormat.A4,
Landscape = true, // Horizontal orientation
PrintBackground = true
});
不限于标准格式:
var pdfData = await page.PdfDataAsync(new PdfOptions
{
Width = "210mm", // Custom width
Height = "297mm", // Custom height (this is A4, but you can use any size)
PrintBackground = true
});
除了测试和PDF生成之外, PutopieeerSharp还擅长其他几个自动化任务。让我们探索现实世界中最常见的应用。
传统 HTML 解析器不足的 JavaScript-heavy 站点:
public class ProductScraper
{
public async Task<List<Product>> ScrapeProductsAsync(string url)
{
await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true
});
await using var page = await browser.NewPageAsync();
await page.GoToAsync(url, new NavigationOptions
{
WaitUntil = new[] { WaitUntilNavigation.Networkidle2 }
});
// Wait for products to render (adjust selector as needed)
await page.WaitForSelectorAsync(".product-item");
// Extract product data using JavaScript
var products = await page.EvaluateFunctionAsync<List<Product>>(@"() => {
return Array.from(document.querySelectorAll('.product-item')).map(item => ({
name: item.querySelector('.product-name')?.textContent?.trim(),
price: parseFloat(item.querySelector('.product-price')?.textContent?.replace('£', '')),
imageUrl: item.querySelector('img')?.src,
inStock: !item.querySelector('.out-of-stock')
}));
}");
return products;
}
}
何时使用 :
不使用时 :
超过测试, 截图对缩略图、预览或归档有用 :
public class ScreenshotService
{
public async Task<byte[]> CaptureWebsiteAsync(string url, int width = 1920, int height = 1080)
{
await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true
});
await using var page = await browser.NewPageAsync();
await page.SetViewportAsync(new ViewPortOptions
{
Width = width,
Height = height
});
await page.GoToAsync(url, new NavigationOptions
{
WaitUntil = new[] { WaitUntilNavigation.Networkidle2 }
});
// Full page screenshot
return await page.ScreenshotDataAsync(new ScreenshotOptions
{
FullPage = true,
Type = ScreenshotType.Png
});
}
public async Task<byte[]> CaptureElementAsync(string url, string selector)
{
await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true
});
await using var page = await browser.NewPageAsync();
await page.GoToAsync(url);
var element = await page.WaitForSelectorAsync(selector);
if (element == null)
{
throw new InvalidOperationException($"Element {selector} not found");
}
// Screenshot of specific element
return await element.ScreenshotDataAsync();
}
}
实用用途:
测量页面负载性能:
public class PerformanceMonitor
{
public async Task<PerformanceMetrics> MeasurePagePerformanceAsync(string url)
{
await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true
});
await using var page = await browser.NewPageAsync();
var stopwatch = Stopwatch.StartNew();
await page.GoToAsync(url, new NavigationOptions
{
WaitUntil = new[] { WaitUntilNavigation.Networkidle2 }
});
stopwatch.Stop();
// Get performance metrics from the browser
var metrics = await page.MetricsAsync();
// Get performance timing data
var performanceTiming = await page.EvaluateExpressionAsync<PerformanceTiming>(@"
JSON.parse(JSON.stringify(performance.timing))
");
return new PerformanceMetrics
{
TotalLoadTime = stopwatch.ElapsedMilliseconds,
DomContentLoaded = performanceTiming.DomContentLoadedEventEnd - performanceTiming.NavigationStart,
FirstPaint = metrics["FirstPaint"],
LayoutCount = (int)metrics["LayoutCount"],
ScriptDuration = metrics["ScriptDuration"]
};
}
}
将 HTML 与 PDF 生成的自动报告组合在一起:
public class MonthlyReportGenerator
{
public async Task<byte[]> GenerateMonthlyReportAsync(ReportData data)
{
await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true
});
await using var page = await browser.NewPageAsync();
// Generate HTML report using your preferred templating engine
var html = GenerateReportHtml(data);
await page.SetContentAsync(html);
// Wait for any charts to render (if using Chart.js, D3.js, etc.)
await Task.Delay(2000);
return await page.PdfDataAsync(new PdfOptions
{
Format = PaperFormat.A4,
PrintBackground = true,
DisplayHeaderFooter = true,
HeaderTemplate = $@"
<div style='font-size: 9px; margin: 0 auto; text-align: center;'>
Monthly Report - {data.Month:MMMM yyyy}
</div>
",
FooterTemplate = @"
<div style='font-size: 9px; margin: 0 auto; text-align: center;'>
Page <span class='pageNumber'></span> of <span class='totalPages'></span>
</div>
",
MarginOptions = new MarginOptions
{
Top = "25mm",
Bottom = "20mm",
Left = "15mm",
Right = "15mm"
}
});
}
}
使用Putoptipeer Sharp作为PDF一代的PDF用户, 这是“免费”的意思, 意思是您不支付PDF图书馆许可证, 资源无自由.
每个浏览器实例 :
比较于专用 PDF 库, 例如 :
对 PDFs 使用 Putppeteer Sharp 时:
何时使用专用 PDF 库 :
有时最好的解决办法是同时使用:
public class PdfService
{
private readonly ILogger<PdfService> _logger;
public async Task<byte[]> GeneratePdfAsync(PdfRequest request)
{
// Simple documents - use QuestPDF (fast, low resources)
if (request.IsSimpleLayout)
{
return GenerateWithQuestPdf(request);
}
// Complex documents with web content - use PuppeteerSharp
return await GenerateWithPuppeteerAsync(request);
}
private byte[] GenerateWithQuestPdf(PdfRequest request)
{
// QuestPDF code here - much faster for simple layouts
return Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(2, Unit.Centimetre);
page.Content().Text(request.Content);
});
}).GeneratePdf();
}
private async Task<byte[]> GenerateWithPuppeteerAsync(PdfRequest request)
{
// PuppeteerSharp code for complex layouts
await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true
});
await using var page = await browser.NewPageAsync();
await page.SetContentAsync(request.HtmlContent);
return await page.PdfDataAsync();
}
}
PutopieerSharp 在我的.NET 工程中是 E2E 测试的绝对游戏交换器。 它比更快, 具有更现代化的API, 通常测试比杂技要少。
下面我建议:
以 Putputeeer 开始 Sharp 开始 如果你只是测试铬/铬。它比替代品简单快捷。
使用 Playwright 需要多浏览器支持时,可以享受木偶夏尔普加Firefox和Safari的好处。
避免 用于新项目,除非有特定理由使用(如IE11支持,希望你不会使用)。
谨慎写作测试E2E 测试缓慢,而且可能易碎。使用它们进行关键的用户旅程,而不是测试每一个小细节。这就是单元和集成测试的目的。
保持独立测试. 每次测试应建立自己的数据,然后进行清理。
使用助手方法 我展示的基本级图案 让你的实际测试代码保持干净 专注于测试内容 而不是测试方式
E2E 测试不必是痛苦的。 有了正确的工具和模式, 它实际上是相当愉快的。 给 Putppiteeer Sharp 来一个您的下一个项目- 我想你会很惊讶的。
好了,我要去写更多的测试了 快乐的测试!
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.