Modern E2E (End-To-End, använda din webbplats som användare skulle) testning behöver inte vara smärtsam. Denna omfattande guide visar dig hur du använder PuppeterSharp för snabb, tillförlitlig webbläsarautomation i .NET-täcka allt från grundläggande testning till PDF-generering och webbskrapning. Medan Microsofts Playwright är den modernare multi-browser lösning, Jag valde PuppeterSharp för den här bloggen eftersom det är vad jag visste och Chrome-bara testning var tillräckligt för mina behov. Om du behöver Firefox och Safari stöd, kolla in min Uppspelningsguide istället.
Om du någonsin har jobbat med Med en tjocklek av mer än 0,15 mm men högst 0,15 mm För end-to-end testing, du vet att det kan vara en rätt smärta på baksidan. Mellan brottning med förare versioner, hantera flagnande tester som fungerar på din maskin men ingen annanstans, och den allmänna tröghet i WebDriver protokollet, det är tillräckligt för att få dig att vilja kasta allt i och testa manuellt i stället.
Ange PuppeterSharp - .NET-porten för Googles PuppeterName Det är som Seleniums yngre, snabbare kusin som faktiskt bryr sig om att dyka upp i tid och inte kräver att du hämtar sjutton olika webbläsardrivrutiner.
I denna artikel, Jag ska gå igenom hur jag har genomfört PuppeterSharp för E2E testning på denna blogg, komplett med verkliga kod exempel från repo. Vi ska täcka testning, PDF-generering, webbskrapning, och jämföra det med alternativen.
PuppeterSharp är ett .NET-bibliotek som tillhandahåller en hög nivå API för att styra Chrome eller krom webbläsare med hjälp av Chrome DevTools-protokollet. Till skillnad från Med en tjocklek av mer än 0,15 mm men högst 0,15 mm, som använder WebDriver- protokoll (en ganska klumpig HTTP-baserad JSON wire protokoll), PuppeteerSharp talar direkt till webbläsaren via DevTools.
Tänk på det så här:
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
Innan vi dyker djupare, låt oss tala om var E2E-testning passar in i det stora schemat av saker. Du har förmodligen hört talas om testpyramid - här är hur det faktiskt fungerar i praktiken:
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
Verklighetens kontroll:
När du behöver E2E-test:
När du inte behöver E2E-test:
Låt mig räkna sätt:
Ingen drivrutinshantering Faff: PuppeterSharp hämtar och hanterar webbläsaren Chrome åt dig. Inget mer mucking om med ChromeDriver versioner som inte matchar din installerade Chrome-version.
Snabbare körning: DevTools Protocol är betydligt snabbare än WebDriver. Dina tester kommer att gå snabbare, och du kommer att spendera mindre tid väntar på saker att hända.
Bättre API: API är mer modern och intuitiv. Det är async / avänta hela vägen ner, som passar vackert med modern .NET-utveckling.
Inbyggd skärmdump och PDF-generering: Vill du ha en skärmbild när ett test misslyckas? Det är helt enkelt med PuppeterSharp.
Interceptera nätverksförfrågningar: Du kan snappa upp, ändra eller blockera nätverksförfrågningar med lätthet - lysande för att testa offline-scenarier eller håna API-svar.
Korrekt JavaScript- körning: Kör JavaScript i sidsammanhang och få resultat tillbaka på ett sätt som inte gör att du vill gråta.
Lägg först till PuppeterSharp NuGet-paket:
dotnet add package PuppeteerSharp
Här är min testprojektkonfiguration (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>
Jag använder xUnit (ASP.NET Core standard), men PuppeteerSharp fungerar lika bra med Nunit Ordförande eller MSTest Ordförande.
I stället för att upprepa setup/inardown-kod i varje test, har jag skapat en basklass (Mostlylucid.Test/E2E/E2ETestBase.cs:12) som hanterar hantering av webbläsares livscykel:
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;
}
Vi genomför IAsyncLivstid från xUnit, som tillhandahåller async setup/teardown. Till skillnad från traditionella konstruktörer, gör detta att vi på rätt sätt väntar på webbläsarens initiering.
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;
}
och BrowserFetcher Hämtar automatiskt en kompatibel Chromium-version vid första körningen - ingen manuell drivrutinshantering behövs. --no-sandbox Flaggningar krävs för Docker/CI-miljöer.
public async Task DisposeAsync()
{
if (Page != null) await Page.CloseAsync();
if (Browser != null) await Browser.CloseAsync();
}
}
Korrekt bortskaffande är viktigt för att undvika minnesläckage. Varje webbläsarinstans använder 100-200 MB RAM.
Basklassen innehåller hjälpmetoder för att minska pannplattan (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);
}
Dessa hanterar de tråkiga bitarna - väntar på element att existera, graciös timeout hantering, och automatisk loggning för när tester misslyckas i CI.
Okej, låt oss komma till de bra sakerna - skriva faktiska tester. Här är ett riktigt test från min blogg filter bar funktionalitet (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");
}
Detta test kontrollerar att mitt språk dropdown fungerar korrekt. Låt oss titta på vad som får det att ticka:
[Fact(Skip = "Local E2E test - requires site to be running on localhost:8080")]
Jag har hoppat över detta test som standard eftersom det kräver att webbplatsen körs lokalt. För E2E tester, du vanligtvis vill köra dem on-demand snarare än med varje bygge. Du kan packa upp dem när du är redo att köra dem, eller köra dem i en separat CI jobb där du har webbplatsen spunnen.
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';
}");
Detta är ett av de områden där PuppeteerSharp absolut lyser. EvaluateFunctionAsync metoden låter dig köra JavaScript i webbläsaren sammanhang och få resultatet tillbaka som en riktig .NET-typ. I detta fall, Jag kontrollerar om en dropdown är faktiskt synlig (inte bara närvarande i DOM) genom att titta på dess beräknade stilar.
Jämför detta med Selenium där du behöver:
Min blogg använder HTMX Ordförande I stor utsträckning (server-side rendering utan att skriva JavaScript). Här är ett test som kontrollerar sorteringsfunktionalitet (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");
}
Nyckeln här är await WaitAsync(1000) Efter att ha ändrat valvärdet. HTMX behöver ett ögonblick för att göra sin begäran och uppdatera DOM. I en perfekt värld, skulle vi vänta på en specifik nätverksbegäran att slutföra, men för enkla fall, en kort fördröjning är bra.
Här är ett fräckt test som kontrollerar att min filter bar är ordentligt gömd på mobila enheter (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
});
}
Du kan ändra visningen när som helst, vilket är lysande för att testa responsiva layouter. Mycket enklare än att ändra storlek på webbläsarens fönster manuellt!
En av mina favoritfunktioner är möjligheten att avlyssna och ändra nätverksförfrågningar. Detta är ovärderligt för att testa feltillstånd eller offline-scenarier:
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();
}
};
När ett test misslyckas är en skärmdump värd tusen loggmeddelanden:
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
}
Du kan även generera PDF-filer av sidor, som är användbart för att testa server-side rendering eller skriva ut stilmallar:
await Page.PdfAsync("page.pdf", new PdfOptions
{
Format = PaperFormat.A4,
PrintBackground = true
});
PuppeterSharp kan även samla JavaScript kod täckningsdata:
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}%");
Låt oss ta en ordentlig titt på hur PuppeterSharp staplar upp mot andra E2E-testverktyg:
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
Det gamla gardet
Selen har funnits sedan 2004 och det visar. Det är mogen, väldokumenterad, och stöder varje webbläsare under solen. Men det visar också sin ålder:
Förmåner:
Minus:
När du ska använda den: När du absolut behöver testa över flera webbläsare, eller när du redan är investerad i Selenium ekosystem.
Det nya barnet på blocket
Uppspelningsrätt är Microsofts svar på Puppeter, med Stöd för .NET bakad från början. Det är i huvudsak PuppeterSharp men med multi-browser stöd:
Förmåner:
Minus:
När du ska använda den: När du behöver multi-browser stöd men vill ha en modern API. Om du startar ett nytt projekt och behöver kors-browser testning, Playwright är förmodligen din bästa insats.
JavaScript-utvecklarens älskling
Cypress är lysande om du arbetar i JavaScript/TypeScript, men det är en icke-startare för .NET-utvecklare:
Förmåner:
Minus:
När du ska använda den: Håll dig till nåt som integrerar med din tech stack.
Här är min tagning:
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
För de flesta .NET-utvecklare bygga moderna webbapplikationer:
E2E tester är alla bra och bra på din lokala maskin, men de måste köras i CI / CD rörledningar också. Så här har jag satt upp saker för GitHub-åtgärder:
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
Nyckelbitarna:
E2E tester kan vara flagnande - de passerar ibland och misslyckas andra. Detta är oftast ner till timing frågor. Här är hur man undviker dem:
Dåligt.
await Page.ClickAsync("#button");
var text = await GetTextContentAsync("#result");
Assert.Equal("Success", text);
Bra.
await Page.ClickAsync("#button");
await Page.WaitForSelectorAsync("#result");
var text = await GetTextContentAsync("#result");
Assert.Equal("Success", text);
Vänta alltid på att det element du ska interagera med ska existera och vara synligt.
Varje test bör vara helt oberoende. Lita inte på tillstånd från tidigare tester:
Dåligt.
[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");
}
Bra.
[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
}
För komplexa sidor, använd mönstret Page Object för att hålla dina tester underhållbara:
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 tester är långsammare än enhet tester, det finns ingen att komma runt det. Men du kan göra dem snabbare:
xUnit kör tester parallellt som standard, men du måste vara försiktig med delat tillstånd:
[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
}
Snabba upp tester genom att inaktivera funktioner du inte behöver:
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
}
});
Blockera onödiga resurser för att påskynda saker och ting:
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();
}
};
När tester misslyckas (och de kommer), måste du felsöka dem. Här är några tekniker:
Ställ in Headless = false för att titta på webbläsaren i funktion:
Browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = false,
SlowMo = 100, // Slow down by 100ms to see what's happening
});
Du kan faktiskt öppna DevTools programmatiskt:
Browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = false,
Devtools = true, // Auto-open DevTools
});
Fånga konsolmeddelanden från webbläsaren:
Page.Console += (sender, e) =>
{
Output.WriteLine($"Browser console: {e.Message.Text}");
};
Logga alla nätverksförfrågningar:
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}");
};
Här är några mönster som jag använder regelbundet i mina E2E tester:
[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);
}
Du kan integrera PuppeterSharp med ASP.NET Core WebApplicationFactory För en mer integrerad testupplevelse:
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";
}
}
Medan E2E-testning är lysande, är PuppeteerSharp en schweizisk armékniv som kan göra mycket mer. En av dess mest populära användningsområden är att generera PDF-filer från webbinnehåll - det är otroligt användbart för detta, men inte utan dess getchas. Om du bygger fakturor, rapporter, eller något dokumentgenereringssystem, kommer detta avsnitt spara timmar av felsökning.
Idén är enkel: gör en webbsida i Chrome och spara den som en PDF. Perfekt för att generera fakturor, rapporter, certifikat, eller något dynamiskt innehåll som behöver distribueras i PDF-format.
Här är det grundläggande tillvägagångssättet:
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;
}
}
Jag följer dig genom landminorna.
Problemet: Dina vackra anpassade teckensnitt visas inte i PDF, eller värre, de är där men ser helt skräp.
Varför det händer: Chrome behöver tillgång till teckensnittsfilerna under PDF-genereringen. Om dina teckensnitt laddas via extern CDN och Chrome inte kan nå dem (brandvägg, nätverksproblem, timing), är du stoppad.
Lösningen:
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
});
Ännu bättre, värd dina teckensnitt lokalt eller bädda in dem som bas64 i din CSS. Ja, det är en faff, men det är pålitlig.
Problemet: Din PDF ser inte alls ut som din webbsida eftersom Chrome tillämpar tryck mediefrågor.
Det här är faktiskt korrekt beteende PDF:er är tryckta medier, men det fångar upp alla första gången.
Lösningen:
Användning @media print CSS-regler på lämpligt sätt:
/* 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;
}
}
Eller, om du vill ha skärmversionen i din PDF (användbar för att generera "screenshots" som PDF:er):
await page.EmulateMediaTypeAsync(MediaType.Screen); // Force screen media
var pdfData = await page.PdfDataAsync();
Problemet: Ditt innehåll blir pinsamt delat på sidor, med rubriker föräldralösa längst ner eller tabeller halverade.
Verkligheten: Du kämpar mot Chromes interna pagineringsalgoritm, och den kommer att vinna för det mesta.
Vad du kan göra:
@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;
}
}
Och i din PuppeterSharp-kod:
var pdfData = await page.PdfDataAsync(new PdfOptions
{
Format = PaperFormat.A4,
PrintBackground = true,
PreferCSSPageSize = true, // Respect CSS @page rules
DisplayHeaderFooter = false
});
Pro-tips: För komplexa layouter, ibland är det lättare att strukturera din HTML med explicita sidbrytningar snarare än att bekämpa webbläsaren:
<div class="page">
<!-- First page content -->
</div>
<div class="page-break"></div>
<div class="page">
<!-- Second page content -->
</div>
Du kan lägga till headers och sidfotar, men API är lite wonky:
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
}
});
Jag fattar.
date, title, url, pageNumber, totalPagesSom standard, Chrome inte skriva ut bakgrundsbilder eller färger (detta är en webbläsare standard för att spara bläck). måste aktivera den:
var pdfData = await page.PdfDataAsync(new PdfOptions
{
PrintBackground = true // Without this, your beautiful backgrounds vanish
});
Problemet: Generera massor av PDF-filer orsakar din ansökan minne ballong och så småningom krascha.
Varför: Varje webbläsarinstans använder betydande minne (100-200MB), och om du inte kasserar ordentligt, de staplar upp.
Lösningen:
Använd alltid await using eller lämpligt bortskaffande:
// 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();
}
}
För hög volym PDF-generering, överväga att återanvända webbläsarinstanser:
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();
}
}
Ibland behöver du få mer innehåll på en sida:
var pdfData = await page.PdfDataAsync(new PdfOptions
{
Format = PaperFormat.A4,
Scale = 0.8m, // 80% scale - fits more content
PrintBackground = true
});
Men var försiktig - för liten och det är oläsligt.
Så här gör jag faktiskt PDF-generering i produktionen:
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>";
}
}
Enkelt men ofta nödvändigt:
var pdfData = await page.PdfDataAsync(new PdfOptions
{
Format = PaperFormat.A4,
Landscape = true, // Horizontal orientation
PrintBackground = true
});
Inte begränsat till standardformat:
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
});
Utöver testning och PDF-generering utmärker sig PuppeteerSharp på flera andra automationsuppgifter. Låt oss utforska de vanligaste verkliga applikationerna.
PuppeterSharp är lysande för att skrapa JavaScript-tunga webbplatser där traditionella HTML-tolkar faller kort:
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;
}
}
När du ska använda den:
När du inte ska använda den:
Utöver testning är skärmdumpar användbara för miniatyrbilder, förhandsgranskningar eller arkivering:
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();
}
}
Praktiska användningsområden:
Mät sidbelastningens prestanda:
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"]
};
}
}
Kombinera HTML-templering med PDF-generering för automatiserad rapportering:
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"
}
});
}
}
Nu, här är grejen med att använda PuppeterSharp för PDF-generering - det är "gratis" i den meningen att du inte betalar för en PDF-bibliotek licens, men det är inte fri när det gäller resurser.
Varje browser instans:
Jämför detta med dedikerade PDF-bibliotek som:
När du ska använda PuppeterSharp för PDF-filer:
När du ska använda dedikerade PDF-bibliotek:
Ibland är den bästa lösningen att använda båda:
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();
}
}
PuppeterSharp har varit en absolut game-changer för E2E-testning i mina .NET-projekt. Det är snabbare än Selenium, har ett modernare API, och bara generellt gör testning mindre av en uppgift.
Här är vad jag skulle rekommendera:
Börja med PuppeterSharp Om du bara testar Chrome/krom. Det är enklare och snabbare än alternativen.
Använd spelrätt om du behöver stöd för multi-browser. Det har alla fördelar med PuppeterSharp plus Firefox och Safari.
Undvik Selen för nya projekt om du inte har en särskild anledning att använda det (som IE11 stöd, vilket förhoppningsvis du inte).
Skriv tester på ett klokt sätt. E2E tester är långsamma och kan vara sprött. Använd dem för kritiska användarresor, inte för att testa varje liten detalj. Det är vad enhet och integration tester är för.
Håll testerna isolerade. Varje test bör ställa in sina egna data och städa upp efter sig själv.
Använd hjälpmetoder Basklassens mönster håller din testkod ren och fokuserad på det du testar, inte hur du testar den.
E2E-testning behöver inte vara smärtsam. Med rätt verktyg och mönster kan det faktiskt vara ganska trevligt. Ge PuppeterSharp ett försök på ditt nästa projekt - jag tror att du kommer att bli glatt överraskad.
Jag ska skriva fler tester.
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.