Migrar de main() a TestNG: framework Selenium con Java

De un main() manual a TestNG con @Test, @BeforeMethod, BaseTest y testng.xml. 5 tests independientes, setup centralizado.

BaseTest.java con @BeforeMethod para setUp y @AfterMethod para tearDown, centralizando inicialización y cierre del driver
BaseTest.java — setUp() y tearDown() se ejecutan antes y después de cada @Test. Se escribe una vez, aplica a todos los tests.

Contexto: el problema del runner manual

Hasta Page Object Model: separar Pages de Tests , mis tests se ejecutaban así:

public class LoginTestRunner {
    public static void main(String[] args) {

        LoginPositiveTests positive = new LoginPositiveTests();
        positive.loginValido_deberiaIngresar();
        positive.clickLoginSinTipear();

        LoginNegativeTests negative = new LoginNegativeTests();
        negative.loginInvalido_passwordIncorrecta_muestraError();
        negative.loginInvalido_usuarioIncorrecto_muestraError();
        negative.loginInvalido_tipearEspacios();
    }
}

Un main() que instancia clases y llama métodos a mano.

Funcionaba. Pero tenía problemas reales:

  • Si un test fallaba con excepción, los siguientes no corrían. El main() se cortaba ahí.
  • Si quería ejecutar solo los negativos, tenía que comentar líneas.
  • No había reporte automático. Solo los prints de mi clase check.java.
  • Cada test repetía las mismas 3 líneas de setup: initDriver(), get(url), quitDriver().
  • No podía ejecutar tests en paralelo.

El runner manual me sirvió para aprender. Pero ya no escala.


Qué es TestNG

TestNG (Test Next Generation) es un framework de testing para Java. Reemplaza la necesidad de un main() para ejecutar tests.

Con el runner manual:

LoginTestRunner.java (main) → crea instancias → llama métodos uno por uno

Con TestNG:

TestNG detecta @Test automáticamente → ejecuta → reporta resultados

No necesitás un runner. TestNG ES el runner.


Por qué TestNG y no JUnit

JUnit es el estándar más común en el mundo dev. TestNG me encaja mejor en este punto porque me da suites y parametrización muy cómoda para automation.

Lo que TestNG me da y que voy a usar:

@Test — Marco un método y TestNG lo ejecuta solo. Si uno falla, los demás siguen corriendo. Eso con el main() no pasaba.

@BeforeMethod / @AfterMethod — Código que se ejecuta antes y después de CADA test. Ahí va el setup (abrir browser, navegar a la URL) y el teardown (cerrar browser). Se escribe una vez y aplica a todos los tests.

testng.xml — Archivo donde defino qué tests correr. Puedo ejecutar solo positivos, solo negativos, o todos. Sin comentar código.

Reportes automáticos — TestNG genera un HTML con resultados: pasó, falló, cuánto tardó cada test.

Reporte HTML generado por TestNG mostrando 15 total, 15 passed, con LoginPositiveTests y LoginNegativeTests en verde
Reporte HTML automático de TestNG. 5 tests, todos pasaron. Cada test muestra sus validaciones con check.java.

Lo que voy a usar más adelante:

DataProviders — Correr el mismo test con distintos datos (múltiples usuarios, contraseñas). Viene en la sesión 9.

Ejecución paralela — Tests corriendo al mismo tiempo. Se configura en testng.xml.


Qué cambié en esta sesión

Tres cosas:

  1. Creé BasePage y BaseTest para eliminar código repetido
  2. Migré los tests a TestNG con @Test
  3. Creé testng.xml y eliminé LoginTestRunner

BasePage: eliminar repetición en las Pages

Hasta ahora, LoginPage y DashboardPage repetían lo mismo:

private final WebDriverWait wait = new WebDriverWait(DriverManager.getDriver(), Duration.ofSeconds(10));

Cada Page necesita el driver y el wait. Repetir eso en cada una es mantenimiento innecesario.

Creé BasePage.java en src/main/java/com/cesar/qa/base/:

package com.cesar.qa.base;

import com.cesar.qa.config.DriverManager;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.support.ui.WebDriverWait;

import java.time.Duration;

public class BasePage {

    protected WebDriver driver;
    protected WebDriverWait wait;

    public BasePage() {
        this.driver = DriverManager.getDriver();
        this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
    }
}

protected significa que las clases que hereden de BasePage pueden usar driver y wait directamente.

Ahora LoginPage y DashboardPage extienden de BasePage:

public class LoginPage extends BasePage {
    // wait y driver vienen heredados, no los declaro
}
public class DashboardPage extends BasePage {
    // mismo caso
}

Eliminé la declaración del wait de ambas. Los métodos internos siguen funcionando igual porque wait ahora viene de BasePage.


BaseTest: eliminar repetición en los Tests

El mismo patrón pero para los tests.

Hasta ahora, cada test repetía:

DriverManager.initDriver();
String baseUrl = ConfigReader.getProperty("base.url");
DriverManager.getDriver().get(baseUrl);

// ... el test ...

DriverManager.quitDriver();

Eso es setup y teardown. Es lo mismo en todos los tests. No tiene sentido escribirlo 5 veces.

Creé BaseTest.java en src/test/java/com/cesar/qa/base/:

package com.cesar.qa.base;

import com.cesar.qa.config.ConfigReader;
import com.cesar.qa.config.DriverManager;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;

public class BaseTest {

    @BeforeMethod
    public void setUp() {
        DriverManager.initDriver();
        String baseUrl = ConfigReader.getProperty("base.url");
        DriverManager.getDriver().get(baseUrl);
    }

    @AfterMethod
    public void tearDown() {
        DriverManager.quitDriver();
    }
}

@BeforeMethod se ejecuta antes de CADA método @Test. @AfterMethod se ejecuta después de CADA método @Test.

No importa si el test pasa o falla: @AfterMethod siempre corre. Eso garantiza que el browser se cierre. Con el runner manual, si un test fallaba con excepción, quitDriver() nunca se ejecutaba y quedaba un Chrome abierto.


Los tests migrados

LoginPositiveTests.java

Antes:

public class LoginPositiveTests {
    public void loginValido_deberiaIngresar() {
        DriverManager.initDriver();
        String baseUrl = ConfigReader.getProperty("base.url");
        DriverManager.getDriver().get(baseUrl);

        LoginPage loginPage = new LoginPage();
        loginPage.loginComo("admin", "serenity");

        DashboardPage dashboard = new DashboardPage();

        check.visible(dashboard.estaVisible(), "Dashboard visible luego de login válido");
        check.equals(dashboard.obtenerTitulo(), "Tablero", "Titulo del dashboard");

        DriverManager.quitDriver();
    }
}

Después:

LoginPositiveTests extendiendo BaseTest con dos métodos @Test: loginValido_deberiaIngresar y clickLoginSinTipear
LoginPositiveTests después de la migración. Sin initDriver(), sin get(url), sin quitDriver(). Solo lógica de negocio.
public class LoginPositiveTests extends BaseTest {

    @Test
    public void loginValido_deberiaIngresar() {
        LoginPage loginPage = new LoginPage();
        loginPage.loginComo("admin", "serenity");

        DashboardPage dashboard = new DashboardPage();

        check.visible(dashboard.estaVisible(), "Dashboard visible luego de login válido");
        check.equals(dashboard.obtenerTitulo(), "Tablero", "Titulo del dashboard");
    }

    @Test
    public void clickLoginSinTipear() {
        LoginPage loginPage = new LoginPage();
        loginPage.clickLogin();

        DashboardPage dashboard = new DashboardPage();

        check.visible(dashboard.estaVisible(), "Dashboard visible luego de login válido");
        check.equals(dashboard.obtenerTitulo(), "Tablero", "Titulo del dashboard");
    }
}

Lo que desapareció: initDriver(), get(baseUrl), quitDriver(). Todo eso lo hace BaseTest.

Lo que se agregó: extends BaseTest y @Test en cada método.

LoginNegativeTests.java

Mismo patrón:

LoginNegativeTests extendiendo BaseTest con tres métodos @Test para login con credenciales inválidas
LoginNegativeTests migrado a TestNG. Tres escenarios negativos, cada uno independiente. Si uno falla, los otros siguen corriendo.
public class LoginNegativeTests extends BaseTest {

    @Test
    public void loginInvalido_passwordIncorrecta_muestraError() {
        LoginPage loginPage = new LoginPage();
        loginPage.loginComo("admin", "mal");
        DashboardPage dashboard = new DashboardPage();

        check.equals(loginPage.obtenerMensajeError(),
            "Error de validación: ¡Nombre de usuario o contraseña inválidos!",
            "Mensaje de error");
        check.isFalse(dashboard.estaVisibleSafe(),
            "El dashboard NO deberia aparecer");
    }

    @Test
    public void loginInvalido_usuarioIncorrecto_muestraError() {
        LoginPage loginPage = new LoginPage();
        loginPage.loginComo("mal", "serenity");
        DashboardPage dashboard = new DashboardPage();

        check.equals(loginPage.obtenerMensajeError(),
            "Error de validación: ¡Nombre de usuario o contraseña inválidos!",
            "Mensaje de error");
        check.isFalse(dashboard.estaVisibleSafe(),
            "El dashboard NO deberia aparecer");
    }

    @Test
    public void loginInvalido_tipearEspacios() {
        LoginPage loginPage = new LoginPage();
        loginPage.loginComo("   ", "   ");
        DashboardPage dashboard = new DashboardPage();

        check.equals(loginPage.obtenerMensajeError(),
            "Error de validación: ¡Nombre de usuario o contraseña inválidos!",
            "Mensaje de error");
        check.isFalse(dashboard.estaVisibleSafe(),
            "El dashboard NO deberia aparecer");
    }
}

LoginTestRunner: eliminado

Ya no existe. TestNG es el runner ahora.

Pasé de controlar manualmente qué tests corren a dejar que TestNG lo haga por mí.


testng.xml: el control de la suite

Creé testng.xml en src/test/resources/:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">

<suite name="QA Automation Lab">

    <test name="Login Tests">
        <classes>
            <class name="com.cesar.qa.tests.login.LoginPositiveTests"/>
            <class name="com.cesar.qa.tests.login.LoginNegativeTests"/>
        </classes>
    </test>

</suite>

Desde acá controlo qué se ejecuta. Si mañana agrego tests de otra funcionalidad, agrego otra sección <test> con sus clases.


Cómo ejecutar

Desde IntelliJ:

  • Click derecho en un método @Test → Run (ejecuta solo ese test)
  • Click derecho en la clase → Run (ejecuta todos los @Test de esa clase)
  • Click derecho en testng.xml → Run (ejecuta toda la suite)

Desde Maven (terminal):

mvn test

Esto funciona porque en el pom.xml ya tenía configurado el plugin maven-surefire-plugin apuntando a testng.xml:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.2.5</version>
    <configuration>
        <suiteXmlFiles>
            <suiteXmlFile>src/test/resources/testng.xml</suiteXmlFile>
        </suiteXmlFiles>
    </configuration>
</plugin>

Ejecución

Ejecuté la suite completa desde testng.xml.

5 tests. Todos pasaron.

===============================================
QA Automation Lab
Total tests run: 5, Passes: 5, Failures: 0, Skips: 0
===============================================

Process finished with exit code 0
Consola de IntelliJ mostrando ejecución de 5 tests con TestNG, todos pasados en 1 minuto 26 segundos, exit code 0
5 tests, 0 failures, exit code 0. Cada test abrió su browser, ejecutó y cerró sin compartir estado con los demás.
Reporte HTML completo de TestNG con detalle de LoginPositiveTests y LoginNegativeTests, todos los tests marcados como passed
Reporte HTML que TestNG genera automáticamente. Sin configurar nada extra, ya tenés visibilidad de resultados.

En esta ejecución los corrió primero X y después Y (según el orden del testng.xml): primero LoginPositiveTests (2 tests), después LoginNegativeTests (3 tests). Cada test abrió su propio browser, navegó a la URL, ejecutó, validó, y cerró. Sin que yo escribiera una sola línea de setup en los tests.

Las advertencias de CDP en el log son las mismas que aparecen desde la sesión 1. No son errores, no afectan la ejecución.

TestNG genera reportes (por ejemplo en test-output) con el resumen de ejecución y detalle de fallos.


Estructura actual del proyecto

src/
├── main/java/com/cesar/qa/
│   ├── base/
│   │   └── BasePage.java          ← NUEVO
│   ├── config/
│   │   ├── ConfigReader.java
│   │   └── DriverManager.java
│   ├── pages/
│   │   ├── DashboardPage.java     ← extends BasePage
│   │   └── LoginPage.java         ← extends BasePage
│   └── utils/
│       └── check.java
│
├── test/java/com/cesar/qa/
│   ├── base/
│   │   └── BaseTest.java          ← NUEVO
│   └── tests.login/
│       ├── LoginPositiveTests.java  ← extends BaseTest + @Test
│       └── LoginNegativeTests.java  ← extends BaseTest + @Test
│
└── test/resources/
    ├── config.properties
    ├── logback.xml
    └── testng.xml                 ← NUEVO

LoginTestRunner.java → eliminado.


Qué gané con esta sesión

Antes (runner manual)Después (TestNG)
Si un test fallaba, los siguientes no corríanCada test es independiente
Setup repetido en cada test (3 líneas)Setup centralizado en BaseTest
Wait repetido en cada PageWait centralizado en BasePage
Para elegir qué correr: comentar códigotestng.xml define qué corre
Sin reportesHTML automático con resultados
Un solo punto de ejecución (main)Ejecutar por test, por clase, o por suite
Este fue el primer salto de mi lab de ‘código que corre’ a ‘framework que se ejecuta como suite’.

Sobre check.java

Sigue funcionando. No lo toqué.

Hoy mis validaciones pasan por check.equals(), check.visible(), check.isFalse(). Son prints con emojis que yo construí.

TestNG tiene su propio sistema de assertions (Assert.assertEquals, Assert.assertTrue). La diferencia es que las assertions de TestNG marcan el test como FAILED cuando algo no coincide. Mi check.java solo imprime, no corta la ejecución.

Voy a migrar a assertions de TestNG en la próxima sesión, junto con DataProviders.

Por ahora lo dejo así porque me sirve como log visual estilo TestComplete.

Próximo paso: DataProviders para ejecutar el mismo test con múltiples credenciales, y reemplazar check.java por assertions que fallen el test de TestNG.


Update:

Modifiqué el BasePage para que lea el timeout de config.properties

config.properties con timeout.explicit=10 en la sección de Timeouts, usado por BasePage para configurar WebDriverWait
config.properties — timeout.explicit=10 es el valor que BasePage lee para crear el WebDriverWait. Cambiar acá aplica a todas las Pages.
BasePage.java leyendo timeout.explicit desde ConfigReader en lugar de tener el valor hardcodeado
BasePage.java — el timeout ya no está hardcodeado. Se lee desde config.properties con ConfigReader.

Así si mañana quiero cambiar el timeout de 10 a 15 segundos, lo toco en config.properties y aplica a todas las Pages sin modificar código.


🔗 Todo el código de esta serie está en: github.com/cesarbeassuarez/qa-automation-lab
📂 selenium-java

Temas conectados:

Esperas Implícitas, Explícitas y Fluent Waits 

Page Object Model: separar Pages de Tests