Back to "Τέλος-to-End Testing με PuppeteerSharp - Μια σωστή εναλλακτική λύση στο Σελήνιο"

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

Τέλος-to-End Testing με PuppeteerSharp - Μια σωστή εναλλακτική λύση στο Σελήνιο

Thursday, 27 November 2025

Σύγχρονη E2E (Τέλος-Τέλος, χρησιμοποιώντας την ιστοσελίδα σας όπως οι χρήστες θα) δοκιμή δεν χρειάζεται να είναι επώδυνη. Αυτός ο περιεκτικός οδηγός σας δείχνει πώς να χρησιμοποιήσετε PuppeteerSharp για γρήγορη, αξιόπιστη αυτοματοποίηση browser σε .NET-καλύπτοντας τα πάντα από βασικές δοκιμές έως PDF γενιά και ξύσιμο ιστού. Ενώ το Playwright της Microsoft είναι η πιο σύγχρονη λύση πολλαπλών browser, Επέλεξα PuppeteerSharp για αυτό το blog επειδή είναι ό, τι ήξερα και Chrome-μόνο δοκιμές ήταν αρκετό για τις ανάγκες μου. Αν χρειάζεστε Firefox και Safari υποστήριξη, ελέγξτε έξω μου Οδηγός θεατρικού συγγραφέα Αντί γι' αυτό.

Εισαγωγή

Αν έχεις δουλέψει ποτέ μαζί του Σελήνιο για το τέλος-to-end testing, θα ξέρετε ότι μπορεί να είναι ένας σωστός πόνος στην πίσω πλευρά. Μεταξύ της πάλης με εκδόσεις των οδηγών, που ασχολούνται με χνουδωτές δοκιμές που λειτουργούν στη μηχανή σας αλλά πουθενά αλλού, και η γενική αργοπορία του πρωτοκόλλου WebDriver, είναι αρκετό για να σας κάνει να θέλετε να τα πετάξετε όλα μέσα και να δοκιμάσετε με το χέρι αντ 'αυτού.

Εισάγετε PuppeteerSharpCity name (optional, probably does not need a translation) - το λιμάνι NET της Google's Κουκλοθέατρο Είναι σαν τον νεότερο, πιο γρήγορο ξάδερφο του Σελήνιου που μπαίνει στον κόπο να εμφανιστεί στην ώρα του και δεν απαιτεί να κατεβάσετε 17 διαφορετικούς οδηγούς περιήγησης.

Σε αυτό το άρθρο, θα σας καθοδηγήσω πώς έχω εφαρμόσει PuppeteerSharp για δοκιμές E2E σε αυτό το ίδιο το blog, πλήρης με πραγματικά παραδείγματα κώδικα από το repo. Θα καλύψει δοκιμές, PDF γενιά, ξύσιμο ιστού, και να το συγκρίνουν με τις εναλλακτικές λύσεις.

Τι είναι το "PuppeteerSharp";

PuppeteerSharpCity name (optional, probably does not need a translation) είναι μια βιβλιοθήκη .NET που παρέχει ένα υψηλό επίπεδο API για τον έλεγχο Chrome ή Chromium browsers χρησιμοποιώντας το Πρωτόκολλο Chrome DevTools- Σε αντίθεση μ' αυτό. Σελήνιο, η οποία χρησιμοποιεί το Πρωτόκολλο WebDriver (ένα μάλλον clunky HTTP-based JSON πρωτόκολλο σύρματος), PuppeteerSharp μιλά απευθείας στο πρόγραμμα περιήγησης μέσω DevTools.

Σκέψου το ως εξής:

  • Σελήνιο: Όπως η αποστολή επιστολών μέσω της θέσης για να επικοινωνήσετε με το πρόγραμμα περιήγησής σας
  • PuppeteerSharpCity name (optional, probably does not need a translation): Όπως το να έχεις απευθείας τηλεφωνική γραμμή στον εγκέφαλο του προγράμματος περιήγησης
graph LR
    A[Test Code] -->|WebDriver Protocol| B[Selenium]
    B -->|JSON Wire Protocol| C[Browser Driver]
    C -->|Commands| D[Browser]

    E[Test Code] -->|DevTools Protocol| F[PuppeteerSharp]
    F -->|Direct Connection| G[Chrome/Chromium]

    style A stroke:#333,stroke-width:2px
    style E stroke:#333,stroke-width:2px
    style F stroke:#0066cc,stroke-width:3px
    style G stroke:#0066cc,stroke-width:3px

Όπου E2E δοκιμή ταιριάζει στη στρατηγική δοκιμής σας

Πριν βουτήξουμε βαθύτερα, ας μιλήσουμε για το πού ταιριάζει η δοκιμή E2E στο μεγάλο σχέδιο των πραγμάτων. Πιθανότατα έχετε ακούσει για την πυραμίδα δοκιμής - εδώ είναι πώς λειτουργεί στην πράξη:

graph TB
    subgraph "Testing Pyramid"
        E2E[E2E Tests<br/>Few, Slow, High Confidence<br/>Test full user journeys]
        INT[Integration Tests<br/>Medium number, Medium speed<br/>Test component interactions]
        UNIT[Unit Tests<br/>Many, Fast, Low Cost<br/>Test individual functions]
    end

    subgraph "Trade-offs"
        SPEED[Speed]
        CONF[Confidence]
        COST[Cost]
    end

    subgraph "When to Use E2E"
        W1[Critical user journeys<br/>e.g. checkout, login]
        W2[Cross-browser compatibility]
        W3[JavaScript-heavy UIs]
        W4[Complex user interactions]
    end

    E2E -.->|Slow but high confidence| CONF
    INT -.->|Balanced| SPEED
    UNIT -.->|Fast and cheap| SPEED

    E2E -.->|Expensive to run| COST
    UNIT -.->|Cheap to run| COST

    style E2E stroke:#cc0000,stroke-width:3px
    style INT stroke:#ff9900,stroke-width:2px
    style UNIT stroke:#00aa00,stroke-width:2px
    style CONF stroke:#0066cc,stroke-width:2px
    style SPEED stroke:#00aa00,stroke-width:2px
    style COST stroke:#cc0000,stroke-width:2px

Ο Έλεγχος Πραγματικότητας:

  • Δοκιμές μονάδας (80% των δοκιμών σας): Γρήγορη, φθηνή, δοκιμή μεμονωμένες λειτουργίες. Αλλά δεν σας λένε αν το σύστημα λειτουργεί πραγματικά ως σύνολο.
  • Δοκιμές ενσωμάτωσης (15% των δοκιμών σας): Δοκιμάστε πώς διαφορετικά μέρη λειτουργούν μαζί. Πιο γρήγορα από E2E αλλά μην δοκιμάσετε το πλήρες UI.
  • Δοκιμές E2E (5% των δοκιμών σας): Αργά, ακριβά, αλλά δοκιμάστε το σύστημα ακριβώς όπως οι χρήστες το βιώνουν.

Όταν χρειάζεστε εξετάσεις E2E:

  1. Κρίσιμα ταξίδια χρήστη - Συνδεθείτε, checkout, επεξεργασία πληρωμών.
  2. JavaScript-heavy UIs - Σύγχρονη ΖΕΠ (Αντίδραση, ΒιούεCity name (optional, probably does not need a translation), Γωνιακός) όπου η UI έχει καταστεί client-side.
  3. Θέματα cross-browser - Διαφορετικοί browser αποδίδουν τα πράγματα διαφορετικά (αν και με PuppeteerSharp είστε Chrome-μόνο).
  4. Σύνθετες αλληλεπιδράσεις - Μάγοι πολλών βημάτων, drag-and-drop, file uploads.

Όταν δεν χρειάζεστε εξετάσεις E2E:

  1. Απλές λειτουργίες CRUD - Τα τεστ ενσωμάτωσης είναι αρκετά.
  2. Καθαρή λογική - Γι' αυτό υπάρχουν οι δοκιμές.
  3. Κάθε αιχμηρή θήκη - Οι δοκιμές E2E είναι πολύ αργές και δαπανηρές για εξαντλητικές δοκιμές.

Γιατί PuppeteerSharp Over Selenium;

Επιτρέψτε μου να μετρήσω τους τρόπους:

  1. No Driver Management Faff: PuppeteerSharp downloads και διαχειρίζεται το πρόγραμμα περιήγησης Chrome για εσάς. Όχι πια mucking σχετικά με τις εκδόσεις ChromeDriver που δεν ταιριάζουν με την εγκατεστημένη έκδοση Chrome σας.

  2. Γρήγορη Εκτέλεση: Το Πρωτόκολλο DevTools είναι σημαντικά γρηγορότερο από το WebDriver. Οι δοκιμές σας θα τρέξουν γρηγορότερα, και θα περάσετε λιγότερο χρόνο περιμένοντας τα πράγματα να συμβούν.

  3. Καλύτερη API: Το API είναι πιο μοντέρνο και διαισθητικό. Είναι async/wait σε όλη τη διαδρομή προς τα κάτω, το οποίο ταιριάζει όμορφα με τη σύγχρονη ανάπτυξη .NET.

  4. Ενσωματωμένο στιγμιότυπο οθόνης & PDF Generation: Θέλετε ένα στιγμιότυπο οθόνης όταν ένα τεστ αποτύχει; Είναι εντελώς απλό με PuppeteerSharp.

  5. Αιτήματα δικτύου αναχαίτισης: Μπορείτε να αναχαιτίσετε, να τροποποιήσετε ή να μπλοκάρετε τις αιτήσεις δικτύου με ευκολία - λαμπρό για τη δοκιμή σεναρίων εκτός σύνδεσης ή την κοροϊδία των απαντήσεων API.

  6. Κατάλληλη εκτέλεση JavaScript: Εκτέλεση JavaScript στο πλαίσιο της σελίδας και να πάρει τα αποτελέσματα πίσω με έναν τρόπο που δεν σας κάνει να θέλετε να κλάψετε.

Ρύθμιση PuppeteerSharp

Πρώτον, προσθέστε το PuppeteerSharpCity name (optional, probably does not need a translation) NuGet πακέτο:

dotnet add package PuppeteerSharp

Ορίστε η διαμόρφωση του δοκιμαστικού έργου μου (Mostlylucid.Test/Mostlylucid.Test.csproj:23):

<PackageReference Include="PuppeteerSharp" Version="20.2.4" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

Χρησιμοποιώ. xUnit (ASP.NET Core's default), αλλά PuppeteerSharp λειτουργεί εξίσου καλά με NUnitCity name (optional, probably does not need a translation) ή MSTestCity name (optional, probably does not need a translation).

Δημιουργία μιας βασικής κατηγορίας δοκιμών

Αντί να επαναλαμβάνω κώδικα ρύθμισης/επιβράβευσης σε κάθε δοκιμή, δημιούργησα μια βασική τάξη (Mostlylucid.Test/E2E/E2ETestBase.cs:12) που χειρίζεται τη διαχείριση του κύκλου ζωής του προγράμματος περιήγησης:

Η δομή της κλάσης

using PuppeteerSharp;
using Xunit.Abstractions;

namespace Mostlylucid.Test.E2E;

public abstract class E2ETestBase : IAsyncLifetime
{
    protected readonly ITestOutputHelper Output;
    protected IBrowser Browser = null!;
    protected IPage Page = null!;

    protected const string BaseUrl = "http://localhost:8080";
    protected const int DefaultTimeout = 30000;

    protected E2ETestBase(ITestOutputHelper output)
    {
        Output = output;
    }

Εμείς εφαρμόζουμε IAsyncLifetime σε αντίθεση με τους παραδοσιακούς κατασκευαστές, αυτό μας επιτρέπει να περιμένουμε σωστά την αρχικοποίηση του προγράμματος περιήγησης.

Αρχικοποίηση προγράμματος περιήγησης

    public async Task InitializeAsync()
    {
        // Download Chromium on first run
        var browserFetcher = new BrowserFetcher();
        await browserFetcher.DownloadAsync();

        // Launch browser with sensible defaults
        Browser = await Puppeteer.LaunchAsync(new LaunchOptions
        {
            Headless = true, // Set false for debugging
            DefaultViewport = new ViewPortOptions
            {
                Width = 1400,
                Height = 900
            },
            Args = new[]
            {
                "--no-sandbox",
                "--disable-setuid-sandbox"
            }
        });

        Page = await Browser.NewPageAsync();
        Page.DefaultTimeout = DefaultTimeout;
    }

Η BrowserFetcher αυτόματα μεταφορτώνει μια συμβατή έκδοση Chromium κατά την πρώτη εκτέλεση - δεν χρειάζεται χειροκίνητη διαχείριση οδηγού. --no-sandbox Για τα περιβάλλοντα Docker/CI απαιτούνται σημαίες.

Καθαρισμός

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

Η σωστή διάθεση είναι κρίσιμη για την αποφυγή διαρροών μνήμης. Κάθε παράδειγμα προγράμματος περιήγησης χρησιμοποιεί 100-200MB RAM.

Μέθοδοι Βοηθού

Η βασική τάξη περιλαμβάνει μεθόδους βοηθού για τη μείωση των λεβήτων (Mostlylucid.Test/E2E/E2ETestBase.cs:72-172):

// Navigation with automatic network idle waiting
protected async Task NavigateAsync(string path)
{
    var url = path.StartsWith("http") ? path : $"{BaseUrl}{path}";
    await Page.GoToAsync(url, new NavigationOptions
    {
        WaitUntil = new[] { WaitUntilNavigation.Networkidle2 }
    });
}

// Safe element waiting with timeout handling
protected async Task<IElementHandle?> WaitForSelectorAsync(string selector, int timeout = 5000)
{
    try
    {
        return await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions
        {
            Timeout = timeout,
            Visible = true
        });
    }
    catch (WaitTaskTimeoutException)
    {
        return null; // Graceful degradation
    }
}

// Common element operations
protected async Task<bool> ElementExistsAsync(string selector) =>
    await Page.QuerySelectorAsync(selector) != null;

protected async Task<string?> GetTextContentAsync(string selector)
{
    var element = await Page.QuerySelectorAsync(selector);
    return element == null ? null :
        await Page.EvaluateFunctionAsync<string>("el => el.textContent", element);
}

protected async Task TypeAsync(string selector, string text, int delay = 50)
{
    await Page.WaitForSelectorAsync(selector);
    await Page.TypeAsync(selector, text, new TypeOptions { Delay = delay });
}

protected async Task ClickAsync(string selector)
{
    await Page.WaitForSelectorAsync(selector);
    await Page.ClickAsync(selector);
}

Αυτά χειρίζονται τα κουραστικά κομμάτια - περιμένοντας στοιχεία να υπάρχουν, χαριτωμένος χειρισμός του χρόνου, και αυτόματη καταγραφή για όταν οι δοκιμές αποτυγχάνουν στο CI.

Γράφοντας Πραγματικές Δοκιμασίες

Σωστά, ας πάμε στο καλό πράγμα - γράφοντας πραγματικές δοκιμές. Εδώ είναι μια πραγματική δοκιμή από τη λειτουργία μπαρ φίλτρου του 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");
}

Αυτή η δοκιμή ελέγχει ότι η πτώση της γλώσσας μου λειτουργεί σωστά.

Η Παράκαμψη

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

Για τις δοκιμές E2E, συνήθως θέλετε να τρέξει τους κατά παραγγελία και όχι με κάθε κατασκευή. Μπορείτε να τους ξεγλιστρήσει όταν είστε έτοιμοι να τα τρέξει, ή να τρέξει σε μια ξεχωριστή δουλειά CI όπου έχετε το site περιστρέφεται επάνω.

Εκτέλεση 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';
}");

Αυτή είναι μια από τις περιοχές όπου PuppeteerSharp απολύτως λάμπει. EvaluateFunctionAsync μέθοδος σας επιτρέπει να τρέξετε JavaScript στο πλαίσιο του προγράμματος περιήγησης και να πάρετε το αποτέλεσμα ως κατάλληλο .NET τύπο. Σε αυτή την περίπτωση, ελέγχω αν μια πτώση είναι πραγματικά ορατή (δεν είναι μόνο παρούσα στο DOM) εξετάζοντας τα υπολογιστικά στυλ του.

Συγκρίνετε αυτό με Σελήνιο όπου θα πρέπει να:

  1. Βρείτε το στοιχείο
  2. Αποκτήστε το οικόπεδο της οθόνης του
  3. Ανάλυση του αποτελέσματος συμβολοσειράς
  4. Ελπίζω να μην είναι μπαγιάτικο μέχρι να το ελέγξεις.

Δοκιμή αλληλεπιδράσεων HTMX

Το blog μου χρησιμοποιεί HTMX Εδώ είναι μια δοκιμή που ελέγχει τη λειτουργικότητα διαλογής (Mostlylucid.Test/E2E/FilterBarTests.cs:98-126):

[Fact(Skip = "Local E2E test - requires site to be running on localhost:8080")]
public async Task FilterBar_SortOrder_ChangesPostOrder()
{
    // Arrange
    await NavigateAsync("/blog");

    // Get the first post title before sorting
    var firstPostBefore = await EvaluateFunctionAsync<string>(@"() => {
        const postLink = document.querySelector('.post-title, article h2 a, #contentcontainer article a');
        return postLink?.textContent?.trim() || '';
    }");
    Output.WriteLine($"First post before sort: {firstPostBefore}");

    // Act - Change sort order to "Oldest first"
    await Page.SelectAsync("#orderSelect", "date_asc");
    await WaitAsync(1000); // Wait for HTMX to update

    // Assert - Post order should have changed
    var firstPostAfter = await EvaluateFunctionAsync<string>(@"() => {
        const postLink = document.querySelector('.post-title, article h2 a, #contentcontainer article a');
        return postLink?.textContent?.trim() || '';
    }");
    Output.WriteLine($"First post after sort: {firstPostAfter}");

    var selectValue = await EvaluateFunctionAsync<string>("() => document.querySelector('#orderSelect')?.value");
    Assert.Equal("date_asc", selectValue);
    Output.WriteLine("✅ Sort order selection works correctly");
}

Το κλειδί εδώ είναι το await WaitAsync(1000) Μετά την αλλαγή της τιμής επιλογής. HTMX χρειάζεται μια στιγμή για να κάνει το αίτημά της και να ενημερώσει το DOM. Σε έναν τέλειο κόσμο, θα περιμένουμε ένα συγκεκριμένο αίτημα δικτύου για να ολοκληρωθεί, αλλά για απλές περιπτώσεις, μια σύντομη καθυστέρηση είναι μια χαρά.

Δοκιμή Responsive Design

Εδώ είναι ένα αναιδές τεστ που ελέγχει τη γραμμή φίλτρου μου είναι σωστά κρυμμένο σε κινητές συσκευές (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
    });
}

Μπορείτε να αλλάξετε το viewport ανά πάσα στιγμή, το οποίο είναι λαμπρό για τη δοκιμή ανταποκρίνεται διατάξεις. Πολύ πιο εύκολο από την αλλαγή μεγέθους παράθυρο του προγράμματος περιήγησης σας με το χέρι!

Advanced PuppeteerSharp Χαρακτηριστικά

Διασύνδεση δικτύου

Ένα από τα αγαπημένα μου χαρακτηριστικά είναι η ικανότητα να αναχαιτίζω και να τροποποιώ αιτήματα δικτύου.

await Page.SetRequestInterceptionAsync(true);

Page.Request += async (sender, e) =>
{
    // Block all image requests to speed up tests
    if (e.Request.ResourceType == ResourceType.Image)
    {
        await e.Request.AbortAsync();
    }
    // Mock API responses
    else if (e.Request.Url.Contains("/api/posts"))
    {
        await e.Request.RespondAsync(new ResponseData
        {
            Status = HttpStatusCode.OK,
            ContentType = "application/json",
            Body = "{\"posts\": []}"
        });
    }
    else
    {
        await e.Request.ContinueAsync();
    }
};

Λήψη στιγμιότυπων οθόνης

Όταν μια δοκιμή αποτυγχάνει, ένα στιγμιότυπο οθόνης αξίζει χίλια μηνύματα καταγραφής:

try
{
    // Your test code here
    await Page.ClickAsync("#someButton");
}
catch (Exception)
{
    // Take a screenshot on failure
    await Page.ScreenshotAsync("test-failure.png");
    throw; // Re-throw to fail the test
}

Παραγωγή PDF

Μπορείτε ακόμη και να δημιουργήσετε PDF των σελίδων, η οποία είναι χρήσιμη για τη δοκιμή server-side απόδοση ή εκτύπωση φύλλων στυλ:

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

Κάλυψη κωδικού

Το PuppeteerSharp μπορεί ακόμη και να συλλέξει δεδομένα κάλυψης κώδικα 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 vs The Competition

Ας ρίξουμε μια σωστή ματιά στο πώς PuppeteerSharp στοίβες επάνω σε άλλα 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

Σελήνιο WebDriver

Η Παλαιά Φρουρά

Είναι ώριμο, καλά τεκμηριωμένο και υποστηρίζει κάθε πρόγραμμα περιήγησης κάτω από τον ήλιο. Αλλά δείχνει επίσης την ηλικία του:

Pros:

  • Υποστηρίζει όλα τα προγράμματα περιήγησης (Chrome, Firefox, Safari, Edge, IE αν είστε μαζοχιστής)
  • Μαζικό οικοσύστημα εργαλείων και επεκτάσεων
  • Γνωστό και ευρέως υιοθετημένο
  • Καλό για τη δοκιμή cross-browser

Κατά:

  • Το πρωτόκολλο WebDriver είναι αργό
  • Διαχείριση οδηγού είναι ένας πόνος (αν και WebDriverManager βοηθά)
  • API αισθάνεται χρονολογείται σε σύγκριση με τις σύγχρονες εναλλακτικές λύσεις
  • Οι δοκιμές Flaky είναι συχνές λόγω προβλημάτων χρονισμού
  • Καμία ενσωματωμένη παρακολούθηση δικτύου

Πότε να το χρησιμοποιήσετε: Όταν πρέπει οπωσδήποτε να δοκιμάσετε σε πολλούς browsers, ή όταν έχετε ήδη επενδύσει στο οικοσύστημα Selenium.

Playwright

Το Νέο Παιδί στο Μπλοκ

Playwright είναι η απάντηση της Microsoft στο Puppeteer, με . NET υποστήριξη Είναι ουσιαστικά PuppeteerSharp αλλά με υποστήριξη multi-browser:

Pros:

  • Υποστηρίζει Chrome, Firefox, Safari (WebKit)
  • Σύγχρονη API παρόμοια με Puppeteer
  • Auto-downloads browsers
  • Ενσωματωμένη παρακολούθηση δικτύου, στιγμιότυπα οθόνης κ.λπ.
  • Εξαιρετική υποστήριξη .NET

Κατά:

  • Καινούργιο, τόσο μικρότερο οικοσύστημα
  • Μπορεί να είναι υπερβολή αν χρειάζεσαι μόνο Chrome
  • Λίγο πιο περίπλοκη ρύθμιση λόγω της υποστήριξης πολλαπλών browser

Πότε να το χρησιμοποιήσετε: Όταν χρειάζεστε υποστήριξη multi-browser αλλά θέλετε ένα σύγχρονο API. Αν ξεκινάτε ένα νέο έργο και χρειάζεστε δοκιμή cross-browser, Playwright είναι πιθανώς το καλύτερο στοίχημά σας.

ΚυπαρίσσιCity name (optional, probably does not need a translation)

Η αγάπη του προγραμματιστή JavaScript

Cypress είναι λαμπρή αν εργάζεστε σε JavaScript / TypeScript, αλλά είναι ένα μη starter για .NET προγραμματιστές:

Pros:

  • Φανταστική εμπειρία προγραμματιστή
  • Αποσφαλμάτωση χρονοταξιδιών
  • Αυτόματη αναμονή
  • Σπουδαία τεκμηρίωση

Κατά:

  • JavaScript/TypeScript μόνο
  • Δεν υπάρχει υποστήριξη NET
  • Δεν μπορούν να δοκιμάσουν πολλαπλές καρτέλες ή παράθυρα
  • Περιορισμένη για να δοκιμάσετε τη δική σας εφαρμογή (δεν δοκιμή σε τομείς)

Πότε να το χρησιμοποιήσετε: Μη, γράφεις κώδικα .NET... μείνε σε κάτι που ενσωματώνει με την τεχνική σου στοίβα.

Τι Πρέπει να Χρησιμοποιείτε;

Ορίστε η γνώμη μου:

graph TD
    A[What E2E tool?] --> B{Need multi-browser testing?}
    B -->|Yes| C{Starting new project?}
    B -->|No| D[PuppeteerSharp]

    C -->|Yes| E[Playwright]
    C -->|No| F{Invested in Selenium?}

    F -->|Yes| G[Stick with Selenium]
    F -->|No| E

    D --> H[✅ Fast, simple, reliable]
    E --> I[✅ Modern, flexible]
    G --> J[⚠️ Consider migrating]

    style D stroke:#0066cc,stroke-width:3px
    style H stroke:#00aa00,stroke-width:2px

Για τους περισσότερους προγραμματιστές NET που χτίζουν σύγχρονες εφαρμογές ιστοσελίδων:

  • Δοκιμασία μόνο για Chrome; → PuppeteerSharp
  • Δοκιμή πολλαπλών περιηγητών; → Playwright
  • Χρησιμοποίησε ήδη Σελήνιο; → Σκεφτείτε τη μετανάστευση στο Playwright, αλλά μην βιαστείτε

Διεξαγωγή δοκιμών σε CI/CD

E2E δοκιμές είναι όλα καλά και καλά για την τοπική μηχανή σας, αλλά πρέπει να τρέξει σε CI / CD αγωγούς πάρα πολύ. Ενέργειες GitHub:

name: E2E Tests

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  e2e-tests:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3

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

    - name: Install dependencies
      run: dotnet restore

    - name: Build
      run: dotnet build --no-restore

    - name: Start application
      run: |
        dotnet run --project Mostlylucid/Mostlylucid.csproj &
        echo $! > app.pid

    - name: Wait for application to start
      run: |
        timeout 60 bash -c 'until curl -f http://localhost:8080/health; do sleep 2; done'

    - name: Run E2E tests
      run: |
        dotnet test Mostlylucid.Test/Mostlylucid.Test.csproj \
          --filter "Category=E2E" \
          --logger "console;verbosity=detailed"

    - name: Upload screenshots on failure
      if: failure()
      uses: actions/upload-artifact@v3
      with:
        name: test-screenshots
        path: '**/test-failure-*.png'

    - name: Stop application
      if: always()
      run: |
        kill $(cat app.pid) || true

Τα βασικά κομμάτια:

  1. Έναρξη της εφαρμογής στο παρασκήνιο
  2. Περιμένετε να είναι υγιής (χρησιμοποιώντας ένα τελικό σημείο ελέγχου υγείας)
  3. Εκτέλεση των δοκιμών E2E
  4. Ανεβάστε στιγμιότυπα οθόνης εάν οποιαδήποτε δοκιμή αποτύχει
  5. Πάντα να σταματάτε την εφαρμογή, ακόμα και αν οι δοκιμές αποτυγχάνουν

Κοινές Πτυχές και Πώς να τις Αποφεύγετε

Δοκιμασίες FlakyName

Οι δοκιμές E2E μπορεί να είναι αδύναμες - περνούν μερικές φορές και αποτυγχάνουν σε άλλους.

Κακό:

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

Ωραία:

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

Πάντα να περιμένεις το στοιχείο με το οποίο θα αλληλεπιδράσεις για να υπάρχεις και να είσαι ορατός.

Απομόνωση δοκιμής

Κάθε δοκιμή πρέπει να είναι εντελώς ανεξάρτητη. Μην βασίζεστε σε κατάσταση από προηγούμενες δοκιμές:

Κακό:

[Fact]
public async Task Test1_Login()
{
    await LoginAsync("user", "password");
    // User is now logged in for subsequent tests
}

[Fact]
public async Task Test2_ViewDashboard()
{
    // Assumes user is still logged in from Test1
    await NavigateAsync("/dashboard");
}

Ωραία:

[Fact]
public async Task Test1_Login()
{
    await LoginAsync("user", "password");
    await LogoutAsync(); // Clean up
}

[Fact]
public async Task Test2_ViewDashboard()
{
    await LoginAsync("user", "password"); // Set up needed state
    await NavigateAsync("/dashboard");
    await LogoutAsync(); // Clean up
}

Τύπος αντικειμένου σελίδας

Για περίπλοκες σελίδες, χρησιμοποιήστε το μοτίβο Page Object για να διατηρήσετε τις δοκιμές σας διατηρήσιμες:

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 είναι πιο αργές από τις δοκιμές μονάδας, δεν μπορείς να το παρακάμψεις, αλλά μπορείς να τις κάνεις πιο γρήγορα:

Εκτελέστε τις δοκιμές στο παράλληλο

xUnit τρέχει δοκιμές παράλληλα από προεπιλογή, αλλά θα πρέπει να είστε προσεκτικοί σχετικά με την κοινή κατάσταση:

[Collection("E2E Tests")] // Tests in same collection run sequentially
public class FilterBarTests : E2ETestBase
{
    // Tests here share resources
}

[Collection("Blog Tests")] // Different collection runs in parallel
public class BlogTests : E2ETestBase
{
    // Tests here run in parallel with FilterBarTests
}

Απενεργοποίηση περιττών χαρακτηριστικών

Επιταχύνετε τις δοκιμές με την απενεργοποίηση χαρακτηριστικών που δεν χρειάζεστε:

Browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
    Headless = true,
    Args = new[]
    {
        "--no-sandbox",
        "--disable-setuid-sandbox",
        "--disable-dev-shm-usage", // Overcome limited resource problems
        "--disable-accelerated-2d-canvas",
        "--disable-gpu", // Not needed for headless
        "--disable-images", // Don't load images if you don't need them
        "--disable-javascript", // Only if testing static content
    }
});

Χρήση Υποδοχής Δικτύων Σοφά

Αποκλεισμός περιττών πόρων για την επιτάχυνση των πραγμάτων:

await Page.SetRequestInterceptionAsync(true);
Page.Request += async (sender, e) =>
{
    var blockedResourceTypes = new[]
    {
        ResourceType.Image,
        ResourceType.Media,
        ResourceType.Font,
        ResourceType.StyleSheet // If you don't need to test styling
    };

    if (blockedResourceTypes.Contains(e.Request.ResourceType))
    {
        await e.Request.AbortAsync();
    }
    else
    {
        await e.Request.ContinueAsync();
    }
};

Αποσφαλμάτωση δοκιμών E2E

Όταν οι δοκιμές αποτυγχάνουν (και θα το κάνουν), πρέπει να τις αποσφαλματώσετε.

Εκτέλεση σε μη Headless Mode

Σετ Headless = false για να παρακολουθήσετε το πρόγραμμα περιήγησης σε δράση:

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

Χρήση DevTools

Μπορείτε πραγματικά να ανοίξετε DevTools προγραμματισμένα:

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

Καταγραφή κονσόλας

Συλλάβετε μηνύματα κονσόλας από το πρόγραμμα περιήγησης:

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

Αίτημα καταγραφής

Καταγραφή όλων των αιτήσεων δικτύου:

Page.Request += (sender, e) =>
{
    Output.WriteLine($"Request: {e.Request.Method} {e.Request.Url}");
};

Page.Response += (sender, e) =>
{
    Output.WriteLine($"Response: {e.Response.Status} {e.Response.Url}");
};

Ρεάλ-Παγκόσμια πρότυπα δοκιμών

Εδώ είναι μερικά πρότυπα που χρησιμοποιώ τακτικά στις εξετάσεις E2E μου:

Υποβολές εντύπων δοκιμής

[Fact]
public async Task Can_Submit_Comment()
{
    await NavigateAsync("/blog/some-post");

    // Fill in the comment form
    await TypeAsync("#comment-name", "Test User");
    await TypeAsync("#comment-email", "[email protected]");
    await TypeAsync("#comment-content", "This is a test comment");

    // Submit the form
    await ClickAsync("#comment-submit");

    // Wait for success message
    await WaitForSelectorAsync(".comment-success");

    // Verify the comment appears
    var commentText = await GetTextContentAsync(".comment-list .comment:last-child .comment-content");
    Assert.Contains("test comment", commentText.ToLower());
}

Δοκιμή αλληλεπιδράσεων πληκτρολογίου

[Fact]
public async Task Can_Navigate_With_Keyboard()
{
    await NavigateAsync("/blog");

    // Focus the search box
    await Page.FocusAsync("#search");

    // Type a search query
    await Page.Keyboard.TypeAsync("testing");

    // Press arrow down to select first result
    await Page.Keyboard.PressAsync("ArrowDown");

    // Press enter to navigate
    await Page.Keyboard.PressAsync("Enter");

    // Verify we navigated to the right page
    await WaitAsync(1000);
    Assert.Contains("/blog/", Page.Url);
}

Δοκιμή αποστολής αρχείων@ info: whatsthis

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

Ενσωμάτωση με τις δοκιμές πυρήνα ASP.NET

Μπορείτε να ενσωματώσετε PuppeteerSharp με ASP.NET Core's Web ApplicationFactory για μια πιο ολοκληρωμένη εμπειρία δοκιμών:

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

Πέρα από τις δοκιμές - PuppeteerSharp για PDF Γενιά και Αυτοματισμού

Ενώ η δοκιμή E2E είναι λαμπρή, PuppeteerSharp είναι ένα ελβετικό μαχαίρι Στρατού που μπορεί να κάνει πολύ περισσότερα. Μια από τις πιο δημοφιλείς χρήσεις του είναι η παραγωγή PDF από το περιεχόμενο του ιστού - είναι εξαιρετικά χρήσιμο για αυτό, αν και όχι χωρίς gatchas. Αν είστε οικοδόμηση τιμολόγια, αναφορές, ή οποιοδήποτε σύστημα παραγωγής εγγράφων, αυτό το τμήμα θα σας σώσει ώρες αποσφαλμάτωσης.

Δημιουργία PDF - Η Υπόσχεση και ο Πόνος

Η ιδέα είναι απλή: να μετατρέψετε μια ιστοσελίδα στο Chrome και να την αποθηκεύσετε ως PDF. Ιδανικό για τη δημιουργία τιμολογίων, εκθέσεων, πιστοποιητικών ή οποιουδήποτε δυναμικού περιεχομένου που πρέπει να διανεμηθεί σε μορφή PDF.

Εδώ είναι η βασική προσέγγιση:

public class PdfGeneratorService
{
    public async Task<byte[]> GeneratePdfFromUrlAsync(string url)
    {
        await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions
        {
            Headless = true,
            Args = new[] { "--no-sandbox", "--disable-setuid-sandbox" }
        });

        await using var page = await browser.NewPageAsync();
        await page.GoToAsync(url, new NavigationOptions
        {
            WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
        });

        var pdfData = await page.PdfDataAsync(new PdfOptions
        {
            Format = PaperFormat.A4,
            PrintBackground = true,
            MarginOptions = new MarginOptions
            {
                Top = "20mm",
                Right = "20mm",
                Bottom = "20mm",
                Left = "20mm"
            }
        });

        return pdfData;
    }
}

Λοιπόν, είναι... μέχρι να μην είναι. 'σε με να σε πάω μέσα από τις νάρκες.

PDF Generation Gotchas - Τι Κανείς δεν σας λέει

1. Εφιάλτες προσθήκης γραμματοσειράς

Το πρόβλημα: Οι όμορφες γραφικές γραμματοσειρές σας δεν εμφανίζονται στο PDF, ή χειρότερα, είναι εκεί, αλλά φαίνονται απολύτως σκουπίδια.

Γιατί Συμβαίνει: Το Chrome χρειάζεται πρόσβαση στα αρχεία γραμματοσειρών κατά τη διάρκεια της γενιάς PDF. Αν οι γραμματοσειρές σας φορτώνονται μέσω εξωτερικού CDN και το Chrome δεν μπορεί να τα φτάσει (τοίχος φωτιάς, θέματα δικτύου, συγχρονισμός), είστε γεμάτοι.

Η Λύση:

await page.GoToAsync(url, new NavigationOptions
{
    WaitUntil = new[]
    {
        WaitUntilNavigation.Networkidle0,  // Wait for network to be idle
        WaitUntilNavigation.Load           // Wait for fonts to load
    },
    Timeout = 60000  // Give it time to load fonts
});

// Extra insurance - wait for fonts to actually load
await page.EvaluateFunctionAsync(@"async () => {
    await document.fonts.ready;
}");

var pdfData = await page.PdfDataAsync(new PdfOptions
{
    Format = PaperFormat.A4,
    PrintBackground = true  // CRUCIAL for @font-face fonts
});

Ακόμα καλύτερα, να φιλοξενήσετε τις γραμματοσειρές σας τοπικά ή να τις ενσωματώσετε ως βάση64 στο CSS σας.

2. CSS Εκτύπωση ερωτημάτων πολυμέσων

Το πρόβλημα: Το PDF σας δεν μοιάζει καθόλου με την ιστοσελίδα σας επειδή ο Chrome εφαρμόζει ερωτήσεις πολυμέσων εκτύπωσης.

Αυτό είναι στην πραγματικότητα σωστή συμπεριφορά Τα PDF είναι μέσα εκτύπωσης, αλλά τους πιάνει όλους την πρώτη φορά.

Η Λύση:

Χρήση @media print Κανόνες CSS κατάλληλα:

/* Show on screen, hide in PDF */
.no-print {
    display: block;
}

@media print {
    .no-print {
        display: none !important;
    }

    /* Prevent page breaks inside elements */
    .keep-together {
        page-break-inside: avoid;
        break-inside: avoid;
    }

    /* Force page breaks */
    .page-break {
        page-break-before: always;
    }
}

Ή, αν θέλετε την έκδοση οθόνης στο PDF σας (χρήσιμο για τη δημιουργία "screenshots" ως PDF):

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

3. Διαλείμματα σελίδας - Η Έκρηξη της Ύπαρξής Σας

Το πρόβλημα: Το περιεχόμενό σας χωρίζεται άβολα σε σελίδες, με τίτλους ορφανά στο κάτω μέρος ή τα τραπέζια κομμένα στη μέση.

Η Πραγματικότητα: Πολεμάς ενάντια στον εσωτερικό αλγόριθμο επιγραφής του Chrome, και τον περισσότερο καιρό θα κερδίζει.

Τι μπορείτε να κάνετε:

@media print {
    h1, h2, h3, h4, h5, h6 {
        page-break-after: avoid;
        break-after: avoid;
    }

    table, figure, img {
        page-break-inside: avoid;
        break-inside: avoid;
    }

    /* Force specific breaks */
    .new-page {
        page-break-before: always;
    }
}

Και στον κωδικό σας PuppeteerSharp:

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

Pro Tip: Για περίπλοκες διατάξεις, μερικές φορές είναι πιο εύκολο να δομήσει HTML σας με σαφή διαλείμματα σελίδας αντί για την καταπολέμηση του προγράμματος περιήγησης:

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

4. Headers και Footers - Περισσότερο σύνθετο από ό, τι θα σκεφτόσασταν

Μπορείτε να προσθέσετε κεφαλίδες και υποσέλιδα, αλλά το API είναι λίγο θορυβώδες:

var pdfData = await page.PdfDataAsync(new PdfOptions
{
    Format = PaperFormat.A4,
    DisplayHeaderFooter = true,
    HeaderTemplate = @"
        <div style='font-size: 10px; text-align: center; width: 100%;'>
            <span class='title'></span>
        </div>
    ",
    FooterTemplate = @"
        <div style='font-size: 10px; text-align: center; width: 100%;'>
            Page <span class='pageNumber'></span> of <span class='totalPages'></span>
        </div>
    ",
    MarginOptions = new MarginOptions
    {
        Top = "30mm",     // Must be larger to accommodate header
        Bottom = "25mm"   // Must be larger to accommodate footer
    }
});

Σ' έπιασα:

  • Τα πρότυπα κεφαλίδας/πόδι πρέπει να είναι έγκυρα HTML αλλά είναι εξαιρετικά περιορισμένα - καμία εξωτερική CSS, καμία JavaScript
  • Μπορείτε να πάρετε μόνο συγκεκριμένες μεταβλητές: date, title, url, pageNumber, totalPages
  • Το στυλ είναι μόνο σε απευθείας σύνδεση.
  • Τα περιθώρια πρέπει να είναι αρκετά μεγάλα για να φιλοξενήσουν κεφαλίδες / πόδια ή θα επικαλύπτουν το περιεχόμενό σας

5. Γραφικά φόντου

Από προεπιλογή, το Chrome δεν εκτυπώνει εικόνες φόντου ή χρώματα (αυτό είναι ένα πρόγραμμα περιήγησης προεπιλογή για την αποθήκευση μελανιού). πρέπει Του επιτρέπει:

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

6. Διαρροές μνήμης με μεγάλα έγγραφα

Το πρόβλημα: Η δημιουργία πολλών PDF προκαλεί τη μνήμη της εφαρμογής σας σε μπαλόνι και τελικά συντριβή.

Γιατί: Κάθε παράδειγμα προγράμματος περιήγησης χρησιμοποιεί σημαντική μνήμη (100-200MB), και αν δεν ξεφορτώνεστε σωστά, συσσωρεύονται.

Η Λύση:

Πάντοτε να χρησιμοποιείτε await using ή κατάλληλη απόρριψη:

// Good - automatic disposal
await using var browser = await Puppeteer.LaunchAsync(options);
await using var page = await browser.NewPageAsync();

// Or manually
IBrowser? browser = null;
try
{
    browser = await Puppeteer.LaunchAsync(options);
    // ... use browser
}
finally
{
    if (browser != null)
    {
        await browser.CloseAsync();
        await browser.DisposeAsync();
    }
}

Για την παραγωγή PDF μεγάλου όγκου, εξετάστε τις περιπτώσεις επαναχρησιμοποίησης του προγράμματος περιήγησης:

public class PdfGeneratorService : IDisposable
{
    private IBrowser? _browser;
    private readonly SemaphoreSlim _semaphore = new(1, 1);

    public async Task<byte[]> GeneratePdfAsync(string url)
    {
        await _semaphore.WaitAsync();
        try
        {
            // Reuse browser instance
            _browser ??= await Puppeteer.LaunchAsync(new LaunchOptions
            {
                Headless = true
            });

            await using var page = await _browser.NewPageAsync();
            await page.GoToAsync(url);
            return await page.PdfDataAsync();
        }
        finally
        {
            _semaphore.Release();
        }
    }

    public async ValueTask DisposeAsync()
    {
        if (_browser != null)
        {
            await _browser.CloseAsync();
            await _browser.DisposeAsync();
        }
        _semaphore.Dispose();
    }

    public void Dispose()
    {
        DisposeAsync().AsTask().Wait();
    }
}

7. Η επιλογή κλίμακας - μικρότερο κείμενο, περισσότερο περιεχόμενο

Μερικές φορές θα πρέπει να χωρέσει περισσότερο περιεχόμενο σε μια σελίδα:

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

Αλλά να είσαι προσεκτικός - πολύ μικρός και είναι δυσανάγνωστος.

Ρεάλ-Παγκόσμιο πρότυπο παραγωγής PDF

Ακούστε πώς κάνω την παραγωγή PDF στην παραγωγή:

public class InvoicePdfGenerator
{
    private readonly ILogger<InvoicePdfGenerator> _logger;

    public InvoicePdfGenerator(ILogger<InvoicePdfGenerator> logger)
    {
        _logger = logger;
    }

    public async Task<byte[]> GenerateInvoicePdfAsync(Invoice invoice)
    {
        await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions
        {
            Headless = true,
            Args = new[]
            {
                "--no-sandbox",
                "--disable-setuid-sandbox",
                "--disable-dev-shm-usage"  // Overcome limited resource problems
            }
        });

        await using var page = await browser.NewPageAsync();

        // Set up console logging to debug issues
        page.Console += (_, e) =>
        {
            _logger.LogInformation("Browser console: {Message}", e.Message.Text);
        };

        try
        {
            // Generate HTML content (using Razor, or however you do it)
            var htmlContent = await GenerateInvoiceHtmlAsync(invoice);

            // Set content directly rather than navigating to URL
            await page.SetContentAsync(htmlContent, new NavigationOptions
            {
                WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
            });

            // Wait for fonts to load
            await page.EvaluateFunctionAsync("() => document.fonts.ready");

            // Force screen media type to avoid print media queries changing layout
            await page.EmulateMediaTypeAsync(MediaType.Screen);

            // Generate PDF
            var pdfData = await page.PdfDataAsync(new PdfOptions
            {
                Format = PaperFormat.A4,
                PrintBackground = true,
                MarginOptions = new MarginOptions
                {
                    Top = "10mm",
                    Right = "10mm",
                    Bottom = "10mm",
                    Left = "10mm"
                },
                PreferCSSPageSize = false
            });

            _logger.LogInformation("Generated PDF for invoice {InvoiceId}, size: {Size} bytes",
                invoice.Id, pdfData.Length);

            return pdfData;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to generate PDF for invoice {InvoiceId}", invoice.Id);

            // Take a screenshot for debugging
            try
            {
                var screenshot = await page.ScreenshotDataAsync();
                _logger.LogWarning("Captured screenshot of failed PDF generation: {Size} bytes",
                    screenshot.Length);
                // Could save this to blob storage for debugging
            }
            catch
            {
                // Swallow screenshot errors
            }

            throw;
        }
    }

    private async Task<string> GenerateInvoiceHtmlAsync(Invoice invoice)
    {
        // Your HTML generation logic here
        // Could use Razor views, or any templating engine
        return $@"
<!DOCTYPE html>
<html>
<head>
    <meta charset='utf-8'>
    <style>
        @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');

        body {{
            font-family: 'Inter', sans-serif;
            margin: 0;
            padding: 20px;
            color: #333;
        }}

        @media print {{
            .page-break {{
                page-break-before: always;
            }}

            .no-break {{
                page-break-inside: avoid;
            }}
        }}
    </style>
</head>
<body>
    <div class='no-break'>
        <h1>Invoice #{invoice.Number}</h1>
        <p>Date: {invoice.Date:yyyy-MM-dd}</p>
    </div>

    <!-- Invoice content -->
</body>
</html>";
    }
}

Τοπίο εναντίον Πορτραίτου

Απλό αλλά συχνά χρειάζεται:

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

Προσαρμοσμένα μεγέθη σελίδας

Δεν περιορίζεται σε τυποποιημένους μορφότυπους:

var pdfData = await page.PdfDataAsync(new PdfOptions
{
    Width = "210mm",   // Custom width
    Height = "297mm",  // Custom height (this is A4, but you can use any size)
    PrintBackground = true
});

Άλλες Πρακτικές Χρήσεις για το PuppeteerSharp

Πέρα από τις δοκιμές και την παραγωγή PDF, PuppeteerSharp excells σε πολλές άλλες εργασίες αυτοματισμού. Ας εξερευνήσουμε τις πιο κοινές εφαρμογές του πραγματικού κόσμου.

Web Scroping για την εξαγωγή δεδομένων

PuppeteerSharp είναι λαμπρό για ξύσιμο JavaScript-βαρύ τοποθεσίες όπου οι παραδοσιακοί parsers HTML πέφτουν κάτω από:

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

Πότε να το χρησιμοποιήσετε:

  • Αποξένωση εφαρμογών μιας σελίδας (React, Vue, Angular)
  • Τοποθεσίες με άπειρες ρόλους ή τεμπέλης φόρτωσης
  • Όταν χρειάζεται να αλληλεπιδράσετε με τη σελίδα (κλικ κουμπιά, γεμίστε τις φόρμες) πριν από την ξύσιμο
  • Περιεχόμενο πίσω από τοίχους σύνδεσης

Όταν ΔΕΝ το χρησιμοποιήσετε:

  • Απλό στατικό ξύσιμο HTML (χρήση HtmlAgilityPack ή AngleSharpCity name (optional, probably does not need a translation) αντί - πολύ πιο γρήγορα και ελαφρύτερα)
  • Αποξένωση μεγάλου όγκου (το φυλλομετρητή είναι σημαντικό)
  • Όταν υπάρχει API διαθέσιμο (πάντα προτιμούν τα επίσημα API αντί ξύσιμο!)

Αυτόματη παραγωγή στιγμιότυπου οθόνης

Πέρα από τις δοκιμές, τα στιγμιότυπα οθόνης είναι χρήσιμα για μικρογραφίες, προεπισκόπηση, ή αρχειοθέτηση:

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

Πρακτικές χρήσεις:

  • Δημιουργία og:image tags για blog posts
  • Δημιουργία μικρογραφιών για γκαλερί ιστοσελίδων
  • Αρχικοποίηση ιστοσελίδων για συμμόρφωση
  • Δημιουργία εικόνων προεπισκόπησης για την ανταλλαγή συνδέσμων

Παρακολούθηση των επιδόσεων

Επιδόσεις φορτίου σελίδας μέτρησης:

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

Αυτόματη Γενιά Αναφοράς

Συνδυάστε το templating HTML με την παραγωγή PDF για την αυτόματη αναφορά:

public class MonthlyReportGenerator
{
    public async Task<byte[]> GenerateMonthlyReportAsync(ReportData data)
    {
        await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions
        {
            Headless = true
        });

        await using var page = await browser.NewPageAsync();

        // Generate HTML report using your preferred templating engine
        var html = GenerateReportHtml(data);
        await page.SetContentAsync(html);

        // Wait for any charts to render (if using Chart.js, D3.js, etc.)
        await Task.Delay(2000);

        return await page.PdfDataAsync(new PdfOptions
        {
            Format = PaperFormat.A4,
            PrintBackground = true,
            DisplayHeaderFooter = true,
            HeaderTemplate = $@"
                <div style='font-size: 9px; margin: 0 auto; text-align: center;'>
                    Monthly Report - {data.Month:MMMM yyyy}
                </div>
            ",
            FooterTemplate = @"
                <div style='font-size: 9px; margin: 0 auto; text-align: center;'>
                    Page <span class='pageNumber'></span> of <span class='totalPages'></span>
                </div>
            ",
            MarginOptions = new MarginOptions
            {
                Top = "25mm",
                Bottom = "20mm",
                Left = "15mm",
                Right = "15mm"
            }
        });
    }
}

Το κόστος της "ελεύθερης" γενιάς PDF

Τώρα, εδώ είναι το θέμα σχετικά με τη χρήση PuppeteerSharp για PDF γενιά - είναι "δωρεάν" με την έννοια ότι δεν πληρώνετε για μια άδεια βιβλιοθήκης PDF, αλλά είναι όχι δωρεάν από την άποψη των πόρων.

Κάθε παράδειγμα προγράμματος περιήγησης:

  • Χρησιμοποιεί 100-200MB RAM
  • Απαιτεί σημαντική CPU για την απόδοση
  • Χρειάζονται 2-5 δευτερόλεπτα για να δημιουργηθεί ένα PDF (ανάλογα με την πολυπλοκότητα)

Συγκρίνετε αυτό με τις αφιερωμένες βιβλιοθήκες PDF όπως:

  • iText (πρώην iTextSharp) - Απαιτείται εμπορική άδεια (~ 500-300 / έτος), αλλά παράγει PDF σε χιλιοστά του δευτερολέπτου με μικροσκοπικό αποτύπωμα μνήμης
  • QuestPDF - Δωρεάν και ανοικτή πηγή με άδεια MIT, παράγει PDFs από άπταιστα C# κώδικα (no HTML), blazing γρήγορα
  • PdfSharpCore - Δωρεάν άδεια MIT, αλλά πιο περιορισμένες δυνατότητες

Πότε να χρησιμοποιήσετε το PuppeteerSharp για PDF:

  • Έχετε ήδη πρότυπα HTML και δεν θέλετε να ξαναγράψετε σε κώδικα διάταξης PDF
  • Χρειάζεστε pixel-τέλεια απόδοση των σύνθετων διατάξεων ιστού
  • Ο όγκος είναι χαμηλός (< 100 PDFs ανά ώρα)
  • Πρέπει να δημιουργήσετε PDF από εξωτερικές ιστοσελίδες που δεν ελέγχετε

Πότε να χρησιμοποιήσετε τις ειδικές βιβλιοθήκες PDF:

  • Παραγωγή μεγάλου όγκου (> 100 PDF ανά ώρα)
  • Απλές διατάξεις (τιμολόγια, αποδείξεις, εκθέσεις)
  • Προστατευόμενα από τους πόρους περιβάλλοντα
  • Χρειάζεστε προηγμένα χαρακτηριστικά PDF (μορφές, υπογραφές, κρυπτογράφηση)

Μια Υβριδική Προσέγγιση

Μερικές φορές η καλύτερη λύση είναι η χρήση και των δύο:

public class PdfService
{
    private readonly ILogger<PdfService> _logger;

    public async Task<byte[]> GeneratePdfAsync(PdfRequest request)
    {
        // Simple documents - use QuestPDF (fast, low resources)
        if (request.IsSimpleLayout)
        {
            return GenerateWithQuestPdf(request);
        }

        // Complex documents with web content - use PuppeteerSharp
        return await GenerateWithPuppeteerAsync(request);
    }

    private byte[] GenerateWithQuestPdf(PdfRequest request)
    {
        // QuestPDF code here - much faster for simple layouts
        return Document.Create(container =>
        {
            container.Page(page =>
            {
                page.Size(PageSizes.A4);
                page.Margin(2, Unit.Centimetre);
                page.Content().Text(request.Content);
            });
        }).GeneratePdf();
    }

    private async Task<byte[]> GenerateWithPuppeteerAsync(PdfRequest request)
    {
        // PuppeteerSharp code for complex layouts
        await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions
        {
            Headless = true
        });

        await using var page = await browser.NewPageAsync();
        await page.SetContentAsync(request.HtmlContent);
        return await page.PdfDataAsync();
    }
}

Συμπέρασμα

PuppeteerSharp έχει μια απόλυτη αλλαγή παιχνιδιού για τις δοκιμές E2E στα έργα μου .NET. Είναι ταχύτερη από το Σελήνιο, έχει ένα πιο σύγχρονο API, και μόνο γενικά κάνει δοκιμή λιγότερο από μια αγγαρεία.

Άκου τι θα πρότεινα:

  1. Ξεκινήστε με PuppeteerSharp Αν δοκιμάζεις μόνο Chrome/Chromium, είναι απλούστερο και γρηγορότερο από τις εναλλακτικές.

  2. Χρήση του Playwright Έχει όλα τα οφέλη των PuppeteerSharp συν Firefox και Safari.

  3. Αποφύγετε το Σελήνιο για νέα έργα εκτός εάν έχετε συγκεκριμένο λόγο για να το χρησιμοποιήσετε (όπως υποστήριξη IE11, η οποία ελπίζουμε ότι δεν το κάνετε).

  4. Γράψτε τις εξετάσεις με σύνεση. E2E δοκιμές είναι αργή και μπορεί να είναι εύθραυστη. Χρησιμοποιήστε τους για κρίσιμες διαδρομές χρήστη, όχι για τη δοκιμή κάθε μικρή λεπτομέρεια.

  5. Διατηρήστε τις δοκιμές απομονωμένεςΚάθε δοκιμή θα πρέπει να δημιουργεί τα δικά της δεδομένα και να καθαρίζει από μόνη της.

  6. Χρήση μεθόδων βοηθού Το μοτίβο της τάξης της βάσης που έδειξα κρατάει τον πραγματικό σου κώδικα δοκιμής καθαρό και επικεντρώνεται σε αυτό που δοκιμάζεις, όχι στο πώς τον δοκιμάζεις.

Οι δοκιμές E2E δεν χρειάζεται να είναι επώδυνες. Με τα σωστά εργαλεία και μοτίβα, μπορεί πραγματικά να είναι αρκετά ευχάριστο. Δώστε PuppeteerSharp ένα go στο επόμενο έργο σας - Φαντάζομαι ότι θα εκπλαγείτε ευχάριστα.

Σωστά, πάω να γράψω κι άλλες εξετάσεις.

Περαιτέρω Ανάγνωση

logo

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