Principio de hojaldre

A todos los impávidos en el camino de la negación a la condena está dedicado ...


imagen



Existe una opinión justa entre los desarrolladores de que si un programador no cubre el código con pruebas, entonces simplemente no comprende por qué son necesarias y cómo prepararlas. Es difícil no estar de acuerdo con esto cuando ya comprendes de qué se trata. Pero, ¿cómo se puede lograr este precioso entendimiento?



No debería ser...



Sucede que a menudo las cosas más obvias no tienen una descripción clara entre las toneladas de información útil en la red global. Una especie de solicitante habitual decide abordar la pregunta urgente "¿qué son las pruebas unitarias?" Y se topa con muchos de estos ejemplos, que se copian de un artículo a otro como un papel de calco:



"Tenemos un método que calcula la suma de números"



public Suma de números enteros (Integer a, Integer b) {

return a + b

}



"puedes escribir una prueba para este método"



Prueba

public void testGoodOne () {

aseverarQue (suma (2,2), es (4));

}


Esto no es una broma, este es un ejemplo simplificado de un artículo típico sobre tecnología de pruebas unitarias, donde al principio y al final hay frases generales sobre el beneficio y la necesidad, y en el medio está ...



Al ver esto, y releerlo dos veces por el bien de la fe, el solicitante exclama: “Qué tontería más cruel ? .. ”Después de todo, en su código prácticamente no hay métodos que obtengan todo lo que necesitan a través de argumentos, y luego les den un resultado inequívoco. Estos son métodos utilitarios típicos y apenas cambian. Pero, ¿qué pasa con los procedimientos complejos, las dependencias inyectadas y los métodos sin devolver valores? Allí, este enfoque no es aplicable a la palabra "absolutamente".



Si en esta etapa el aspirante obstinado no mueve la mano y se sumerge más, pronto descubre que los MOC se utilizan para las dependencias, para cuyos métodos se define algún comportamiento condicional, de hecho un trozo. Aquí, el solicitante puede dejar completamente alucinado si no hay ningún tipo y paciente intermedio / senior que esté listo y sea capaz de explicar todo lo que hay cerca ... De lo contrario, el solicitante de la verdad pierde por completo el significado de "qué son las pruebas unitarias", ya que la mayoría del método probado resulta ser una especie de ficción falsa , y lo que se está probando en este caso no está claro. Además, no está claro cómo organizar esto para una aplicación grande de varias capas y por qué es necesario. Así, en el mejor de los casos, la pregunta se pospone hasta tiempos mejores, en el peor, se esconde en una caja de malditas cosas.



Lo más molesto es que la tecnología de cobertura de prueba es elementalmente simple y accesible para todos, y sus beneficios son tan obvios que cualquier excusa parece ingenua para las personas conocedoras. Pero para resolverlo, el principiante carece de una esencia elemental muy pequeña, como el accionar de un interruptor.



Misión clave



Para empezar, propongo formular en pocas palabras la función clave (misión) de las pruebas unitarias y la ganancia clave. Hay varias opciones pintorescas aquí, pero propongo considerar esta: La



función clave de las pruebas unitarias es capturar el comportamiento esperado del sistema.



y éste: el



beneficio clave de las pruebas unitarias es la capacidad de "ejecutar" toda la funcionalidad de la aplicación en cuestión de segundos.



Recomiendo recordar esto para las entrevistas y explicaré un poco. Cualquier funcionalidad implica reglas de uso y resultados. Estos requisitos provienen del negocio, a través de análisis de sistemas y se implementan en código. Pero el código está en constante evolución, llegan nuevos requisitos y mejoras, que pueden cambiar algo imperceptible e inesperadamente en la funcionalidad final. ¡Aquí es donde las pruebas unitarias están en guardia, que fijan las reglas aprobadas según las cuales el sistema debería funcionar! Las pruebas registran un escenario que es importante para el negocio, y si después de la siguiente revisión falla la prueba, entonces falta algo: o el desarrollador o el analista se equivocó, o los nuevos requisitos contradicen los existentes y deben aclararse, etc. Lo más importante es que la “sorpresa” no se escapó.



Una prueba unitaria simple y estándar hizo posible detectar el comportamiento inesperado y probablemente indeseable del sistema desde el principio. Mientras tanto, el sistema crece y se expande, la probabilidad de perder sus detalles también aumenta y solo los scripts de prueba unitaria pueden recordar todo y evitar salidas inadvertidas a tiempo. Es muy conveniente y confiable, y la principal conveniencia es la velocidad. La aplicación ni siquiera necesita iniciarse y recorrer cientos de sus campos, formularios o botones, necesita ejecutar pruebas y obtener una preparación completa o un error en cuestión de segundos.



Por lo tanto, recuerde: capture el comportamiento esperado en forma de scripts de prueba unitaria y "ejecute" instantáneamente la aplicación sin iniciarla. Este es el valor absoluto que pueden alcanzar las pruebas unitarias.



Pero, maldita sea, ¿cómo?



Pasemos a la parte divertida. Las aplicaciones modernas se están deshaciendo activamente de la monoliticidad. Microservicios, módulos, “capas” son los principios básicos de la organización del código de trabajo, permitiendo lograr independencia, facilidad de reutilización, intercambio y transferencia a sistemas, etc. La inyección de capas y dependencias son clave en nuestro tema.



Veamos las capas de una aplicación web típica: controladores, servicios, repositorios, etc. Además, se utilizan utilidades, fachadas, modelos y capas DTO. Los dos últimos no deben contener funcionalidad, es decir métodos distintos a los de acceso (getters / setters), por lo que no es necesario cubrirlos con pruebas. Consideraremos el resto de capas como objetivos de cobertura.



No importa qué tan sabrosa sugiera esta comparación, la aplicación no se puede comparar con un pastel de hojaldre por la razón de que estas capas están incrustadas entre sí, como dependencias:



  • el controlador implementa el / los servicio / s, que solicita el resultado
  • el servicio inyecta repositorios (DAO) en sí mismo, puede inyectar componentes de utilidad
  • la fachada está diseñada para combinar el trabajo de muchos servicios o componentes, respectivamente, los implementa


La idea principal de probar todo esto en toda la aplicación: cubrir cada capa independientemente de las otras capas. Una referencia a la independencia y otras características anti-monolíticas. Aquellos. si un repositorio está incrustado en el servicio probado, este “invitado” se burla como parte de la prueba del servicio, pero personalmente se prueba honestamente como parte de la prueba del repositorio. Por lo tanto, se crean pruebas para cada elemento de cada capa, nadie se olvida, todo está en el negocio.



Principio de hojaldre



Pasemos a los ejemplos, una aplicación simple de Java Spring Boot, el código será elemental, por lo que la esencia es fácil de entender y aplicable de manera similar a otros lenguajes / marcos modernos. La aplicación tendrá una tarea simple: multiplicar el número por 3, es decir, triple, pero al mismo tiempo crearemos una aplicación de varias capas con inyección de dependencia y cobertura en capas de la cabeza a los pies.



imagen



La estructura contiene paquetes para tres capas: controlador, servicio, repositorio. La estructura de las pruebas es similar.

La aplicación funcionará así:



  1. desde el front-end, una solicitud GET llega al controlador con el identificador del número que debe triplicarse.
  2. el controlador solicita el resultado de su dependencia de servicio
  3. el servicio solicita datos de su dependencia - repositorio, multiplica y devuelve el resultado al controlador
  4. el controlador complementa el resultado y regresa al front-end


Comencemos con el controlador:



@RestController
@RequiredArgsConstructor
public class SomeController {
   private final SomeService someService; // dependency injection

   static final String RESP_PREFIX = ": ";

   static final String PATH_GET_TRIPLE = "/triple/{numberId}";

   @GetMapping(path = PATH_GET_TRIPLE) // mapping method to GET with url=path
   public ResponseEntity<String> triple(@PathVariable(name = "numberId") int numberId) {
       int res = someService.tripleMethod(numberId);   // dependency call
       String resp = RESP_PREFIX + res;                // own logic
       return ResponseEntity.ok().body(resp);
   }
}
      
      





Un controlador de descanso típico tiene una inyección de dependencia someService. El método triple se configura para una solicitud GET a la URL "/ triple / {numberId}", donde el identificador de número se pasa en la variable de ruta. El método en sí se puede dividir en dos componentes principales:



  • acceder a una dependencia: solicitar datos desde el exterior o llamar a un procedimiento sin resultado
  • propia lógica: trabajar con datos existentes


Considere un servicio:



@Service
@RequiredArgsConstructor
public class SomeService {
   private final SomeRepository someRepository; // dependency injection

   public int tripleMethod(int numberId) {
       Integer fromDB = someRepository.findOne(numberId);  // dependency call
       int res = fromDB * 3;                               // own logic
       return res;
   }
}
      
      





Aquí hay una situación similar: inyectando la dependencia someRepository, y el método consiste en acceder a la dependencia y su propia lógica.



Finalmente, el repositorio, por simplicidad, se hace sin una base de datos:



@Repository
public class SomeRepository {
   public Integer findOne(Integer id){
       return id;
   }
}
      
      





El método condicional findOne supuestamente busca en la base de datos un valor por identificador, pero simplemente devuelve el mismo número entero. Esto no afecta la esencia de nuestro ejemplo.



Si ejecuta nuestra aplicación, en la URL configurada podrá ver:







¡Funciona! ¡En capas! En producción ...



Oh sí, pruebas ...



Un poco sobre la esencia. ¡Las pruebas de redacción también son un proceso creativo! Por lo tanto, la excusa "Soy un desarrollador, no un tester" es completamente inapropiada. Una buena prueba, como una buena funcionalidad, requiere ingenio y belleza. Pero antes que nada, es necesario determinar la estructura básica de la prueba.



La clase de prueba contiene métodos que prueban los métodos de la clase de destino. El mínimo que debe contener cada método de prueba es una llamada al método correspondiente de la clase objetivo, hablando condicionalmente así:



@Test
    void someMethod_test() {
        // prepare...

        int res = someService.someMethod(); 
        
        // check...
    }
      
      





Este desafío puede estar rodeado de Preparación y Revisión. Preparación de datos, incluidos argumentos de entrada y descripción del comportamiento de los simulacros. Validar los resultados suele ser una comparación con el valor esperado, ¿recuerda capturar el comportamiento esperado? En total, una prueba es un escenario que simula una situación y registra que pasó como se esperaba y arrojó los resultados esperados.



Usando el controlador como ejemplo, intentemos representar en detalle el algoritmo básico para escribir una prueba. En primer lugar, el método de destino del controlador toma un parámetro int numberId, vamos a agregarlo a nuestro script:



int numberId = 42; // input path variable
      
      





El mismo numberId se transmite en tránsito a la entrada del método de servicio, y ahora es el momento de proporcionar el servicio simulado:



@MockBean
private SomeService someService;
      
      





El propio código del método del controlador trabaja con el resultado recibido del servicio, simulamos este resultado, así como una llamada que lo devuelve:




int serviceRes = numberId*3; // result from mock someService
// prepare someService.tripleMethod behavior
when(someService.tripleMethod(eq(numberId))).thenReturn(serviceRes);

      
      





Esta entrada significa: "cuando se llama a someService.tripleMethod con un argumento igual a numberId, devuelve el valor de serviceRes".



Además, este registro captura el hecho de que se debe llamar a este método de servicio, lo cual es un punto importante. Sucede que necesita arreglar una llamada a un procedimiento sin un resultado, luego se usa una notación diferente, convencionalmente como "no hacer nada cuando ...":




Mockito.doNothing().when(someService).someMethod(eq(someParam));

      
      





Nuevamente, aquí hay solo una imitación del trabajo de someService, las pruebas honestas con una corrección detallada del comportamiento de someService se implementarán por separado. Además, aquí ni siquiera es importante que el valor se triplique, si escribimos




int serviceRes = numberId*5; 
      
      





esto no romperá el script actual, ya que no es el comportamiento de someService lo que se captura aquí, sino el comportamiento del controlador que da por sentado el resultado de someService. Esto es completamente lógico, porque la clase objetivo no puede ser responsable del comportamiento de la dependencia inyectada, sino que debe confiar en ella.



Así que hemos definido el comportamiento del simulacro en nuestro script, por lo tanto, al ejecutar la prueba, cuando dentro de la llamada al método objetivo llega a un simulacro, devolverá lo que se solicitó - serviceRes, y luego el propio código del controlador funcionará con este valor.



A continuación, hacemos una llamada al método de destino en el script. El método del controlador tiene una peculiaridad: no se llama explícitamente en el código, sino que está vinculado a través del método HTTP GET y la URL, por lo que en las pruebas se llama a través de un cliente de prueba especial. En Spring, esto es MockMvc, en otros frameworks hay análogos, por ejemplo, WebTestCase.createClient en Symfony. Entonces, además, es simple ejecutar el método del controlador a través del mapeo por GET y URL.




       //// mockMvc.perform
       MockHttpServletRequestBuilder requestConfig = MockMvcRequestBuilders.get(SomeController.PATH_GET_TRIPLE, numberId);

       MvcResult mvcResult = mockMvc.perform(requestConfig)
           .andExpect(status().isOk())
           //.andDo(MockMvcResultHandlers.print())
           .andReturn()
       ;//// mockMvc.perform

      
      





También se comprueba allí que exista tal mapeo. Si la llamada tiene éxito, se trata de verificar y corregir los resultados. Por ejemplo, puede fijar la cantidad de veces que se llamó al método simulado:




// check of calling
Mockito.verify(someService, Mockito.atLeastOnce()).tripleMethod(eq(numberId));

      
      





En nuestro caso, esto es redundante, ya que ya hemos arreglado su única llamada a través de cuándo, pero a veces este método es apropiado.



Y ahora lo principal: verificamos el comportamiento del propio código del controlador:




// check of result
assertEquals(SomeController.RESP_PREFIX+serviceRes, mvcResult.getResponse().getContentAsString());

      
      





Aquí hemos arreglado de qué es responsable el método en sí: que el resultado recibido de someService está concatenado con el prefijo del controlador, y es esta línea la que va al cuerpo de la respuesta. Por cierto, puedes ver con tus propios ojos el contenido de Body si descomentas la línea




//.andDo(MockMvcResultHandlers.print())

      
      





pero normalmente esta impresión en la consola se utiliza sólo como ayuda para la depuración.



Por lo tanto, tenemos un método de prueba en la clase de prueba del controlador:




@WebMvcTest(SomeController.class)
class SomeControllerTest {
   @MockBean
   private SomeService someService;

   @Autowired
   private MockMvc mockMvc;

   @Test
   void triple() throws Exception {
       int numberId = 42; // input path variable
       int serviceRes = numberId*3; // result from mock someService
       // prepare someService.tripleMethod behavior
       when(someService.tripleMethod(eq(numberId))).thenReturn(serviceRes);

       //// mockMvc.perform
       MockHttpServletRequestBuilder requestConfig = MockMvcRequestBuilders.get(SomeController.PATH_GET_TRIPLE, numberId);

       MvcResult mvcResult = mockMvc.perform(requestConfig)
           .andExpect(status().isOk())
           //.andDo(MockMvcResultHandlers.print())
           .andReturn()
       ;//// mockMvc.perform

       // check of calling
       Mockito.verify(someService, Mockito.atLeastOnce()).tripleMethod(eq(numberId));
       // check of result
       assertEquals(SomeController.RESP_PREFIX+serviceRes, mvcResult.getResponse().getContentAsString());
   }
}
      
      





Ahora es el momento de probar honestamente el método someService.tripleMethod, donde de manera similar hay una llamada de dependencia y su propio código. Prepare un argumento de entrada arbitrario y simule el comportamiento de la dependencia someRepository:




int numberId = 42;
when(someRepository.findOne(eq(numberId))).then(AdditionalAnswers.returnsFirstArg());

      
      





Traducción: "cuando se llama a someRepository.findOne con un argumento igual a numberId, devuelve el mismo argumento". Una situación similar: aquí no verificamos la lógica de la dependencia, pero confiamos en su palabra. Solo capturamos la llamada a la dependencia dentro de este método. El principio aquí es la lógica propia del servicio, su área de responsabilidad:




assertEquals(numberId*3, res);

      
      





Arreglamos que el valor recibido del repositorio debería triplicarse por la propia lógica del método. Ahora bien, esta prueba está guardando este requisito:




@ExtendWith(MockitoExtension.class)
class SomeServiceTest {
   @Mock
   private SomeRepository someRepository; // ,  

   @InjectMocks
   private SomeService someService; //   ,  

   @Test
   void tripleMethod() {
       int numberId = 42;
       when(someRepository.findOne(eq(numberId))).then(AdditionalAnswers.returnsFirstArg());

       int res = someService.tripleMethod(numberId);

       assertEquals(numberId*3, res);
   }
}
      
      





Dado que nuestro repositorio es condicionalmente juguete, la prueba resultó ser apropiada:




class SomeRepositoryTest {
   // no dependency injection
   private final SomeRepository someRepository = new SomeRepository();

   @Test
   void findOne() {
       int id = 777;
       Integer fromDB = someRepository.findOne(id);
       assertEquals(id, fromDB);
   }
}
      
      





Sin embargo, incluso aquí todo el esqueleto está en su lugar: preparación, invocación y verificación. Por lo tanto, el trabajo correcto de someRepository.findOne es fijo.



Un repositorio real requiere probar con levantar la base de datos en la memoria o en un contenedor de prueba, migrar la estructura y los datos, a veces insertando registros de prueba. Esta es a menudo la capa de prueba más larga, pero no menos importante porque Se registra la migración exitosa, los modelos de guardado, la selección correcta, etc. La organización de las pruebas de bases de datos está más allá del alcance de este artículo, pero se describe con precisión en los manuales. No hay inyección de dependencias en el repositorio y no es necesario, su tarea es trabajar con la base de datos. En nuestro caso, sería una prueba con un guardado preliminar del registro en la base de datos y posterior búsqueda por id.



Así, hemos logrado una cobertura total de toda la cadena funcional. Cada prueba es responsable de ejecutar su propio código y captura las llamadas a todas las dependencias. La prueba de una aplicación no requiere ejecutarla con la generación de contexto completa, lo cual es difícil y requiere mucho tiempo. Mantener la funcionalidad con pruebas unitarias rápidas y fáciles crea un entorno de trabajo cómodo y confiable.



Además, las pruebas mejoran la calidad del código. Como parte de las pruebas independientes en capas, a menudo necesita repensar cómo organiza su código. Por ejemplo, primero se ha creado un método en el servicio, no es pequeño, contiene su propio código y simulacros y, por ejemplo, no tiene sentido dividirlo, está cubierto por las pruebas en su totalidad; todas las preparaciones y comprobaciones están definidas. Entonces alguien decide agregar un segundo método al servicio, que llama al primer método. Alguna vez parece una situación común, pero cuando se trata de la cobertura con una prueba, algo no cuadra ... Para el segundo método, ¿tendrá que describir el segundo escenario y duplicar el primer escenario de preparación? Después de todo, no funcionará bloquear el primer método de la clase bajo prueba.



Quizás, en este caso, sea apropiado pensar en una organización diferente del código. Hay dos enfoques opuestos:



  • mueva el primer método a un componente de utilidad que se inyecta como una dependencia en el servicio.
  • Mueva el segundo método a una fachada de servicio que combine diferentes métodos del servicio integrado o incluso varios servicios.


Ambas opciones encajan bien en el principio de "capas" y se prueban convenientemente con burlas de dependencia. La belleza es que cada capa es responsable de su propio trabajo y juntas crean un marco sólido para la invulnerabilidad de todo el sistema.



En la pista ...



Pregunta de la entrevista: ¿Cuántas veces debe un desarrollador ejecutar pruebas dentro de un ticket? Tantas como quieras, pero al menos dos veces:



  • antes de comenzar a trabajar, para asegurarse de que todo esté bien, y para no descubrir más tarde lo que ya se ha roto, y no tú
  • al final del trabajo


Entonces, ¿por qué escribir pruebas? Entonces, que no vale la pena intentar recordar y prever todo en una aplicación grande y compleja, debe confiarse a la automatización. Un desarrollador que no posee pruebas automáticas no está listo para participar en un proyecto grande, cualquier entrevistado lo revelará de inmediato.



Por lo tanto, recomiendo desarrollar estas habilidades si desea calificar para salarios altos. Puede comenzar esta emocionante práctica con cosas básicas, es decir, dentro del marco de su marco favorito, aprender a probar:



  • componentes con dependencias incrustadas, técnicas de burla
  • controladores, porque hay matices de llamar al punto final
  • DAO, repositorios, incluida la elevación de la base de prueba y las migraciones


Espero que este concepto de "hojaldre" nos haya ayudado a comprender la técnica de probar aplicaciones complejas ya sentir lo flexible y poderosa que se nos presenta una herramienta para trabajar. Por supuesto, cuanto mejor es la herramienta, más hábil requiere el trabajo.



¡Disfruta tu trabajo y tu gran habilidad!



El código de ejemplo está disponible en el enlace de github.com: https://github.com/denisorlov/examples/tree/main/unittestidea



All Articles