Pruebas de extremo a extremo con TitiriteroSharp - Una alternativa adecuada al selenio (Español (Spanish))

Pruebas de extremo a extremo con TitiriteroSharp - Una alternativa adecuada al selenio

Thursday, 27 November 2025

//

40 minute read

Modern E2E (End-To-End, utilizando su sitio como los usuarios) pruebas no tiene que ser doloroso. Esta guía completa le muestra cómo utilizar TippeteerSharp para la automatización rápida y confiable del navegador en .NET-cubriendo todo, desde pruebas básicas a la generación de PDF y raspamiento web. Mientras que Playwright de Microsoft es la solución más moderna multi-navegador, Elegí TippeteerSharp para este blog porque es lo que sabía y pruebas sólo Chrome fue suficiente para mis necesidades. Si usted necesita Firefox y Safari apoyo, echa un vistazo a mi Guía del dramaturgo En lugar de eso.

Introducción

Si alguna vez has trabajado con Selenio Para las pruebas de extremo a extremo, sabrás que puede ser un dolor en la parte de atrás. Entre luchar con versiones de conductor, lidiar con pruebas escalofriantes que funcionan en tu máquina pero en ninguna otra parte, y la lentitud general del protocolo WebDriver, es suficiente para hacer que quieras tirar todo y probar manualmente en su lugar.

Entrar TitiriteroSharp - el puerto .NET de Google's Titiritero biblioteca. Es como el primo más joven, más rápido de Selenium que en realidad se molesta en aparecer a tiempo y no requiere que descargue diecisiete controladores de navegador diferentes.

En este artículo, te explicaré cómo he implementado PuppeteerSharp para las pruebas E2E en este mismo blog, con ejemplos de código real de la repo. Cubriremos pruebas, generación de PDF, raspado web, y lo compararemos con las alternativas.

¿Entonces qué es PuppeteerSharp?

TitiriteroSharp es una biblioteca .NET que proporciona una API de alto nivel para controlar los navegadores Chrome o Chromium utilizando el Cromo DevTools ProtocoloA diferencia de Selenio, que utiliza el Protocolo WebDriver (un protocolo de cable JSON basado en HTTP bastante torpe), PuppeteerSharp habla directamente con el navegador a través de DevTools.

Piénsalo de esta manera:

  • Selenio: Como enviar cartas a través del post para comunicarse con su navegador
  • TitiriteroSharp: Como tener una línea telefónica directa al cerebro del navegador
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

Donde las pruebas E2E encajan en su estrategia de pruebas

Antes de sumergirnos más profundamente, hablemos de dónde encajan las pruebas E2E en el gran esquema de las cosas. Probablemente han oído hablar de la pirámide de pruebas - así es como funciona realmente en la práctica:

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

La comprobación de la realidad:

  • Pruebas por unidad (80% de tus pruebas): Rápido, barato, prueba funciones individuales. Pero no te dicen si el sistema realmente funciona como un todo.
  • Pruebas de integración (15% de tus pruebas): Prueba cómo funcionan juntas las diferentes partes. Más rápido que E2E, pero no pruebes toda la interfaz de usuario.
  • Pruebas E2E (5% de tus pruebas): Lento, caro, pero prueba el sistema exactamente como los usuarios lo experimentan. Aquí es donde TitiriteroSharp brilla.

Cuando usted necesita pruebas de E2E:

  1. Viajes críticos de los usuarios - Inicio de sesión, pago, procesamiento de pagos. Si estos descansos, su negocio se detiene.
  2. Útiles de interfaz de usuario con JavaScript pesadas - SPA modernos (Reaccionar, Vue, Angular) donde la interfaz de usuario se hace del lado del cliente.
  3. Cuestiones relativas a los navegantes cruzados - Diferentes navegadores hacen las cosas de manera diferente (aunque con TitiriteroSharp eres sólo Chrome).
  4. Interacciones complejas - Asistentes de varios pasos, arrastrar y soltar, carga de archivos.

Cuando no necesita pruebas de E2E:

  1. Operaciones simples de CRUD - Las pruebas de integración son suficientes.
  2. Lógica pura - Para eso son las pruebas de unidad.
  3. Cada caja de borde - Las pruebas E2E son demasiado lentas y costosas para realizar pruebas exhaustivas.

¿Por qué Titiritero Sharp sobre el selenio?

Déjame contar las maneras:

  1. Sin gestión de controladores Faff: PuppeteerSharp descarga y gestiona el navegador Chrome para usted. No más tonterías con las versiones ChromeDriver que no coinciden con su versión Chrome instalada.

  2. Ejecución más rápida: El protocolo DevTools es significativamente más rápido que WebDriver. Sus pruebas se ejecutarán más rápido, y usted pasará menos tiempo esperando que las cosas sucedan.

  3. Mejor API: La API es más moderna e intuitiva. Es async / espera todo el camino hacia abajo, que se adapta perfectamente con el desarrollo moderno .NET.

  4. Captura de pantalla integrada y generación de PDF: ¿Quieres una captura de pantalla cuando una prueba falla? Es muy simple con PuppeteerSharp.

  5. Solicitudes de interconexión de la red: Puede interceptar, modificar o bloquear peticiones de red con facilidad - brillante para probar escenarios fuera de línea o burlarse de las respuestas de la API.

  6. Ejecución correcta de JavaScript: Ejecutar JavaScript en el contexto de la página y obtener resultados de una manera que no te haga querer llorar.

Configuración de TitiriteroSharp

En primer lugar, añádase el TitiriteroSharp Paquete NuGet:

dotnet add package PuppeteerSharp

Aquí está mi configuración de proyecto de prueba (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>

Estoy usando xUnit (ASP.NET Core por defecto), pero PuppeteerSharp funciona igualmente bien con NUnit o MSTest.

Creación de una clase de prueba básica

En lugar de repetir código de configuración/desgarro en cada prueba, he creado una clase base (Mostlylucid.Test/E2E/E2ETestBase.cs:12) que maneja la gestión del ciclo de vida del navegador:

La estructura de clase

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

Implementamos IAsyncLifetime a diferencia de los constructores tradicionales, esto nos permite esperar adecuadamente la inicialización del navegador.

Inicialización del navegador

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

Los BrowserFetcher descarga automáticamente una versión de cromo compatible en la primera ejecución - no se necesita gestión manual del controlador. --no-sandbox se requieren banderas para entornos Docker/CI.

Limpieza

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

La eliminación adecuada es fundamental para evitar fugas de memoria. Cada instancia del navegador utiliza 100-200MB de RAM.

Métodos de ayuda

La clase base incluye métodos de ayuda para reducir la placa de caldera (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);
}

Estos manejan los tediosos bits - esperando a que existan elementos, manejo elegante del tiempo de espera, y registro automático para cuando las pruebas fallan en CI.

Escribir pruebas reales

Bien, vamos a llegar a lo bueno - escribir pruebas reales. Aquí hay una prueba real de la funcionalidad de la barra de filtro de mi 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");
}

Esta prueba está comprobando que mi menú desplegable del lenguaje funciona correctamente.

El atributo Skip

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

He omitido esta prueba por defecto porque requiere que el sitio se ejecute localmente. Para las pruebas E2E, normalmente quieres ejecutarlas bajo demanda en lugar de con cada compilación. Puedes descomprimirlas cuando estés listo para ejecutarlas, o ejecutarlas en un trabajo CI separado donde tienes el sitio girado.

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

Esta es una de las áreas donde TitiriteroSharp absolutamente brilla. EvaluateFunctionAsync método le permite ejecutar JavaScript en el contexto del navegador y obtener el resultado de nuevo como un tipo .NET adecuado. En este caso, estoy comprobando si un desplegable es realmente visible (no sólo presente en el DOM) mirando sus estilos calculados.

Compare esto con el selenio donde necesitaría:

  1. Encontrar el elemento
  2. Obtener su propiedad de exhibición
  3. Analizar el resultado de la cadena
  4. Espero que no esté rancio para cuando lo revises.

Probando Interacciones HTMX

Mi blog usa HTMX extensamente (renderizado del lado del servidor sin escribir JavaScript). Aquí hay una prueba que comprueba la funcionalidad de clasificación (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 clave aquí es la await WaitAsync(1000) después de cambiar el valor seleccionado. HTMX necesita un momento para hacer su solicitud y actualizar el DOM. En un mundo perfecto, esperaríamos a que una petición de red específica para completar, pero para casos simples, un breve retraso está bien.

Testing Responsive Design

Aquí hay una prueba descarada que comprueba que mi barra de filtro está adecuadamente oculta en los dispositivos móviles (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
    });
}

Usted puede cambiar la vista en cualquier momento, que es brillante para probar diseños sensibles. Mucho más fácil que cambiar el tamaño de su ventana del navegador manualmente!

Características avanzadas del titiriteroSharp

Intercepción de la red

Una de mis características favoritas es la capacidad de interceptar y modificar peticiones de red. Esto es invaluable para probar estados de error o escenarios fuera de línea:

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

Tomando capturas de pantalla

Cuando una prueba falla, una captura de pantalla vale mil mensajes de registro:

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
}

Generación PDF

Incluso puede generar PDFs de páginas, que es útil para probar la renderización del lado del servidor o imprimir hojas de estilos:

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

Cobertura de código

TitiriteroSharp incluso puede recopilar datos de cobertura de código 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}%");

Titiritero Sharp vs La competencia

Echemos un vistazo a cómo TitiriteroSharp se apila contra otras herramientas de prueba 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 vieja guardia

El selenio ha estado alrededor desde 2004 y se muestra. Es maduro, bien documentado, y soporta cada navegador bajo el sol. Pero también está mostrando su edad:

Pros:

  • Soporta todos los navegadores (Chrome, Firefox, Safari, Edge, IE si eres masoquista)
  • Ecosistema masivo de herramientas y extensiones
  • Bien conocido y ampliamente adoptado
  • Bueno para las pruebas de cross-browser

Contras:

  • El protocolo WebDriver es lento
  • La gestión del conductor es un dolor (aunque WebDriverManager ayuda)
  • API se siente anticuado en comparación con las alternativas modernas
  • Las pruebas de Flaky son comunes debido a problemas de tiempo
  • No hay intercepción de red incorporada

Cuándo usarlo: Cuando usted necesita absolutamente probar a través de múltiples navegadores, o cuando usted ya está invertido en el ecosistema de selenio.

Playwright

El nuevo chico en el bloque

Playwright es la respuesta de Microsoft a Titiritero, con Apoyo a .NET Es esencialmente TitiriteroSharp pero con soporte multi-navegador:

Pros:

  • Soporta Chrome, Firefox, Safari (WebKit)
  • API moderna similar a Puppeteer
  • Descarga automáticamente los navegadores
  • Intercepción de red integrada, capturas de pantalla, etc.
  • Excelente soporte .NET

Contras:

  • Ecosistema más nuevo y más pequeño
  • Puede ser exagerado si sólo necesita Chrome
  • Configuración ligeramente más compleja gracias al soporte multi-navegador

Cuándo usarlo: Cuando necesitas soporte multi-navegador pero quieres una API moderna. Si estás iniciando un nuevo proyecto y necesitas pruebas de cross-navegador, Playwright es probablemente tu mejor apuesta.

Cipreses

El amor del desarrollador de JavaScript

Cypress es brillante si estás trabajando en JavaScript/TypeScript, pero no es un iniciador para los desarrolladores de .NET:

Pros:

  • Fantástica experiencia de desarrollador
  • Depuración del viaje en el tiempo
  • Espera automática
  • Gran documentación

Contras:

  • Solo JavaScript/TypeScript
  • No hay soporte .NET
  • No se pueden probar múltiples pestañas o ventanas
  • Limitado a probar su propia aplicación (sin pruebas en todos los dominios)

Cuándo usarlo: No, estás escribiendo código .NET. Apégate a algo que se integra con tu pila de tecnología.

Entonces, ¿qué debe usar?

Aquí está mi opinión:

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

Para la mayoría de los desarrolladores .NET que construyen aplicaciones web modernas:

  • ¿Pruebas de sólo Chrome? → TitiriteroSharp
  • ¿Pruebas de múltiples navegadores? → Playwright
  • ¿Ya usas Selenio? → Considera migrar a Playwright, pero no te apresures

Ensayos de ejecución en CI/CD

Las pruebas E2E están bien en su máquina local, pero también necesitan funcionar en tuberías CI/CD. He aquí cómo he configurado las cosas para Acciones de 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

Los bits clave:

  1. Iniciar la aplicación en segundo plano
  2. Esperar a que esté sano (usando un punto final de un chequeo de salud)
  3. Ejecutar las pruebas E2E
  4. Cargar capturas de pantalla si alguna prueba falla
  5. Siempre detenga la aplicación, incluso si las pruebas fallan

Caídas comunes y cómo evitarlos

Ensayos con escamas

Las pruebas E2E pueden ser escalofriantes - pasan a veces y fallan a otros. Esto generalmente se reduce a problemas de tiempo. He aquí cómo evitarlos:

Mal:

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

Bien:

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

Siempre espera a que el elemento con el que estás a punto de interactuar exista y sea visible.

Aislamiento de prueba

Cada prueba debe ser completamente independiente. No confíe en el estado de pruebas anteriores:

Mal:

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

Bien:

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

Patrón de objeto de página

Para páginas complejas, utilice el patrón de objeto de página para mantener sus pruebas mantenidas:

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

Consideraciones sobre el desempeño

Las pruebas E2E son más lentas que las pruebas unitarias, no hay forma de evitarlas, pero puedes hacerlos más rápidos:

Ejecutar pruebas en paralelo

xUnit ejecuta pruebas en paralelo por defecto, pero debe tener cuidado con el estado compartido:

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

Desactivar las características innecesarias

Acelere las pruebas al desactivar las características que no necesita:

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

Use la intercepción de red sabiamente

Bloquear recursos innecesarios para acelerar las cosas:

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

Depuración de pruebas E2E

Cuando las pruebas fallan (y lo harán), es necesario depurarlas. Estas son algunas técnicas:

Ejecutar en modo sin cabeza

Establecer Headless = false para ver el navegador en acción:

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

Usar DevTools

En realidad puedes abrir DevTools programáticamente:

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

Registro de la consola

Capturar mensajes de consola desde el navegador:

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

Registro de solicitudes

Registrar todas las peticiones de red:

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

Patrones de prueba del mundo real

Estos son algunos patrones que utilizo regularmente en mis pruebas de E2E:

Envíos de formularios de ensayo

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

Pruebas de las interacciones del teclado

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

Pruebas de envíos de archivos

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

Probando arrastrar y soltar

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

Integración con ASP.NET Core Testing

Puede integrar PuppeteerSharp con ASP.NET Core's WebpoliciationFactory para una experiencia de ensayo más integrada:

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

Más allá de las pruebas - PuppeteerSharp para la generación y automatización de PDF

Mientras que la prueba E2E es brillante, PuppeteerSharp es un cuchillo del ejército suizo que puede hacer mucho más. Uno de sus usos más populares es generar PDFs a partir de contenido web - es increíblemente útil para esto, aunque no sin sus gotchas. Si usted está construyendo facturas, informes, o cualquier sistema de generación de documentos, esta sección le ahorrará horas de depuración.

Generando PDFs - La promesa y el dolor

La idea es simple: renderizar una página web en Chrome y guardarla como un PDF. Perfecto para generar facturas, informes, certificados, o cualquier contenido dinámico que necesita ser distribuido en formato PDF.

Aquí está el enfoque básico:

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

Bueno, lo es... hasta que no lo sea, déjame guiarte a través de las minas terrestres.

Generación PDF Gotchas - Lo que nadie te dice

1. Pesadillas empotradas en fuentes

El problema: Sus hermosas fuentes personalizadas no aparecen en el PDF, o peor aún, están ahí, pero parecen absolutamente basura.

Por qué sucede: Chrome necesita acceso a los archivos de fuentes durante la generación de PDF. Si sus fuentes se cargan a través de CDN externo y Chrome no puede llegar a ellos (firewall, problemas de red, cronometraje), que están rellenos.

La solución:

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

Aún mejor, alojar sus fuentes localmente o incrustarlas como base64 en su CSS. Sí, es un faff, pero es confiable.

2. Consultas con los medios de comunicación impresos CSS

El problema: Tu PDF no se parece en nada a tu página web porque Chrome aplica consultas de medios impresos.

Esto es en realidad comportamiento correcto Los PDF son medios impresos, pero atrapan a todos la primera vez.

La solución:

Uso @media print Reglas CSS apropiadas:

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

O, si desea la versión de pantalla en su PDF (útil para generar "screenshots" como PDFs):

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

3. Romper la página - La prohibición de tu existencia

El problema: Su contenido se divide torpemente entre páginas, con encabezados huérfanos en la parte inferior o tablas cortadas a la mitad.

La realidad: Usted está luchando contra el algoritmo de paginación interna de Chrome, y va a ganar la mayor parte del tiempo.

Lo que usted puede hacer:

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

Y en tu código PuppeteerSharp:

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

Consejo Pro: Para diseños complejos, a veces es más fácil estructurar su HTML con saltos de página explícitos en lugar de luchar contra el navegador:

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

4. Encabezados y pies de página - Más complejo de lo que usted pensaría

Puedes añadir encabezados y pies de página, pero la API es un poco wonkky:

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:

  • Las plantillas de encabezado/pie de página deben ser HTML válidas pero son extremadamente limitadas - no CSS externo, no JavaScript
  • Sólo se obtienen variables específicas: date, title, url, pageNumber, totalPages
  • El estilo es sólo en línea
  • Los márgenes deben ser lo suficientemente grandes como para acomodar cabeceras/pies o se superpondrán a su contenido

5. Gráficos de antecedentes

Por defecto, Chrome no imprime imágenes de fondo o colores (este es un navegador predeterminado para guardar tinta). debe habilitarlo:

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

6. Hojas de memoria con documentos grandes

El problema: La generación de un montón de PDF hace que la memoria de su aplicación a globo y eventualmente se estrelló.

¿Por qué? Cada instancia del navegador utiliza memoria significativa (100-200MB), y si usted no está disponiendo correctamente, se acumulan.

La solución:

Usar siempre await using o eliminación adecuada:

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

Para la generación de PDF de alto volumen, considere la reutilización de instancias del navegador:

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. La opción de escala - texto más pequeño, más contenido

A veces necesitas encajar más contenido en una página:

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

Pero ten cuidado - demasiado pequeño y es ilegible.

Patrón de generación de PDF en el mundo real

Así es como realmente hago la generación de PDF en la producción:

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

Paisaje vs retrato

Sencillo pero a menudo necesario:

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

Tamaños de página personalizados

No se limita a los formatos estándar:

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

Otros usos prácticos para titiriteroSharp

Más allá de las pruebas y la generación de PDF, PuppeteerSharp destaca en varias otras tareas de automatización. Exploremos las aplicaciones más comunes del mundo real.

Raspado web para extracción de datos

PuppeteerSharp es brillante para el raspado de sitios JavaScript-pesados donde los analizadores HTML tradicionales se quedan cortos:

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

Cuándo usarlo:

  • Raspando aplicaciones de una sola página (React, Vue, Angular)
  • Sitios con desplazamiento infinito o carga perezosa
  • Cuando necesita interactuar con la página (haga clic en los botones, rellene los formularios) antes de raspar
  • Contenido detrás de las paredes de acceso

Cuándo NO se debe usar:

  • Sencillo raspado estático de HTML (usar HtmlAgilityPack o AngleSharp en su lugar - mucho más rápido y más ligero)
  • Raspado de alto volumen (los gastos generales del navegador son significativos)
  • Cuando hay una API disponible (¡prefiere siempre las API oficiales sobre el raspado!)

Generación automatizada de capturas de pantalla

Más allá de las pruebas, las capturas de pantalla son útiles para miniaturas, vistas previas o archivos:

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

Usos prácticos:

  • Generando etiquetas og:image para publicaciones de blog
  • Crear miniaturas para galerías de sitios web
  • Archivo de páginas web para el cumplimiento
  • Generar imágenes de vista previa para compartir enlaces

Supervisión de la actuación profesional

Medir el rendimiento de carga de la página:

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

Generación automatizada de informes

Combine la plantillación HTML con la generación PDF para informes automatizados:

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

El costo de la generación "gratis" de PDF

Ahora, esto es lo que pasa con el uso de PuppeteerSharp para la generación PDF - es "gratis" en el sentido de que no se paga por una licencia de biblioteca PDF, pero es no libres en términos de recursos.

Cada instancia del navegador:

  • Utiliza 100-200MB de RAM
  • Requiere CPU significativa para renderizar
  • Toma 2-5 segundos para generar un PDF (dependiendo de la complejidad)

Compare esto con bibliotecas PDF dedicadas como:

  • iText (anteriormente iTextSharp) - Licencia comercial requerida (500-3000/año), pero genera PDFs en milisegundos con pequeña huella de memoria
  • QuestPDF - Libre y de código abierto bajo licencia MIT, genera PDFs a partir de código C# fluido (sin HTML), brillando rápido
  • PdfSharpCore - Licencia MIT libre, pero más limitada en capacidades

Cuándo usar PuppeteerSharp para PDFs:

  • Ya tienes plantillas HTML y no quieres volver a escribir en código de diseño PDF
  • Necesita renderizar pixel-perfecto de diseños web complejos
  • El volumen es bajo (< 100 PDFs por hora)
  • Necesitas generar PDFs desde sitios web externos que no controlas

Cuándo Usar Bibliotecas PDF Dedicadas:

  • Generación de alto volumen (> 100 PDFs por hora)
  • Diseños simples (facturas, recibos, informes)
  • Entornos con limitaciones de recursos
  • Necesita funciones avanzadas de PDF (formularios, firmas, cifrado)

Un enfoque híbrido

A veces la mejor solución es el uso de ambos:

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

Conclusión

PuppeteerSharp ha sido un cambio absoluto de juego para las pruebas E2E en mis proyectos .NET. Es más rápido que Selenium, tiene una API más moderna, y por lo general hace que las pruebas sean menos tareas.

Esto es lo que recomiendo:

  1. Empieza con TitiriteroSharp si sólo está probando Chrome / cromo. Es más simple y más rápido que las alternativas.

  2. Usar el derecho de reproducción Si necesitas soporte multi-navegador. Tiene todos los beneficios de PuppeteerSharp más Firefox y Safari.

  3. Evitar el selenio para nuevos proyectos a menos que tenga una razón específica para utilizarlo (como el apoyo IE11, que esperamos que no).

  4. Escribe las pruebas con sensatez. Las pruebas E2E son lentas y pueden ser quebradizas. Úsalas para viajes críticos de usuario, no para probar cada pequeño detalle. Para eso son las pruebas de unidad e integración.

  5. Mantener aislados los ensayos. Cada prueba debe establecer sus propios datos y limpiar después de sí mismo.

  6. Usar métodos de ayuda El patrón de clase base que mostré mantiene tu código real de prueba limpio y enfocado en lo que estás probando, no en cómo lo estás probando.

Las pruebas de E2E no tienen que ser dolorosas. Con las herramientas y patrones adecuados, en realidad puede ser bastante agradable. Dale a TitiriteroSharp una oportunidad en su próximo proyecto - Creo que se sorprenderá gratamente.

Bien, me voy a hacer más pruebas. ¡Feliz prueba!

Lectura adicional

Finding related posts...
logo

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