Implementaciones para C# Plawright
Alexandra Solorzano edited this page 2026-01-06 18:24:10 +00:00
This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

📌 Índice

  1. Introducción
  2. Estructura del Proyecto
  3. Archivo de Configuración JSON
  4. Clase Base: ForecastTestBase
    1. Implementacion de Contexto Compartido
    2. Cargando JSON
    3. Creando un punto inicial para ForecastTool
    4. Login Centralizado Reutilizable
    5. Reporte Final de Resultados (PDF)
  5. Ejemplo de la logica de cada Tests
    1. Logica para añadir una prueba al reporte pdf
  6. Consejos y Buenas Prácticas
  7. Ejemplo de Flujo Completo
  8. QCS - Guía Técnica y Buenas Prácticas
    1. Error: Duplicate Assembly
    2. Trabajo con múltiples regiones (VE / TT)
    3. Valores dinámicos según la región
    4. TXT Log de Errores

🧭 Introducción

Este documento resume todo lo implementado en las últimas semanas para automatizar el sistema ForecastTool. Incluye:

✔️ Uso profesional de Playwright con C# y MSTest
✔️ Configuración externa con JSON
✔️ Login reutilizable
✔️ Contexto compartido para evitar relogin
✔️ Helpers organizados
✔️ Generación de reportes PDF al finalizar la suite

Esta guía servirá para estandarizar buenas prácticas en QA Automation.


📁 Estructura del Proyecto

ForecastTool/
 ├── bin
 ├── obj
 ├── Reports/
 │     └── (PDFs generados)
 ├── Plantillas/
 │     └── PlantillaReporte.html
 ├── ForecastTests/
 │     ├── ForecastDownloadTests.cs
 │     ├── QPPeriodManagement.cs
 │     ├── QPPeriodTests.cs
 │     ├── ...
 ├── Executor.cs
 ├── ForecastTestBase.cs
 ├── ForecastTool.csproj
 ├── testdata.json

🧾 Archivo de Configuración JSON

Se creó un archivo externo con parámetros reutilizables.

testdata.json

{
    "baseUrl": "http://10.16.8.11:8001",
    "homeUrl": "http://10.16.8.11:8001/ForecastTool/pag_Modules.iface",
    "user": "81224403",
    "pass": "Casino20",
    "userDataDir": "C:/Users/85180357/EdgePlaywrightProfile",

    "editRow1": 25,
    "editRow2": 25,
    "salesTarget": 100,
    "confirmationMessage": "Changes applied successfully.",

    "periodYear": 2025,
    "periodMonth": 10,
    "country": "Antigua",
    "periodStatus": "OPENED"
}

:brick: Clase Base: ForecastTestBase

  • Carga JSON
  • Configura SlowMo
  • Define el Browser
  • Implementa Contexto Compartido
  • Incluye LoginAsync()
  • Configuraciones del reporte PDF
  • Carga una plantilla de HTML

♻️ Implementacion de Contexto Compartido

Esto evita:

❌ Logearse en cada prueba
❌ Abrir navegador múltiples veces
❌ Reiniciar sesión accidentalmente

✔️ Acelera la suite
✔️ Hace las pruebas más estables
✔️ Simula usuario real navegando entre módulos

ForecastTestBase.cs

[TestClass]
[DoNotParallelize]
public class ForecastTestBase
{
  protected TestDataForecast _testData;
  protected IBrowserContext _context = null!;
  protected IPage _page = null!;

  public static List<TestInfo> results = new();
  public static DateTime startTime = DateTime.Now;

  public IPage Page => _page;

  // JSON
  public ForecastTestBase()
  {
    _testData = LoadTestData();
  }

  [TestInitialize]
  public async Task Setup()
  {
    var playwright = await Playwright.CreateAsync();
    string userDataDir = _testData.userDataDir;

    _context = await playwright.Chromium.LaunchPersistentContextAsync(userDataDir, new BrowserTypeLaunchPersistentContextOptions
        {
          Headless = false,
          Channel = "msedge",
          SlowMo = 300
        }
    );

    _page = _context.Pages.FirstOrDefault()
            ?? await _context.NewPageAsync();

    // Abrir URL principal
    await _page.GotoAsync(_testData.baseUrl);

    // Si no estás logeado → haz login
    if (await _page.Locator("#txtUsername").IsVisibleAsync())
    {
      await _page.FillAsync("#txtUsername", _testData.user);
      await _page.FillAsync("#txtPassword", _testData.pass);
      await _page.ClickAsync("button:has-text('Login')");
    }
  }

  [TestCleanup]
  public async Task Cleanup()
  {
     await _context.CloseAsync();
  }

// rest of code here...

📄 Cambios Hechos

  • Se eliminó el uso de ExecutablePath = "", ya que no es la práctica recomendada. En su lugar, ahora utilizamos Channel = "", que ofrece una configuración más estable y compatible.
  • La ruta de userDataDir se trasladó al archivo testdata.json, donde ahora se encuentra definida como una variable configurable.
    Esto permite una mejor organización y hace que la configuración sea más flexible y fácil de mantener.

📄 Cargando JSON

private TestDataForecast LoadTestData()
{
  string projectDir = Directory
      .GetParent(AppContext.BaseDirectory)!.Parent!.Parent!.Parent!.FullName;

  string jsonPath = Path.Combine(projectDir, "testdata.json");
  string jsonContent = File.ReadAllText(jsonPath);

  return JsonSerializer.Deserialize<TestDataForecast>(jsonContent)
          ?? throw new InvalidOperationException("No se pudo cargar el archivo JSON");
}

public class TestDataForecast
{
  public string baseUrl { get; set; } = string.Empty;
  public string homeUrl { get; set; } = string.Empty;
  public string user { get; set; } = string.Empty;
  public string pass { get; set; } = string.Empty;
  public string userDataDir { get; set; } = string.Empty;

  public int editRow1 { get; set; }
  public int editRow2 { get; set; }
  public int salesTarget { get; set; }
  public string confirmationMessage { get; set; } = string.Empty;

  public int periodYear { get; set; }
  public int periodMonth { get; set; }
  public string country { get; set; } = string.Empty;
  public string periodStatus { get; set; } = string.Empty;
}

✏️ Creando un punto inicial para ForecastTool

En el caso de ForecastTool, como la aplicación no cuenta con un botón Home para regresar al inicio en cada prueba, es necesario almacenar la URL principal y redirigirnos a ella al inicio de cada test.
La única excepción es LoginTests.cs, donde esta redirección no aplica.

protected async Task GoHomeAsync()
{
  await Page.GotoAsync(_testData.homeUrl, new PageGotoOptions
  {
    WaitUntil = WaitUntilState.NetworkIdle
  });
}

🔐 Login Centralizado Reutilizable

Se añadió un método para que TODA la suite use el mismo login.

protected async Task LoginAsync()
{
  await Page.GotoAsync($"{_testData.baseUrl}/ForecastTool/pag_Login.iface");

  await Page.Locator("[id='j_id16:j_id35']").ClickAsync();
  await Page.Locator("[id='j_id16:j_id35']").FillAsync(_testData.user);

  await Page.Locator("[id='j_id16:j_id37']").ClickAsync();
  await Page.Locator("[id='j_id16:j_id37']").FillAsync(_testData.pass);

  await Page.Locator("[id='j_id16:j_id42']").ClickAsync();

  var homeLocator = Page.Locator("[id='j_id78:j_id174']");
  await homeLocator.WaitForAsync(new LocatorWaitForOptions
  {
    State = WaitForSelectorState.Visible,
    Timeout = 10_000
  });

  await Expect(homeLocator).ToBeVisibleAsync();
}

📄 Reporte Final de Resultados (PDF)

Se añadió un [ClassCleanup] para generar el reporte al finalizar la suite.

Puntos clave:

✔️ Se crea carpeta /Reports automáticamente
✔️ Se genera un PDF con fecha/hora
✔️ Se muestran:

  • Tiempo total
  • Pruebas aprobadas y fallidas
  • Tasa de éxito

✔️ Ahora el reporte utiliza una plantilla HTML (PlantillaReporte.html).
De esta manera, el código queda más limpio, ya que ya no es necesario incluir el HTML directamente dentro del código fuente.

[ClassCleanup]
public static async Task GenerarReportePdf()
{
  var tiempoTotal = DateTime.Now - startTime;
  var aprobadas = results.Count(r => r.Passed);
  var fallidas = results.Count - aprobadas;
  var tasaExito = results.Count > 0 ? (aprobadas * 100.0 / results.Count) : 0;

  
  string projectRoot = Directory.GetParent(AppContext.BaseDirectory)!
                                  .Parent!.Parent!.Parent!.FullName;

  
  string reportFolder = Path.Combine(projectRoot, "Reportes");

  if (!Directory.Exists(reportFolder))
  {
      Directory.CreateDirectory(reportFolder);
  }

  string rutaPdf = Path.Combine(
      reportFolder,
      $"ReportePruebas_{DateTime.Now:yyyyMMdd_HHmmss}.pdf"
  );
  
  // Cargar plantilla HTML externa
  string plantillaPath = Path.Combine(projectRoot, "Plantillas", "PlantillaReporte.html");

  if (!File.Exists(plantillaPath))
  {
      throw new FileNotFoundException($"No se encontró la plantilla HTML para el reporte en {plantillaPath}");
  }

  string htmlTemplate = File.ReadAllText(plantillaPath);

  Template template = Template.Parse(htmlTemplate);

  var model = Hash.FromAnonymousObject(new
  {
      fecha_generado = DateTime.Now.ToString("dd 'de' MMMM 'de' yyyy • HH:mm:ss"),
      duracion_total = tiempoTotal.TotalSeconds.ToString("F2"),
      total_pruebas = results.Count,
      tasa_exito = tasaExito.ToString("F0"),
      aprobadas,
      fallidas,
      estado_reporte = tasaExito >= 80 ? "✓ Todos los Sistemas Operacionales" : "⚠ Problemas Detectados",
      results = results.Select(t => new
      {
          name = t.Name,
          passed = t.Passed,
          duration = t.Duration.TotalMilliseconds.ToString("F0"),
          message = t.Message
      }).ToList(),
      color_tasa_class = tasaExito >= 80 ? "tasa-alta" : "tasa-baja"
  });

  string htmlFinal = template.Render(model);

  var playwright = await Playwright.CreateAsync();
  var browser = await playwright.Chromium.LaunchAsync();
  var context = await browser.NewContextAsync();
  var page = await context.NewPageAsync();

  await page.SetContentAsync(htmlFinal);

  await page.PdfAsync(new PagePdfOptions
  {
    Path = rutaPdf,
    Format = "A4",
    PrintBackground = true,
    Margin = new Margin
    {
      Top = "15mm",
      Right = "15mm",
      Bottom = "15mm",
      Left = "15mm"
    }
  });

  await browser.CloseAsync();

  Console.WriteLine($"\n✓ Reporte PDF generado: {rutaPdf}");
}

public record TestInfo(string Name, bool Passed, TimeSpan Duration, string Message = "");

:puzzle_piece: Ejemplo de la logica de cada Tests

✏️ Logica para añadir una prueba al reporte pdf

Al principio de la clase añade este codigo:

QPCalendarTests.cs

private readonly IPage _page;
private readonly ForecastTestBase.TestDataForecast _data;

public QPCalendarHelper(IPage page, ForecastTestBase.TestDataForecast data)
{
    _page = page;
    _data = data;
}

Al final de la clase añade este codigo:

private async Task LogStep(string name, Func<Task> action)
    {
        var start = DateTime.Now;
        try
        {
            await action();
            ForecastTestBase.results.Add(new ForecastTestBase.TestInfo(name, true, DateTime.Now - start));
        }
        catch (Exception ex)
        {
            ForecastTestBase.results.Add(new ForecastTestBase.TestInfo(name, false, DateTime.Now - start, ex.Message));
            throw;
        }
    }
}

Ejemplo Final

public class QPCalendarHelper
{
    private readonly IPage _page;
    private readonly ForecastTestBase.TestDataForecast _data;

    public QPCalendarHelper(IPage page, ForecastTestBase.TestDataForecast data)
    {
        _page = page;
        _data = data;
    }

    public async Task EditWeeks_And_SendRequest()
    {
        await LogStep("Calendar: Edit Weeks", async () =>
        {
            await _page.GotoAsync(_data.homeUrl);

            await _page.Locator("[id='j_id78:j_id192']").ClickAsync();
            await _page.Locator("[id='qpCalendarForm:selectOneCountry']").ClickAsync();
            await _page.Locator("[id='qpCalendarForm:btnEditCalendar']").ClickAsync();

            // Fila 1
            await _page.Locator("[id='qpCalendarForm:j_id112:0:j_id138']").ClickAsync();
            var salesPctInput = _page.Locator("[id='qpCalendarForm:txtWeekSalesPct']");
            await salesPctInput.FillAsync(_data.editRow1.ToString());
            await _page.Locator("[id='qpCalendarForm:btnSaveWeekEdit']").ClickAsync();

            // Fila 2
            await _page.Locator("[id='qpCalendarForm:j_id112:1:j_id138']").ClickAsync();
            await salesPctInput.FillAsync(_data.editRow2.ToString());
            await _page.Locator("[id='qpCalendarForm:btnSaveWeekEdit']").ClickAsync();

            // Add x2
            await _page.Locator("[id='qpCalendarForm:btnAdd']").ClickAsync();
            await _page.Locator("[id='qpCalendarForm:btnAdd']").ClickAsync();

            await Expect(_page.Locator("[id='qpCalendarForm:j_id112:j_id119']"))
                .ToHaveTextAsync($"{_data.salesTarget}.0%");

            await _page.Locator("[id='qpCalendarForm:btnSendRequest']").ClickAsync();
            await Expect(_page.Locator("[id='qpCalendarForm:j_id150']"))
                .ToHaveTextAsync(_data.confirmationMessage);

            await _page.Locator("[id='qpCalendarForm:j_id152']").ClickAsync();
        });
    }

    private async Task LogStep(string name, Func<Task> action)
    {
        var start = DateTime.Now;
        try
        {
            await action();
            ForecastTestBase.results.Add(new ForecastTestBase.TestInfo(name, true, DateTime.Now - start));
        }
        catch (Exception ex)
        {
            ForecastTestBase.results.Add(new ForecastTestBase.TestInfo(name, false, DateTime.Now - start, ex.Message));
            throw;
        }
    }
}

📌 Consejos y Buenas Prácticas

✅ 1. Siempre usar Page.Locator() en lugar de Selectors directos

Evita flakiness.

✅ 2. Nunca esperar por Timeouts fijos

Usar:

await Page.WaitForSelectorAsync("...");

✅ 3. Mantener la configuración fuera del código

JSON = mantenimiento simple.

✅ 4. Agrupar todo el flujo en una "Executor"

Permite usar todo con una sola sesión.

✅ 5. Verificar y eliminar archivos innecesarios

Si existen archivos que no cumplen ninguna función dentro del proyecto, es recomendable eliminarlos para mantener una estructura más limpia y organizada.
En nuestro caso, se removieron MSTestSettings.cs, Usings.cs y ForecastTool.runsettings, ya que no aportaban valor al flujo actual de pruebas.

✅ 6. Eliminar comentarios innecesarios

Es importante revisar el código y retirar comentarios que ya no aportan información útil, como código comentado, notas obsoletas o explicaciones redundantes.
Esto mejora la legibilidad, facilita el mantenimiento y evita confusiones futuras sobre la intención del código.


🚀 Ejemplo de Flujo Completo

Executor.cs

namespace ForecastTool.PlaywrightTests
{
    [TestClass]
    [DoNotParallelize]
    public class Executor : ForecastTestBase
    {
        [TestMethod]
        public async Task RunAll()
        {
            await LoginAsync();

            var calendar = new QPCalendarHelper(Page, _testData);
            await calendar.EditWeeks_And_SendRequest();

            var download = new ForecastDownloadHelper(Page, _testData);
            await download.DownloadTemplate();

            var periodTest = new QPPeriodsTests(Page, _testData);
            await periodTest.CreateNewPeriod();

            var periodManagement = new QPPeriodManagementTests(Page, _testData);
            await periodManagement.OpenPeriod();
        }

        [ClassCleanup]
        public static async Task CleanAndGenerateReport()
        {
            await GenerarReportePdf();
        }
    }
}

📘 QCS – Guía Técnica y Buenas Prácticas

Esta sección describe soluciones comunes, configuraciones por región y mejoras recientes implementadas en el proyecto QCS, con el objetivo de facilitar la ejecución de pruebas automatizadas.

1️⃣ Error: Duplicate Assembly

En algunas ocasiones, al ejecutar las pruebas, puede aparecer un error relacionado con Duplicate Assembly. Este error suele estar causado por artefactos de compilación corruptos o conflictos en los folders bin y obj.

🔧 Pasos para resolverlo

  1. Asegúrate de estar ubicado en el folder raíz del proyecto QCS, por ejemplo:

    AplicacionesOperativas/QCS

  2. Elimina los folders de compilación ejecutando el siguiente comando:

rm -r -fo bin,obj

⚠️ Nota importante:
Si el proyecto contiene más de un folder bin o obj (por ejemplo en subproyectos), todos deben ser eliminados.

  1. Restaura nuevamente las dependencias:
dotnet restore
  1. Ejecuta nuevamente las pruebas

Con estos pasos, el error de Duplicate Assembly debería resolverse correctamente.

2️⃣ Trabajo con múltiples regiones (VE / TT)

El proyecto QCS soporta 2 regiones, donde las pruebas son las mismas, pero los valores cambian dependiendo de la región activa.

Actualmente se soportan:

  • :flag_ve: Venezuela (VE)
  • :flag_tt: Trinidad & Tobago (TT)

📌 Configuración de la región activa

La región activa se define en el archivo:

TestDataQCSVE.json

En la parte superior del archivo:

"activeRegion": "VE"

Valores permitidos:

  • VE → Venezuela
  • TT → Trinidad & Tobago

Al cambiar este valor:

  • El método LoginAsync utiliza automáticamente la región activa.
  • Todas las pruebas se ejecutan con los valores correspondientes a dicha región.
  • No es necesario modificar el código de las pruebas.

🔁 Cambios adicionales implementados

Además del cambio en el JSON, se realizaron las siguientes implementaciones en la base de QCS:

  • Se agregaron estructuras por región (ByRegion<T>) para manejar valores dinámicos.
  • Se centralizó la resolución de región activa en la base (QSVETestBase).
  • Se crearon métodos utilitarios para obtener valores según la región sin duplicar lógica.
  • Se evitaron condicionales repetitivos (if/else) dentro de las pruebas.

3️⃣ Valores dinámicos según la región

Dependiendo de la región activa, QCS utiliza máquinas, productos y números de serie distintos.
Esto se maneja directamente desde el JSON.

📄 Ejemplo de configuración en JSON

"tc2_tc8": {
  "product": {
    "VE": "10062438",
    "TT": "10047494"
  },
  "machine": {
    "VE": "Maker 08",
    "TT": "Protos 5"
  }
},
"tc9": {
  "sampleNumber": {
    "VE": "4521570",
    "TT": "3490729"
  },
  "maker": {
    "VE": "Maker 06",
    "TT": "Protos 3 (Module 103)"
  },
  "productionOrder": {
    "VE": "L1005",
    "TT": "1402977"
  },
  "brand": {
    "VE": "Craven",
    "TT": "Craven"
  }
}

:puzzle_piece: Modelos en TestDataQCSVE.cs

En la clase principal:

public Tc2Tc8Data tc2_tc8 { get; set; } = new();
public Tc9Data tc9 { get; set; } = new();
public Tc11Data tc11 { get; set; } = new();
public Tc13Data tc13 { get; set; } = new();
public Tc14Data tc14 { get; set; } = new();

Clases específicas por prueba:

public class Tc2Tc8Data
{
    public ByRegion<string> product { get; set; } = new();
    public ByRegion<string> machine { get; set; } = new();
}

public class Tc9Data
{
    public ByRegion<string> sampleNumber { get; set; } = new();
    public ByRegion<string> maker { get; set; } = new();
    public ByRegion<string> productionOrder { get; set; } = new();
    public ByRegion<string> brand { get; set; } = new();
}

🧪 Uso en las pruebas (ejemplo TC9)

// Función inyectada que devuelve el valor según la región activa
private readonly Func<ByRegion<string>, string> _getByRegionString;

// Resolución dinámica de valores según la región
var sampleNumber = _getByRegionString(_testData.tc9.sampleNumber);
var maker = _getByRegionString(_testData.tc9.maker);
var productionOrder = _getByRegionString(_testData.tc9.productionOrder);
var brand = _getByRegionString(_testData.tc9.brand);

📌 Beneficios:

  • No se usan valores hardcodeados.
  • El mismo test funciona para múltiples regiones.
  • El mantenimiento se centraliza en el JSON.

4️⃣ TXT Log de Errores

El reporte PDF generado al final de la ejecución muestra:

  • Tasa de éxito
  • Pruebas ejecutadas
  • Pruebas fallidas
  • Mensaje de error resumido

Sin embargo, cuando el error es muy largo (stack trace completo), no es viable mostrarlo en el PDF sin afectar el diseño.

📝 Solución implementada

  • Se genera un archivo .txt con el error completo.
  • El archivo se guarda en /Reportes
  • El archivo solo se genera si hay errores.
  • Si todas las pruebas pasan:
    • ❌ No se genera .txt
    • ✅ Solo se genera el PDF

🧠 Implementación (QSVETestBase.cs)

var pruebasFallidas = results.Where(r => !r.Passed).ToList();
string? logPath = null;

if (pruebasFallidas.Any())
{
    logPath = Path.Combine(
        reportFolder,
        $"Errores_QCS_{DateTime.Now:yyyyMMdd_HHmmss}.txt"
    );

    using (var writer = new StreamWriter(logPath))
    {
        foreach (var t in pruebasFallidas)
        {
            writer.WriteLine("========================================");
            writer.WriteLine($"TEST: {t.Name}");
            writer.WriteLine($"FECHA: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
            writer.WriteLine($"DURACIÓN: {t.Duration.TotalMilliseconds:F0} ms");
            writer.WriteLine("----------------------------------------");
            writer.WriteLine(t.Message);
            writer.WriteLine();
        }
    }

    Console.WriteLine($"⚠ Log de errores generado: {logPath}");
}

✅ Resumen de Beneficios

  • ✔️ Preparado para múltiples regiones
  • ✔️ Pruebas reutilizables sin duplicación
  • ✔️ Configuración centralizada en JSON
  • ✔️ Reportes claros y profesionales
  • ✔️ Logs detallados solo cuando son necesarios

🎉 Conclusión

Con esta arquitectura:

✔️ El proyecto quedó modular
✔️ Las pruebas son más rápidas
✔️ No hay repetición de login
✔️ La data está estructurada
✔️ Hay un reporte profesional al final
✔️ Facilita mantenimiento y escalabilidad