Back to "Test end-to-End con PuppeteerSharp - Una corretta alternativa al selenio"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

E2E Testing PuppeteerSharp Testing xUnit

Test end-to-End con PuppeteerSharp - Una corretta alternativa al selenio

Thursday, 27 November 2025

Moderno E2E (End-To-End, utilizzando il tuo sito come gli utenti vorrebbero) test non deve essere doloroso. Questa guida completa vi mostra come utilizzare PuppeteerSharp per veloce, affidabile automazione del browser in .NET-covering tutto da test di base per la generazione di PDF e raschiare web. Mentre Microsoft Playwright è la più moderna soluzione multi-browser, Ho scelto PuppeteerSharp per questo blog perché è quello che sapevo e Chrome-solo test era sufficiente per le mie esigenze. Se avete bisogno di Firefox e supporto Safari, controllare il mio Guida di Playwright Invece.

Introduzione

Se hai mai lavorato con Selenio Per i test end-to-end, saprete che può essere un giusto dolore sul retro. Tra il wrestling con le versioni del driver, che si occupano di test flaky che funzionano sulla vostra macchina, ma da nessun'altra parte, e la lentezza generale del protocollo WebDriver, è sufficiente per farvi desiderare di buttare tutto dentro e testare manualmente invece.

Entra PuppeteerSharp - la porta .NET di Google PuppeteerCity name (optional, probably does not need a translation) biblioteca. E 'come il cugino più giovane di Selenium, più veloce che in realtà si preoccupa di presentarsi in tempo e non richiede di scaricare diciassette driver browser diversi.

In questo articolo, vi spiegherò come ho implementato PuppeteerSharp per il test E2E su questo stesso blog, completo di esempi di codice reale dal repo. Copriremo test, generazione PDF, raschiatura web, e confrontare con le alternative.

Cos'e' PuppeteerSharp allora?

PuppeteerSharp è una libreria .NET che fornisce un alto livello API per controllare i browser Chrome o Chromium utilizzando il Protocollo DevTools Chrome. A differenza Selenio, che utilizza il Protocollo WebDriver (un clunky HTTP-based protocollo di filo JSON), PuppeteerSharp parla direttamente al browser attraverso DevTools.

Pensala in questo modo:

  • Selenio: Come inviare lettere attraverso il post per comunicare con il tuo browser
  • PuppeteerSharp: Come avere una linea telefonica diretta al cervello del browser
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

Dove la prova E2E si adatta alla strategia di prova

Prima di immergerci più a fondo, parliamo di dove i test E2E rientrano nel grande schema delle cose. Probabilmente avete sentito parlare della piramide dei test - ecco come funziona in pratica:

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

Il controllo della realtà:

  • Prove di unità (80% dei vostri test): Veloce, economico, testare le singole funzioni. Ma non vi dicono se il sistema funziona effettivamente nel suo complesso.
  • Test di integrazione (15% dei tuoi test): prova come funzionano insieme diverse parti. Più veloce di E2E ma non testare l'interfaccia utente completa.
  • Prove E2E (5% dei test): Lento, costoso, ma testare il sistema esattamente come gli utenti lo sperimentano. Qui è dove PuppeteerSharp brilla.

Quando hai bisogno di test E2E:

  1. Viaggi critici per l'utente - Login, checkout, elaborazione dei pagamenti. Se questi break, il vostro business si ferma.
  2. UI di JavaScript-heavy - SPA moderne (Reagisci, VueCity name (optional, probably does not need a translation), Angolare) dove l'interfaccia utente è resa client-side.
  3. Questioni relative al browser trasversale - Browser diversi rendere le cose in modo diverso (anche se con PuppeteerSharp sei solo Chrome).
  4. Interazioni complesse - Maghi multi-passo, drag-and-drop, upload di file.

Quando non hai bisogno di test E2E:

  1. Semplici operazioni CRUD - I test di integrazione sono sufficienti.
  2. Logica pura - E' a questo che servono i test dell'unita'.
  3. Ogni caso di bordo - I test E2E sono troppo lenti e costosi per prove esaustive.

Perché PuppeteerSharp over Selenium?

Fammi contare i modi:

  1. Nessun Driver Management Faff: PuppeteerSharp scarica e gestisce il browser Chrome per voi. Basta chiacchiere con le versioni ChromeDriver che non corrispondono alla versione installata Chrome.

  2. Esecuzione più rapida: Il protocollo DevTools è significativamente più veloce di WebDriver. I tuoi test saranno eseguiti più velocemente, e trascorrerete meno tempo aspettando che le cose accadano.

  3. API migliori: L'API è più moderna e intuitiva. E 'async / attesa fino in fondo, che si adatta splendidamente con lo sviluppo .NET moderno.

  4. Schermata integrata e generazione PDF: Vuoi uno screenshot quando un test fallisce? E 'morto semplice con PuppeteerSharp.

  5. Intercetta richieste di rete: È possibile intercettare, modificare o bloccare le richieste di rete con facilità - brillante per testare scenari offline o deridere le risposte API.

  6. Esecuzione JavaScript corretta: Esegui JavaScript nel contesto pagina e ottenere risultati indietro in un modo che non ti fa venire voglia di piangere.

Impostazione PuppeteerSharp

In primo luogo, aggiungere il PuppeteerSharp Pacchetto NuGet:

dotnet add package PuppeteerSharp

Ecco la mia configurazione del progetto di test (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>

Sto usando xUnit (ASP.NET Core di default), ma PuppeteerSharp funziona ugualmente bene con NUnitCity name (optional, probably does not need a translation) oppure MSTestCity name (optional, probably does not need a translation).

Creazione di una classe di prova di base

Invece di ripetere il codice setup/teardown in ogni test, ho creato una classe base (Mostlylucid.Test/E2E/E2ETestBase.cs:12) che gestisce la gestione del ciclo di vita del browser:

Struttura della classe

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;
    }

Implementiamo IAsyncLifetimeCity name (optional, probably does not need a translation) da xUnit, che fornisce async setup/teardown. A differenza dei costruttori tradizionali, questo ci permette di attendere correttamente l'inizializzazione del browser.

Inizializzazione del browser

    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;
    }

La BrowserFetcher scarica automaticamente una versione compatibile Chromium al primo avvio - nessuna gestione manuale dei driver necessari. --no-sandbox sono richieste bandiere per gli ambienti Docker/CI.

Pulizia

    public async Task DisposeAsync()
    {
        if (Page != null) await Page.CloseAsync();
        if (Browser != null) await Browser.CloseAsync();
    }
}

Il corretto smaltimento è fondamentale per evitare perdite di memoria. Ogni istanza del browser utilizza 100-200MB di RAM.

Metodi helper

La classe di base comprende metodi di aiuto per ridurre la piastra caldaia (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);
}

Questi maneggiano i bit noiosi - in attesa dell'esistenza di elementi, gestione graziosa del timeout, e registrazione automatica per quando i test falliscono in CI.

Scrivere test effettivi

Bene, andiamo alla roba buona - scrivere test reali. Ecco un vero test dalla funzionalità della barra dei filtri del mio blog (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");
}

Questo test sta verificando che il mio menu a discesa lingua funziona correttamente. Vediamo cosa lo rende segno di spunta:

L'attributo Salta

[Fact(Skip = "Local E2E test - requires site to be running on localhost:8080")]

Ho saltato questo test per impostazione predefinita perché richiede che il sito sia in esecuzione localmente. Per i test E2E, di solito si desidera eseguire su richiesta piuttosto che con ogni build. È possibile sskip quando si è pronti a eseguirli, o eseguirli in un lavoro CI separato dove hai il sito filato.

Esecuzione di JavaScript

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';
}");

Questa è una delle zone in cui PuppeteerSharp brilla assolutamente. EvaluateFunctionAsync metodo consente di eseguire JavaScript nel contesto del browser e ottenere il risultato indietro come un corretto tipo .NET. In questo caso, Sto controllando se un a discesa è effettivamente visibile (non solo presente nel DOM) guardando i suoi stili calcolati.

Confronta questo a Selenio dove è necessario:

  1. Trova l'elemento
  2. Ottieni la sua proprietà di visualizzazione
  3. Analizza il risultato della stringa
  4. Spero che non sia stantio quando lo controllerai.

Prova delle interazioni HTMX

Il mio blog utilizza HTMX ampiamente (rendering lato server senza scrittura JavaScript). Ecco un test che controlla la funzionalità di ordinamento (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");
}

La chiave qui è il await WaitAsync(1000) Dopo aver cambiato il valore selezionato. HTMX ha bisogno di un momento per fare la sua richiesta e aggiornare il DOM. In un mondo perfetto, saremmo in attesa di una specifica richiesta di rete per completare, ma per i casi semplici, un breve ritardo va bene.

Testing Responsive Design

Qui è un test sfacciato che controlla la mia barra del filtro è correttamente nascosta sui dispositivi mobili (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
    });
}

È possibile modificare la vista in qualsiasi momento, che è brillante per testare i layout reattivi. Molto più facile che ridimensionare la finestra del browser manualmente!

Caratteristiche avanzate PuppeteerSharp

Intercezione della rete

Una delle mie caratteristiche preferite è la possibilità di intercettare e modificare le richieste di rete. Ciò è inestimabile per verificare gli stati di errore o gli scenari offline:

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();
    }
};

Screenshot

Quando un test fallisce, uno screenshot vale mille messaggi di log:

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
}

Generazione PDF

È anche possibile generare PDF di pagine, che è utile per testare il rendering lato server o stampare fogli di stile:

await Page.PdfAsync("page.pdf", new PdfOptions
{
    Format = PaperFormat.A4,
    PrintBackground = true
});

Copertura del codice

PuppeteerSharp può anche raccogliere i dati di copertura del codice 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}%");

PuppeteerSharp contro il concorso

Diamo un'occhiata corretta a come PuppeteerSharp stack contro altri strumenti di test 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

Selenio WebDriver

La vecchia guardia.

Selenium è in giro dal 2004 e mostra. È maturo, ben documentato, e supporta ogni browser sotto il sole. Ma sta anche mostrando la sua età:

Pro:

  • Supporta tutti i browser (Chrome, Firefox, Safari, Edge, IE se sei un masochista)
  • Massiccio ecosistema di strumenti ed estensioni
  • Conosciuto e ampiamente adottato
  • Buono per il test cross-browser

Punti negativi:

  • Il protocollo WebDriver è lento
  • La gestione del conducente è un dolore (anche se WebDriverManager aiuta)
  • API si sente datato rispetto alle alternative moderne
  • Flaky tests sono comuni a causa di problemi di temporizzazione
  • Nessuna intercettazione di rete integrata

Quando usarlo: Quando è assolutamente necessario testare su più browser, o quando si è già investito nell'ecosistema Selenium.

PlaywrightCity name (optional, probably does not need a translation)

Il nuovo ragazzo sul blocco

PlaywrightCity name (optional, probably does not need a translation) è la risposta di Microsoft a Puppeteer, con Supporto .NET è essenzialmente PuppeteerSharp ma con supporto multi-browser:

Pro:

  • Supporta Chrome, Firefox, Safari (WebKit)
  • API moderne simili a Puppeteer
  • Scarica automaticamente i browser
  • Intercettazione di rete integrata, screenshot, ecc.
  • Eccellente supporto .NET

Punti negativi:

  • Più recente, così piccolo ecosistema
  • Può essere eccessivo se hai solo bisogno di Chrome
  • Configurazione leggermente più complessa grazie al supporto multi-browser

Quando usarlo: Quando hai bisogno di supporto multi-browser ma vuoi una API moderna. Se stai iniziando un nuovo progetto e hai bisogno di test cross-browser, Playwright è probabilmente la tua migliore scommessa.

CypressCity name (optional, probably does not need a translation)

Il tesoro dello sviluppatore JavaScript

Cypress è brillante se si sta lavorando in JavaScript/TypeScript, ma è un non-starter per gli sviluppatori .NET:

Pro:

  • Fantastica esperienza di sviluppatore
  • Debug del viaggio nel tempo
  • Attendere automaticamente
  • Ottima documentazione

Punti negativi:

  • Solo JavaScript/TypeScript
  • Nessun supporto .NET
  • Impossibile testare più schede o finestre
  • Limitato a testare la propria applicazione (nessun test tra i domini)

Quando usarlo: Non, stai scrivendo il codice .NET. Attieniti a qualcosa che si integra con il tuo stack tecnologico.

Allora, cosa dovresti usare?

Ecco la mia opinione:

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

Per la maggior parte degli sviluppatori .NET costruire applicazioni web moderne:

  • Solo Chrome testing? → PuppeteerSharp
  • Test multi-browser? → Playwright
  • Hai gia' usato il Selenio? → Considerare la migrazione a Playwright, ma non correre

Eseguire test in CI/CD

I test E2E sono tutti buoni e buoni sulla vostra macchina locale, ma hanno bisogno di funzionare in pipeline CI / CD troppo. Ecco come ho impostato le cose per Azioni 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

I bit chiave:

  1. Avvia l'applicazione in background
  2. Attendere che sia sano (usando un endpoint di controllo dello stato di salute)
  3. Eseguire i test E2E
  4. Caricare gli screenshot se eventuali test falliscono
  5. Fermare sempre l'applicazione, anche se i test falliscono

Pitfalls comuni e come evitarli

Prove di sfaldamento

I test E2E possono essere sgradevoli - a volte passano e falliscono gli altri. Questo è di solito fino a problemi di tempismo. Ecco come evitarli:

Scorretto:

await Page.ClickAsync("#button");
var text = await GetTextContentAsync("#result");
Assert.Equal("Success", text);

Bene:

await Page.ClickAsync("#button");
await Page.WaitForSelectorAsync("#result");
var text = await GetTextContentAsync("#result");
Assert.Equal("Success", text);

Attendere sempre l'elemento con cui stai per interagire per esistere ed essere visibile.

Isolamento della prova

Ogni prova deve essere completamente indipendente. Non fare affidamento sullo stato dei test precedenti:

Scorretto:

[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");
}

Bene:

[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
}

Schema oggetto pagina

Per pagine complesse, usa il modello Page Object per mantenere i tuoi test manutenibili:

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));
}

Considerazioni sulle prestazioni

I test E2E sono piu' lenti dei test unitari, non c'e' modo di aggirarli, ma puoi renderli piu' veloci:

Esegui test in parallelo

xUnit esegue test in parallelo per impostazione predefinita, ma devi stare attento allo stato condiviso:

[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
}

Disabilita funzionalità non necessarie

Accelera i test disabilitando le funzioni che non ti servono:

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
    }
});

Usa saggiamente l'intercezione della rete

Bloccare le risorse inutili per accelerare le cose:

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();
    }
};

Debug dei test E2E

Quando i test falliscono (e lo faranno), è necessario debug loro. Ecco alcune tecniche:

Esegui in modalità senza testa

Imposta Headless = false per guardare il browser in azione:

Browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
    Headless = false,
    SlowMo = 100, // Slow down by 100ms to see what's happening
});

Usa DevTools

È possibile aprire DevTools in modo programmatico:

Browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
    Headless = false,
    Devtools = true, // Auto-open DevTools
});

Console di registrazione

Cattura messaggi di console dal browser:

Page.Console += (sender, e) =>
{
    Output.WriteLine($"Browser console: {e.Message.Text}");
};

Richiesta registrazione

Registra tutte le richieste di rete:

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}");
};

Modelli di test del mondo reale

Ecco alcuni modelli che uso regolarmente nei miei test E2E:

Presentazione del modulo di prova

[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());
}

Verifica delle interazioni con la tastiera

[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);
}

Verifica degli upload dei file

[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);
}

Prova di trascinamento e caduta

[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);
}

Integrazione con ASP.NET Core Testing

Puoi integrare PuppeteerSharp con WebApplicationFactory di ASP.NET Core per un'esperienza di test più integrata:

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";
    }
}

Beyond Testing - PuppeteerSharp per la generazione e l'automazione PDF

Mentre il test E2E è brillante, PuppeteerSharp è un coltello svizzero che può fare molto di più. Uno dei suoi usi più popolari è la generazione di PDF da contenuti web - è incredibilmente utile per questo, anche se non senza il suo gotchas. Se si sta costruendo fatture, report, o qualsiasi sistema di generazione di documenti, questa sezione vi farà risparmiare ore di debug.

Generazione di PDF - La promessa e il dolore

L'idea è semplice: rendere una pagina web in Chrome e salvarlo come un PDF. Perfetto per generare fatture, report, certificati, o qualsiasi contenuto dinamico che deve essere distribuito in formato PDF.

Ecco l'approccio di base:

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;
    }
}

Beh, lo e'... finche' non lo e'.

Generazione PDF Gotchas - quello che nessuno ti dice

1. Incubi di incubo di carattere

Il problema: I tuoi bellissimi font personalizzati non appaiono in PDF, o peggio, sono lì ma sembrano assolutamente spazzatura.

Perché succede: Chrome ha bisogno di accesso ai file di font durante la generazione PDF. Se i tuoi font vengono caricati tramite CDN esterno e Chrome non può raggiungerli (firewall, problemi di rete, tempi), si è riempito.

La soluzione:

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
});

Ancora meglio, ospitare i tuoi font localmente o incorporarli come base64 nel tuo CSS. Sì, è un faff, ma è affidabile.

2. CSS Print Media Queries

Il problema: Il tuo PDF non assomiglia affatto alla tua pagina web perché Chrome applica richieste multimediali di stampa.

Questo e' in realta' comportamento corretto I PDF sono supporti di stampa, ma catturano tutti la prima volta.

La soluzione:

Uso @media print Norme CSS in modo appropriato:

/* 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;
    }
}

Oppure, se si desidera la versione dello schermo nel vostro PDF (utile per generare "istantanee" come PDF):

await page.EmulateMediaTypeAsync(MediaType.Screen);  // Force screen media
var pdfData = await page.PdfDataAsync();

3. Page Breaks - Il Bane della Tua Esistenza

Il problema: Il tuo contenuto si divide in modo imbarazzante tra le pagine, con titoli orfani in basso o tavoli tagliati a metà.

La Realtà: Stai combattendo contro l'algoritmo di paginazione interna di Chrome , e sta andando a vincere la maggior parte del tempo .

Cosa puoi fare:

@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;
    }
}

E nel tuo codice PuppeteerSharp:

var pdfData = await page.PdfDataAsync(new PdfOptions
{
    Format = PaperFormat.A4,
    PrintBackground = true,
    PreferCSSPageSize = true,  // Respect CSS @page rules
    DisplayHeaderFooter = false
});

Suggerimento Pro: Per layout complessi, a volte è più facile strutturare il vostro HTML con interruzioni di pagina esplicite piuttosto che combattere il browser:

<div class="page">
    <!-- First page content -->
</div>
<div class="page-break"></div>
<div class="page">
    <!-- Second page content -->
</div>

4. Intestazioni e piè di pagina - più complesso di quanto si possa pensare

È possibile aggiungere intestazioni e piè di pagina, ma l'API è un po 'whonky:

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
    }
});

Gotchas:

  • I modelli di intestazione/piede devono essere HTML validi ma sono estremamente limitati - nessun CSS esterno, nessun JavaScript
  • Si ottengono solo variabili specifiche: date, title, url, pageNumber, totalPages
  • Lo stile è solo in linea
  • I margini devono essere abbastanza grandi da ospitare intestazioni/piedi o si sovrappongono al contenuto

5. Grafica di sfondo

Per impostazione predefinita, Chrome non stampa immagini di sfondo o colori (questo è un browser predefinito per il salvataggio dell'inchiostro). deve abilitare:

var pdfData = await page.PdfDataAsync(new PdfOptions
{
    PrintBackground = true  // Without this, your beautiful backgrounds vanish
});

6. Memory Leaks con grandi documenti

Il problema: Generare un sacco di PDF causa la memoria dell'applicazione a palloncino e alla fine crash.

Perché: Ogni istanza del browser utilizza memoria significativa (100-200MB), e se non si dispone correttamente, si accumulano.

La soluzione:

Usare sempre await using o un'adeguata eliminazione:

// 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();
    }
}

Per la generazione di PDF ad alto volume, considerare la possibilità di riutilizzare le istanze del browser:

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();
    }
}

7. L'opzione scala - testo più piccolo, più contenuto

A volte è necessario inserire più contenuti su una pagina:

var pdfData = await page.PdfDataAsync(new PdfOptions
{
    Format = PaperFormat.A4,
    Scale = 0.8m,  // 80% scale - fits more content
    PrintBackground = true
});

Ma fate attenzione - troppo piccolo ed è illeggibile.

Modello di generazione PDF nel mondo reale

Ecco come faccio in realtà generazione PDF in produzione:

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>";
    }
}

Paesaggio vs Ritratto

Semplice ma spesso necessaria:

var pdfData = await page.PdfDataAsync(new PdfOptions
{
    Format = PaperFormat.A4,
    Landscape = true,  // Horizontal orientation
    PrintBackground = true
});

Dimensioni della pagina personalizzate

Non limitato ai formati standard:

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
});

Altri usi pratici per PuppeteerSharp

Oltre ai test e alla generazione PDF, PuppeteerSharp eccelle in diverse altre attività di automazione. Esploriamo le applicazioni più comuni del mondo reale.

Scraping Web per l'estrazione dei dati

PuppeteerSharp è brillante per raschiare i siti JavaScript-heavy dove i parser HTML tradizionali cadono a corto di:

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;
    }
}

Quando usarlo:

  • Scraping di applicazioni monopagina (React, Vue, Angolar)
  • Siti con carico infinito o pigro
  • Quando è necessario interagire con la pagina (cliccare sui pulsanti, riempire i moduli) prima di raschiare
  • Contenuto dietro le pareti di accesso

Quando NON usarlo:

  • Semplice raschiamento statico HTML (usare HtmlAgilityPack oppure AngoloCondividi invece - molto più veloce e più leggero)
  • Raschiatura ad alto volume (la sovrastruttura del browser è significativa)
  • Quando c'è una API disponibile (preferisci sempre le API ufficiali al raschiamento!)

Generazione automatica dello screenshot

Oltre ai test, gli screenshot sono utili per miniature, anteprime o archiviazione:

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();
    }
}

Usi pratici:

  • Generazione di tag og:image per i post del blog
  • Creazione di miniature per gallerie di siti web
  • Archiviazione delle pagine web per la conformità
  • Generazione delle immagini di anteprima per la condivisione dei link

Monitoraggio delle prestazioni

Misurare le prestazioni di carico della pagina:

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"]
        };
    }
}

Generazione automatica dei rapporti

Combina il templating HTML con la generazione PDF per la segnalazione automatica:

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"
            }
        });
    }
}

Il costo della generazione PDF "gratuita"

Ora, ecco il problema di utilizzare PuppeteerSharp per la generazione di PDF - è "libero" nel senso che non si paga per una licenza di libreria PDF, ma è non libero in termini di risorse.

Ogni istanza del browser:

  • Usa 100-200MB di RAM
  • Richiede CPU significativa per il rendering
  • Ci vogliono 2-5 secondi per generare un PDF (a seconda della complessità)

Confronta questo a librerie PDF dedicate come:

  • iText (ex iTextSharp) - Richiesta licenza commerciale (~ £ 500-3000/anno), ma genera PDF in millisecondi con minuscola impronta di memoria
  • QuestPDF - Libero e open source sotto licenza MIT, genera PDF da codice C# fluente (senza HTML), lampeggiante veloce
  • PdfSharpCore - Licenza MIT gratuita, ma più limitata nelle funzionalità

Quando usare PuppeteerSharp per i PDF:

  • Hai già dei template HTML e non vuoi riscrivere in codice PDF
  • Hai bisogno di pixel-perfect rendering di layout web complessi
  • Il volume è basso (< 100 PDF all'ora)
  • È necessario generare PDF da siti web esterni che non si controlla

Quando usare le librerie PDF dedicate:

  • Alta generazione di volume (> 100 PDF all'ora)
  • Layout semplici (fatture, ricevute, report)
  • Ambienti protetti dalle risorse
  • Servono funzionalità PDF avanzate (forme, firme, crittografia)

Un approccio ibrido

A volte la soluzione migliore è l'utilizzo di entrambi:

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();
    }
}

Conclusione

PuppeteerSharp è stato un cambio di gioco assoluto per il test E2E nei miei progetti .NET. E 'più veloce di Selenium, ha una API più moderna, e solo in generale rende il test meno di un compito.

Ecco cosa consiglierei:

  1. Inizia con PuppeteerSharp se stai solo testando Chrome / Chromium. E 'più semplice e più veloce rispetto alle alternative.

  2. Usa Playwright se avete bisogno di supporto multi-browser. Ha tutti i vantaggi di PuppeteerSharp più Firefox e Safari.

  3. Evitare il selenio per i nuovi progetti a meno che non abbiate un motivo specifico per utilizzarlo (come il supporto IE11, che si spera non si).

  4. Scrivere i test con giudizio. I test E2E sono lenti e possono essere fragili. Usali per i viaggi critici degli utenti, non per testare ogni minimo dettaglio. Ecco a cosa servono i test di unità e integrazione.

  5. Tenere isolati i test. Ogni test dovrebbe impostare i propri dati e ripulire dopo se stesso.

  6. Utilizzare metodi helper Il modello di classe base che ho mostrato mantiene il tuo codice di prova pulito e concentrato su quello che stai testando, non su come lo stai testando.

Il test E2E non deve essere doloroso. Con gli strumenti e i modelli giusti, in realtà può essere abbastanza piacevole. Dai a PuppeteerSharp un'occhiata al tuo prossimo progetto - penso che sarete piacevolmente sorpresi.

Bene, vado a fare altri test.

Ulteriore lettura

logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.