Reflexiones sobre pruebas empresariales efectivas

Hola Habr!

Recientemente, hemos regresado a un estudio exhaustivo del tema de las pruebas y, en los planes previsibles, incluso tenemos un excelente libro sobre Pruebas unitarias. Al mismo tiempo, creemos que el contexto es importante en este tema como en ningún otro lugar, por lo tanto, hoy ofrecemos una traducción de dos publicaciones (combinadas en una) publicadas en el blog de un destacado especialista en Java EE, Sebastian Dashner, a saber, 1/6 y 2/6 de la serie " Reflexiones sobre pruebas empresariales eficientes ".

Las pruebas empresariales son un tema que aún no se ha examinado con tanto detalle como nos gustaría. Se necesita mucho tiempo y esfuerzo para escribir y especialmente para apoyar las pruebas, sin embargo, tratar de ahorrar tiempo al abandonar las pruebas no es una opción. ¿Qué volúmenes de tareas, enfoques y tecnologías de prueba valen la pena explorar para aumentar la efectividad de las pruebas?

Introducción


Independientemente de los diferentes tipos de pruebas y su alcance, el objetivo de preparar un conjunto de pruebas es asegurarse de que, en producción, nuestra aplicación funcione exactamente como se espera. Tal motivación debería ser la principal cuando se verifica si el sistema cumple con la tarea, si consideramos este sistema desde el punto de vista del usuario.

Dado que la capacidad de atención y el cambio de contexto son aspectos a tener en cuenta, debemos asegurarnos de que nuestras pruebas se ejecuten y prueben en un corto período de tiempo y que los resultados de las pruebas sean predecibles. Al escribir código, la verificación rápida del código (factible en un segundo) es crucial, esto asegura una alta productividad y enfoque durante el trabajo.
Por otro lado, debemos garantizar el soporte de la prueba. El software cambia muy a menudo, y con una cobertura sustancial del código con pruebas funcionales, cada cambio funcional en el código de producción requerirá un cambio a nivel de prueba. Idealmente, el código de prueba debería cambiar solo cuando la funcionalidad, es decir, la lógica del negocio, cambia, y no cuando se limpia código innecesario y se refactoriza. En general, los escenarios de prueba deben incluir la posibilidad de cambios estructurales no funcionales.

Cuando consideramos las diferentes áreas de aplicación de las pruebas, surge la pregunta: ¿cuáles de estas áreas merecen el tiempo y el esfuerzo? Por ejemplo, en aplicaciones de microservicio, así como en cualquier sistema que proporcione un trabajo significativo en la distribución e integración de código, las pruebas de integración son especialmente importantes, ya que ayudan a andar a tientas los límites del sistema. Por lo tanto, necesitamos una forma efectiva de probar toda la aplicación como un todo durante el desarrollo local, mientras mantenemos el entorno y la estructura de esta aplicación en la forma más cercana posible a la producción.

Principios y limitaciones


Independientemente de las soluciones que se seleccionarán, definamos los siguientes principios y limitaciones para nuestro conjunto de pruebas:

  • , . , . , , .
  • , , . , , , . , , .
  • - . , , .
  • , , . : « HTTP- gRPC, JSON - enterprise-, ..?”.
  • , , -. API, DSL .
  • « », , , , , , “dev” debug () , dev Quarkus', Telepresence, watch-and-deploy (« ») .
  • . , , , , . .
  • , , -, , , . , , , .


La prueba unitaria verifica el comportamiento de un solo módulo, generalmente una clase, mientras que todos los factores externos que no están relacionados con la estructura del módulo se ignoran o simulan. Las pruebas unitarias deben verificar la lógica empresarial de los módulos individuales, sin verificar su integración o configuración adicional.

En mi experiencia, la mayoría de los desarrolladores empresariales tienen una idea bastante buena de cómo se compilan las pruebas unitarias. Para hacer una impresión de esto, puede ver este ejemplo en mi proyecto de prueba de café .. En la mayoría de los proyectos, JUnit se usa en combinación con Mockito para simular dependencias, e idealmente con AssertJ para definir eficientemente declaraciones legibles. Siempre enfatizo que las pruebas unitarias se pueden realizar sin extensiones o iniciadores especiales, es decir, hacer esto con el JUnit habitual. La explicación es simple: se trata de tiempo de ejecución, porque necesitamos la capacidad de ejecutar cientos de pruebas en cuestión de milisegundos.

Como regla general, las pruebas unitarias se ejecutan muy rápidamente, y es fácil ensamblar conjuntos de pruebas complejas o flujos de trabajo especiales a partir de ellas, ya que son simples de ejecutar y no imponen ninguna restricción en el ciclo de vida de la prueba.

Sin embargo, cuando tiene muchas pruebas unitarias que simulan las dependencias de la clase probada, hay un inconveniente: están estrechamente relacionadas con la implementación (esto se aplica especialmente a clases y métodos), por lo que nuestro código es difícil de refactorizar. En otras palabras, para cada acto de refactorización en el código de producción, también requiere cambios en el código de prueba. En el peor de los casos, los desarrolladores incluso comienzan a rechazar parcialmente la refactorización, simplemente porque es demasiado onerosa y la calidad del código en el proyecto está disminuyendo rápidamente. Idealmente, el desarrollador debería ser capaz de refactorizar y reorganizar los elementos, siempre que no haya cambios en la aplicación que sean notorios para el usuario. Las pruebas unitarias de ninguna manera siempre simplifican la refactorización del código de producción.

Pero la experiencia sugiere que las pruebas unitarias son muy efectivas para verificar el código que está densamente lleno de lógica concisa o describe la implementación de una función específica, por ejemplo, un algoritmo y, al mismo tiempo, no interactúa muy activamente con otros componentes. Cuanto menos complejo o denso sea el código en una clase particular, menor será su complejidad ciclomática, o cuanto más interactúe activamente con otros componentes, menos efectivas serán las pruebas unitarias al probar esta clase. Especialmente en casos con microservicios, en los que se incluye relativamente poca lógica de negocios, pero se proporciona una amplia integración con sistemas externos, tal vez, haya poca necesidad de utilizar pruebas unitarias en muchos. En tales sistemas, los módulos individuales (con raras excepciones) generalmente contienen poca lógica especializada. Esto debe tenerse en cuenta al decidirqué es más apropiado gastar tiempo y esfuerzo.

Probar situaciones de aplicación


Para hacer frente al problema de vincular estrechamente las pruebas con la implementación, puede probar un enfoque ligeramente diferente para ampliar el alcance de las pruebas. En mi libro, escribí sobre pruebas de componentes, porque no pude encontrar un término mejor; pero, en esencia, en este caso estamos hablando de probar situaciones aplicadas.

Las pruebas de situación de la aplicación son pruebas de integración que operan a nivel de código, que no utilizan contenedores incorporados; se abandonan en aras de acelerar el lanzamiento. Ponen a prueba la lógica empresarial de componentes bien coordinados, que generalmente se utilizan dentro de un caso práctico específico, desde el método de límite, y luego hasta todos los componentes asociados con ellos. La integración con sistemas externos, por ejemplo, con bases de datos, se imita utilizando simulacros.

Construir tales escenarios sin el uso de tecnologías más avanzadas que conectarían automáticamente los componentes parece una gran tarea. Sin embargo, definimos componentes de prueba reutilizables, también son contrapartes de prueba que extienden componentes simulando, conectando y también agregando configuraciones de prueba; todo esto se hace para minimizar la cantidad total de esfuerzo requerido para refactorizar. El objetivo es crear las únicas responsabilidades que limitan el grado de influencia de los cambios en una sola clase (o varias clases) en el campo de las pruebas. Al llevar a cabo dicho trabajo con miras a reutilizarlo, reducimos la cantidad total de trabajo necesario, y dicha estrategia se justifica cuando el proyecto crece, pero cada componente requiere solo reparaciones menores, y este trabajo se amortiza rápidamente.

Para imaginar mejor todo esto, supongamos que estamos probando una clase que describe el orden del café. Esta clase incluye otras dos clases: CoffeeShopy OrderProcessor.



Las clases de prueba se duplican CoffeeShopTestDoubley OrderProcessorTestDoublese *TDubican en el área de prueba del proyecto, donde heredan los componentes CoffeeShopy se OrderProcessorubican en el área principal del programa. Las contrapartes de prueba pueden establecer la lógica de simulación y conexión necesaria y potencialmente expandir la interfaz pública de la clase utilizando los métodos de simulación necesarios en esta aplicación o mediante métodos de verificación.

A continuación se muestra la clase doble de prueba para el componente CoffeeShop:

public class CoffeeShopTestDouble extends CoffeeShop {

    public CoffeeShopTestDouble(OrderProcessorTestDouble orderProcessorTestDouble) {
        entityManager = mock(EntityManager.class);
        orderProcessor = orderProcessorTestDouble;
    }

    public void verifyCreateOrder(Order order) {
        verify(entityManager).merge(order);
    }

    public void verifyProcessUnfinishedOrders() {
        verify(entityManager).createNamedQuery(Order.FIND_UNFINISHED, Order.class);
    }

    public void answerForUnfinishedOrders(List<Order> orders) {
        //     
    }
}

La clase doble de prueba puede acceder a los campos y constructores de la clase base CoffeeShop para establecer dependencias. Aquí, en forma de gemelos de prueba, también se utilizan variantes de otros componentes, en particular, OrderProcessorTestDoubleson necesarios para llamar a métodos de simulación o verificación adicionales, que son parte del caso práctico.

Las clases de dobles de prueba son componentes reutilizables, cada uno de los cuales se escribe una vez por alcance de cada proyecto, y luego se usa en muchos casos prácticos:

class CoffeeShopTest {

    private CoffeeShopTestDouble coffeeShop;
    private OrderProcessorTestDouble orderProcessor;

    @BeforeEach
    void setUp() {
        orderProcessor = new OrderProcessorTestDouble();
        coffeeShop = new CoffeeShopTestDouble(orderProcessor);
    }

    @Test
    void testCreateOrder() {
        Order order = new Order();
        coffeeShop.createOrder(order);
        coffeeShop.verifyCreateOrder(order);
    }

    @Test
    void testProcessUnfinishedOrders() {
        List<Order> orders = Arrays.asList(...);
        coffeeShop.answerForUnfinishedOrders(orders);

        coffeeShop.processUnfinishedOrders();

        coffeeShop.verifyProcessUnfinishedOrders();
        orderProcessor.verifyProcessOrders(orders);
    }

}

La prueba de componentes verifica el caso específico de la lógica de negocios que se invoca en el punto de entrada, en este caso CoffeeShop. Dichas pruebas se obtienen concisas y legibles, ya que todas las conexiones y simulaciones se realizan en gemelos de prueba separados, y luego pueden usar técnicas de detección altamente especializadas, tales como verifyProcessOrders().

Como puede ver, la clase de prueba amplía el alcance de la clase de producción, permitiéndole instalar mokee y usar métodos que verifiquen el comportamiento. A pesar de que parece que establecer un sistema de este tipo requiere mucho esfuerzo, estos costos se amortizan rápidamente si, en el marco de todo el proyecto, tenemos muchos casos prácticos en los que los componentes pueden reutilizarse. Cuanto más crezca nuestro proyecto, más útil será este enfoque, especialmente si presta atención al tiempo que lleva completar las pruebas. Todos nuestros casos de prueba todavía se ejecutan usando JUnit, y en el menor tiempo posible, se ejecutan en cientos.

Este es el principal beneficio de este enfoque: las pruebas de componentes se ejecutan tan rápido como las pruebas unitarias regulares, sin embargo, estimulan la refactorización del código de producción, ya que los cambios deben realizarse en un solo componente o en unos pocos componentes. Además, al mejorar las contrapartes de prueba con métodos expresivos de ajuste y verificación específicos de nuestra área temática, aumentamos la legibilidad de nuestro código, facilitamos su uso y eliminamos el código estereotipado en los scripts de prueba.

All Articles