Enfoque rápido o declarativo de las pruebas unitarias



¡Hola! Mi nombre es Yuri Skvortsov , nuestro equipo se dedica a pruebas automatizadas en Rosbank. Una de nuestras tareas es desarrollar herramientas para automatizar las pruebas funcionales.



En este artículo quiero hablar de una solución que se concibió como una pequeña utilidad auxiliar para la resolución de otros problemas, pero que al final se convirtió en una herramienta autónoma. Estamos hablando del marco Fast-Unit, que le permite escribir pruebas unitarias en un estilo declarativo y convierte el desarrollo de pruebas unitarias en un constructor de componentes. El proyecto se desarrolló principalmente para probar nuestro producto principal, Tladianta, un marco BDD unificado para probar 4 plataformas: escritorio, web, móvil y resto.



Para empezar, probar un marco de automatización no es una tarea común. Sin embargo, en este caso, no era parte de un proyecto de prueba, sino un producto independiente, por lo que rápidamente nos dimos cuenta de la necesidad de unidades.



En la primera etapa, tratamos de usar herramientas listas para usar como assertJ y Mockito, pero rápidamente encontramos algunas características técnicas de nuestro proyecto:



  • Tladianta ya usa JUnit4 como dependencia, lo que dificulta el uso de una versión diferente de JUnit y dificulta el trabajo con Before;
  • Tladianta contiene componentes para trabajar con diferentes plataformas, tiene muchas entidades que son “extremadamente cercanas” en términos de funcionalidad, pero con diferentes jerarquías y diferentes comportamientos;
  • «» ( ) ;
  • , , , , ;
  • - (, Appium , , , );
  • , : Mockito .




Inicialmente, cuando acabamos de aprender a reemplazar el controlador, crear elementos falsos de Selenium y escribir la arquitectura básica para el arnés de prueba, las pruebas se veían así:



@Test
public void checkOpenHint() {
    ElementManager.getInstance().register(xpath,ElementManager.Condition.VISIBLE,
ElementManager.Condition.DISABLED);
    new HintStepDefs().open(("");
    assertTrue(TestResults.getInstance().isSuccessful("Open"));
    assertTrue(TestResults.getInstance().isSuccessful("Click"));
}

@Test
public void checkCloseHint() {
    ElementManager.getInstance().register(xpath);
    new HintStepDefs().close("");
    assertTrue(TestResults.getInstance().isSuccessful("Close"));
    assertTrue(TestResults.getInstance().isSuccessful("Click"));
}


O incluso así:



@Test
public void fillFieldsTestOld() {
    ElementManager.getInstance().register(ElementManager.Type.CHECK_BOX,"//check-box","",
ElementManager.Condition.NOT_SELECTED);
        ElementManager.getInstance().register(ElementManager.Type.INPUT,"//input","");
        ElementManager.getInstance().register(ElementManager.Type.RADIO_GROUP, 
"//radio-group","");
        DataTable dataTable = new Cucumber.DataTableBuilder()
                .withRow("", "true")
                .withRow("", "not selected element")
                .withRow(" ", "text")
                .build();
        new HtmlCommonSteps().fillFields(dataTable);
        assertEquals(TestResults.getInstance().getTestResult("set"), 
ElementProvider.getInstance().provide("//check-box").force().getAttribute("test-id"));
        assertEqualsTestResults.getInstance().getTestResult("sendKeys"), 
ElementProvider.getInstance().provide("//input").force().getAttribute("test-id"));
        assertEquals(TestResults.getInstance().getTestResult("selectByValue"), 
ElementProvider.getInstance().provide("//radio-group").force().getAttribute("test-id"));
    }


No es difícil encontrar lo que se está probando en el código anterior, así como comprender las comprobaciones, pero hay una gran cantidad de código. Si incluye software para verificar y describir errores, entonces se vuelve muy difícil de leer. Y solo estamos tratando de verificar que el método fue llamado en el objeto deseado, mientras que la lógica real de las verificaciones es extremadamente primitiva. Para escribir una prueba de este tipo, debe conocer ElementManager, ElementProvider, TestResults, TickingFuture (un contenedor para implementar un cambio en el estado de un elemento durante un tiempo determinado). Estos componentes eran diferentes en diferentes proyectos, no teníamos tiempo para sincronizar los cambios.



Otro desafío fue el desarrollo de algún estándar. Nuestro equipo tiene la ventaja de los automatizadores, muchos de nosotros no tenemos suficiente experiencia en el desarrollo de pruebas unitarias, y aunque a primera vista es sencillo, leer el código de los demás es bastante laborioso. Intentamos liquidar la deuda técnica con la suficiente rapidez, y cuando aparecieron cientos de tales pruebas, se volvió difícil de mantener. Además, el código resultó estar sobrecargado con configuraciones, se perdieron controles reales y las correas gruesas llevaron al hecho de que, en lugar de probar la funcionalidad del marco, se probaron nuestras propias correas.



Y cuando intentamos transferir los desarrollos de un módulo a otro, quedó claro que necesitábamos resaltar la funcionalidad general. En ese momento, nació la idea no solo de crear una biblioteca con las mejores prácticas, sino también de crear un proceso de desarrollo de una sola unidad dentro de esta herramienta.



Cambiando la filosofía



Si observa el código como un todo, puede ver que muchos bloques de código se repiten "sin significado". Probamos métodos, pero usamos constructores todo el tiempo (para evitar la posibilidad de que se almacene en caché algún error). La primera transformación: hemos trasladado las comprobaciones y la generación de instancias probadas a anotaciones.



@IExpectTestResult(errDesc = "    set", value = "set",
expected = "//check-box", convertedBy = Converters.XpathToIdConverter.class, soft = true)
@IExpectTestResult(errDesc = "    sendKeys", value = "sendKeys", 
expected = "//input", convertedBy = Converters.XpathToIdConverter.class, soft = true)
@IExpectTestResult(errDesc = "    selectByValue", value = "selectByValue",
expected = "//radio-group", convertedBy = Converters.XpathToIdConverter.class, soft = true)
@Test
public void fillFieldsTestOld() {
    ElementManager.getInstance().register(ElementManager.Type.CHECK_BOX, "//check-box", "",
ElementManager.Condition.NOT_SELECTED);
    ElementManager.getInstance().register(ElementManager.Type.INPUT, "//input", "");
    ElementManager.getInstance().register(ElementManager.Type.RADIO_GROUP, 
"//radio-group", "");
    DataTable dataTable = new Cucumber.DataTableBuilder()
            .withRow("", "true")
            .withRow("", "not selected element")
            .withRow(" ", "text")
            .build();
    runTest("fillFields", dataTable);
}


¿Qué cambió?



  • Los controles se han delegado a un componente separado. Ahora no necesita saber cómo se almacenan los elementos, pruebe los resultados.
  • : errDesc , .
  • , , , – runTest, , .
  • .
  • - , .


Nos gustó esta forma de notación y decidimos simplificar otro componente complejo de la misma manera: la generación de elementos. La mayoría de nuestras pruebas están dedicadas a pasos ya hechos, y debemos estar seguros de que funcionan correctamente, sin embargo, para tales comprobaciones, es necesario “lanzar” completamente la aplicación falsa y llenarla con elementos (recordemos que estamos hablando de Web, Desktop y Mobile, las herramientas para las cuales difieren bastante).



@IGenerateElement(type = ElementManager.Type.CHECK_BOX)
@IGenerateElement(type = ElementManager.Type.RADIO_GROUP)
@IGenerateElement(type = ElementManager.Type.INPUT)
@Test
@IExpectTestResult(errDesc = "    set", value = "set", 
expected = "//check-box", convertedBy = Converters.XpathToIdConverter.class, soft = true)
@IExpectTestResult(errDesc = "    sendKeys", value = "sendKeys", 
expected = "//input", convertedBy = Converters.XpathToIdConverter.class, soft = true)
@IExpectTestResult(errDesc = "    selectByValue", value = "selectByValue",
expected = "//radio-group", convertedBy = Converters.XpathToIdConverter.class, soft = true)
public void fillFieldsTest() {
    DataTable dataTable = new Cucumber.DataTableBuilder()
            .withRow("", "true")
            .withRow("", "not selected element")
            .withRow(" ", "text")
            .build();
    runTest("fillFields", dataTable);
}


Ahora el código de prueba se ha convertido en una plantilla completa, los parámetros son claramente visibles y toda la lógica se traslada a los componentes de la plantilla. Las propiedades predeterminadas permitieron eliminar las líneas vacías y brindaron amplias oportunidades de sobrecarga. Este código está casi en línea con el enfoque BDD, condición previa, verificación, acción. Además, todas las vinculaciones se han desprendido de la lógica de las pruebas, ya no necesitas saber sobre administradores, almacenamientos de resultados de pruebas, el código es simple y fácil de leer. Dado que las anotaciones en Java casi no se pueden personalizar, introdujimos un mecanismo para convertidores que pueden recibir el resultado final de una cadena. Este código no solo comprueba el hecho de llamar al método, sino también el id del elemento que lo ejecutó. Casi todas las pruebas que existían en ese momento (más de 200 unidades) fueron rápidamente transferidas a esta lógica, llevándolas a una sola plantilla. Las pruebas se han convertido en lo que deberían ser: documentación,no código, así que llegamos a la declaratividad. Es este enfoque el que formó la base de Fast-Unit: declaratividad, pruebas de autodocumentación y aislamiento de la funcionalidad probada, la prueba está completamente dedicada a verificar un método de prueba.



Seguimos desarrollándonos



Ahora era necesario agregar la capacidad de crear dichos componentes de forma independiente dentro del marco de los proyectos, agregar la capacidad de controlar la secuencia de su operación. Para ello, hemos desarrollado el concepto de fases: a diferencia de Junit, todas estas fases existen de forma independiente dentro de cada prueba y se ejecutan en el momento de la prueba. Como implementación predeterminada, hemos establecido el siguiente ciclo de vida:



  • Package-generate: procesamiento de anotaciones relacionadas con la información del paquete. Los componentes asociados con estos proporcionan descargas de configuración y preparación general del arnés.
  • Generación de clases: procesamiento de anotaciones asociadas con una clase de prueba. Aquí se realizan las acciones de configuración relacionadas con el framework, adaptándolo al enlace preparado.
  • Generar: procesar anotaciones asociadas con el método de prueba en sí (punto de entrada).
  • Prueba: preparar la instancia y ejecutar el método probado.
  • Afirmar: realizar comprobaciones.


Las anotaciones a procesar se describen así:



@Target(ElementType.PACKAGE) //  
@IPhase(value = "package-generate", processingClass = IStabDriver.StabDriverProcessor.class,
priority = 1) //    (      )
public @interface IStabDriver {

    Class<? extends WebDriver> value(); //   ,     

    class StabDriverProcessor implements PhaseProcessor<IStabDriver> { // 
        @Override
        public void process(IStabDriver iStabDriver) {
            //  
        }
    }
}


La característica Fast-Unit es que el ciclo de vida se puede anular para cualquier clase; se describe en la anotación ITestClass, que está diseñada para indicar la clase y las fases bajo prueba. La lista de fases se especifica simplemente como una matriz de cadenas, lo que permite el cambio de composición y la secuencia de fase. Los métodos que manejan las fases también se encuentran usando anotaciones, por lo que es posible crear el manejador necesario en su clase y marcarlo (además, está disponible la anulación dentro de la clase). Una gran ventaja fue que esta separación nos permitió dividir la prueba en capas: si se produjo un error en la prueba final durante la fase de generación o generación de paquetes, entonces el arnés de prueba está dañado. Si genera una clase, hay problemas en los mecanismos de configuración del marco. Si dentro del marco de la prueba hay un error en la funcionalidad probada.La fase de prueba puede arrojar errores técnicamente tanto en el enlace como en la funcionalidad bajo prueba, por lo que incluimos los posibles errores de enlace en un tipo especial: InnerException.



Cada fase está aislada, es decir no depende y no interactúa directamente con otras fases, lo único que se pasa entre las fases son los errores (la mayoría de las fases se saltearán si ocurrió un error en las anteriores, pero esto no es necesario, por ejemplo, la fase de aserción funcionará de todos modos).



Aquí, probablemente, ya ha surgido la pregunta de dónde provienen las instancias de prueba. Si el constructor está vacío, es obvio: usando la API de Reflection, simplemente crea una instancia de la clase bajo prueba. Pero, ¿cómo puede pasar parámetros en esta construcción o configurar la instancia después de que el constructor se haya disparado? ¿Qué hacer si el constructor construye el objeto o, en general, se trata de pruebas estáticas? Para ello se ha desarrollado el mecanismo de proveedores, que esconden tras sí la complejidad del constructor.



Parametrización por defecto:



@IProvideInstance
CheckBox generateCheckBox() {
    return new CheckBox((MobileElement) ElementProvider.getInstance().provide("//check-box")
.get());
}


Sin parámetros, no hay problema (estamos probando la clase CheckBox y registrando un método que creará instancias para nosotros). Dado que el proveedor predeterminado se anula aquí, no es necesario agregar nada en las pruebas, automáticamente usarán este método como fuente. Este ejemplo ilustra claramente la lógica de Fast-Unit: ocultamos lo complejo e innecesario. Desde un punto de vista de prueba, no importa en absoluto cómo y de dónde proviene el elemento móvil envuelto con la clase CheckBox. Todo lo que nos importa es que hay algún objeto CheckBox que cumple con los requisitos especificados.



Inyección automática de argumentos: supongamos que tenemos un constructor como este:



public Mask(String dataFormat, String fieldFormat) {
    this.dataFormat = dataFormat;
    this.fieldFormat = fieldFormat;
}


Entonces, una prueba de esta clase usando inyección de argumentos se verá así:



Object[] dataMask={"_:2_:2_:4","_:2/_:2/_:4"};

@ITestInstance(argSource = "dataMask")
@Test
@IExpectTestResult(errDesc = "  ", value = FAST_RESULT,
expected = "12/10/2012")
public void convert() {
    runTest("convert","12102012");
}


Proveedores nombrados



Finalmente, si se necesitan varios proveedores, utilizamos el enlace de nombres, no solo ocultando la complejidad del constructor, sino también revelando su significado real. El mismo problema se puede resolver así:



@IProvideInstance("")
Mask createDataMask(){
    return new Mask("_:2_:2_:4","_:2/_:2/_:4");
} 

@ITestInstance("")
@Test
@IExpectTestResult(errDesc = "  ", value = FAST_RESULT,
expected = "12/10/2012")
public void convert() {
    runTest("convert","12102012");
}


IProvideInstance e ITestInstance son anotaciones asociadas que le permiten decirle al método dónde obtener la instancia bajo prueba (para estática, simplemente devuelve nulo, ya que esta instancia finalmente se usa a través de la API de Reflection). El enfoque del proveedor brinda mucha más información sobre lo que realmente sucede en la prueba, reemplazando la llamada al constructor con algunos parámetros con texto que describe las condiciones previas, por lo que si el constructor cambia de repente, solo tendremos que corregir el proveedor, pero la prueba permanecerá sin cambios hasta que cambie la funcionalidad real. Si, durante la revisión, ve varios proveedores, notará la diferencia entre ellos y, por lo tanto, las peculiaridades del comportamiento del método probado. Incluso sin conocer el marco en absoluto, pero solo conociendo los principios del funcionamiento de Fast-Unit,el desarrollador podrá leer el código de prueba y comprender qué hace el método probado.



Conclusiones y resultados



Nuestro enfoque resultó tener muchas ventajas:



  • Portabilidad de prueba fácil.
  • Ocultando la complejidad de los enlaces, la posibilidad de refactorizarlos sin romper las pruebas.
  • Compatibilidad con versiones anteriores garantizada: los cambios en los nombres de los métodos se registrarán como errores.
  • Las pruebas se han convertido en documentación bastante detallada para cada método.
  • La calidad de las inspecciones ha mejorado significativamente.
  • El desarrollo de pruebas unitarias se ha convertido en un proceso de canalización y la velocidad de desarrollo y revisión ha aumentado significativamente.
  • Estabilidad de las pruebas desarrolladas: aunque el marco y la propia Fast-Unit se están desarrollando activamente, no hay degradación de las pruebas.


A pesar de la aparente complejidad, pudimos implementar rápidamente esta herramienta. Ahora la mayoría de las unidades están escritas en él, y ya han confirmado su confiabilidad con una migración bastante compleja y voluminosa, pudieron identificar defectos bastante complejos (por ejemplo, al esperar elementos y verificaciones de texto). Logramos eliminar rápidamente la deuda técnica y establecer un trabajo efectivo con las unidades, convirtiéndolas en parte integral del desarrollo. Ahora estamos considerando opciones para una implementación más activa de esta herramienta en otros proyectos fuera de nuestro equipo.



Problemas y planes actuales:



  • , . , ( - ).
  • .
  • .
  • , -.
  • Fast-Unit junit4, junit5 testng



All Articles