Pruebas unitarias, consideración detallada de pruebas parametrizadas. Parte I

Buen día compañeros.



Decidí compartir mi visión sobre las pruebas unitarias parametrizadas, cómo lo hacemos y cómo probablemente usted no lo hace (pero quiere hacerlo).



Me gustaría escribir una hermosa frase sobre lo que se debe probar correctamente, y las pruebas son importantes, pero ya se ha dicho y escrito mucho material antes que yo, solo intentaré resumir y resaltar lo que, en mi opinión, la gente rara vez usa (entiende), para lo cual básicamente se mueve en.



El objetivo principal del artículo es mostrar cómo puede (y debe) dejar de saturar su prueba unitaria con código para crear objetos, y cómo crear datos de prueba declarativamente si simulacros (any ()) no es suficiente, y hay muchas situaciones de este tipo.



Creemos un proyecto maven, agreguemos junit5, junit-jupiter-params y mokito para



que no sea completamente aburrido, comenzaremos a escribir desde la prueba, como les gusta a los apologistas de TDD, necesitamos un servicio que probaremos declarativamente, cualquiera servirá, que sea HabrService.



Creemos una prueba HabrServiceTest. Agregue un enlace al HabrService en el campo de la clase de prueba:



public class HabrServiceTest {

    private HabrService habrService;

    @Test
    void handleTest(){

    }
}


cree un servicio a través de ide (presionando ligeramente el atajo), agregue la anotación @InjectMocks al campo.



Comencemos directamente con la prueba: HabrService en nuestra pequeña aplicación tendrá un solo método handle () que tomará un solo argumento HabrItem, y ahora nuestra prueba se ve así:



public class HabrServiceTest {

    @InjectMocks
    private HabrService habrService;

    @Test
    void handleTest(){
        HabrItem item = new HabrItem();
        habrService.handle(item);
    }
}


Agregue el método handle () a HabrService, que devolverá la identificación de una nueva publicación en Habré después de que haya sido moderada y guardada en la base de datos, y tome el tipo HabrItem, también creamos nuestro HabrItem, y ahora la prueba está compilada, pero falla.



El punto es que agregamos una verificación para el valor de retorno esperado.



public class HabrServiceTest {

    @InjectMocks
    private HabrService habrService;

    @BeforeEach
    void setUp(){
        initMocks(this);
    }

    @Test
    void handleTest() {
        HabrItem item = new HabrItem();
        Long actual = habrService.handle(item);

        assertEquals(1L, actual);
    }
}


Además, quiero asegurarme de que durante la llamada al método handle (), se llamaron ReviewService y PersistanceService, se llamaron estrictamente uno tras otro, funcionaron exactamente 1 vez y ya no se llamaron a otros métodos. En otras palabras, así:



public class HabrServiceTest {

    @InjectMocks
    private HabrService habrService;

    @BeforeEach
    void setUp(){
        initMocks(this);
    }

    @Test
    void handleTest() {
        HabrItem item = new HabrItem();
        
        Long actual = habrService.handle(item);
        
        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(item);
        inOrder.verify(persistenceService).makePersist(item);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }
}


Agregue reviewService y persistenceService a los campos de clase, créelos, agregue los métodos makeRewiew () y makePersist (), respectivamente. Ahora todo se compila, pero por supuesto la prueba es roja.



En el contexto de este artículo, las implementaciones de ReviewService y PersistanceService no son tan importantes, la implementación de HabrService es importante, hagámoslo un poco más interesante de lo que es ahora:



public class HabrService {

    private final ReviewService reviewService;

    private final PersistenceService persistenceService;

    public HabrService(final ReviewService reviewService, final PersistenceService persistenceService) {
        this.reviewService = reviewService;
        this.persistenceService = persistenceService;
    }

    public Long handle(final HabrItem item) {
        HabrItem reviewedItem = reviewService.makeRewiew(item);
        Long persistedItemId = persistenceService.makePersist(reviewedItem);

        return persistedItemId;
    }
}


y usando las construcciones when (). then () bloqueamos el comportamiento de los componentes auxiliares, como resultado, nuestra prueba se volvió así y ahora es verde:



public class HabrServiceTest {

    @Mock
    private ReviewService reviewService;

    @Mock
    private PersistenceService persistenceService;

    @InjectMocks
    private HabrService habrService;

    @BeforeEach
    void setUp() {
        initMocks(this);
    }

    @Test
    void handleTest() {
        HabrItem source = new HabrItem();
        HabrItem reviewedItem = mock(HabrItem.class);

        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);

        Long actual = habrService.handle(source);

        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(source);
        inOrder.verify(persistenceService).makePersist(reviewedItem);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }
}


Está lista una maqueta para demostrar el poder de las pruebas parametrizadas.



Agregue un campo con el tipo de concentrador, hubType a nuestro modelo de solicitud para el servicio HabrItem, cree una enumeración HubType e incluya varios tipos en él:



public enum HubType {
    JAVA, C, PYTHON
}


y para el modelo HabrItem, agregue un captador y un definidor al campo HubType creado.



Supongamos que en las profundidades de nuestro HabrService se esconde un switch que, según el tipo de hub, hace algo desconocido con la solicitud, y en la prueba queremos probar cada uno de los casos de lo desconocido, la implementación ingenua del método quedaría así:



        
    @Test
    void handleTest() {
        HabrItem reviewedItem = mock(HabrItem.class);
        HabrItem source = new HabrItem();
        source.setHubType(HubType.JAVA);

        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);

        Long actual = habrService.handle(source);

        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(source);
        inOrder.verify(persistenceService).makePersist(reviewedItem);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }


Puede hacerlo un poco más bonito y más conveniente haciendo que la prueba esté parametrizada y agregando un valor aleatorio de nuestra enumeración como parámetro, como resultado, la declaración de la prueba se verá así:



@ParameterizedTest
    @EnumSource(HubType.class)
    void handleTest(final HubType type) 


maravillosamente, declarativamente, y todos los valores de nuestra enumeración definitivamente se usarán en la próxima ejecución de pruebas, la anotación tiene parámetros, podemos agregar estrategias para incluir, excluir.



Pero quizás no te he convencido de que las pruebas parametrizadas son buenas. añadir

la solicitud original de HabrItem es un nuevo campo editCount, en el que se escribirá la cantidad de miles de veces que los usuarios de Habr editan su artículo antes de publicarlo, para que te guste al menos un poco, y suponga que en algún lugar de las profundidades de HabrService hay algún tipo de lógica que hace lo desconocido algo, dependiendo de cuánto intentó el autor, qué sucede si no quiero escribir 5 o 55 pruebas para todas las opciones de editCount posibles, pero quiero probar declarativamente, y en algún lugar de un lugar indicar inmediatamente todos los valores que me gustaría verificar ... No hay nada más simple, y usando la api de las pruebas parametrizadas, obtenemos algo como esto en la declaración del método:



    @ParameterizedTest
    @ValueSource(ints = {0, 5, 14, 23})
    void handleTest(final int type) 


Hay un problema, queremos recopilar dos valores en los parámetros del método de prueba a la vez de forma declarativa, puede usar otro método excelente de pruebas parametrizadas @CsvSource, perfecto para probar parámetros simples, con un valor de salida simple (extremadamente conveniente para probar clases de utilidad), pero ¿qué si el objeto se vuelve mucho más complicado? Digamos que tendrá alrededor de 10 campos, y no solo primitivos y tipos de Java.



La anotación @MethodSource viene al rescate, nuestro método de prueba se ha vuelto notablemente más corto y no hay más establecedores en él, y la fuente de la solicitud entrante se alimenta al método de prueba como un parámetro:



    
    @ParameterizedTest
    @MethodSource("generateSource")
    void handleTest(final HabrItem source) {
        HabrItem reviewedItem = mock(HabrItem.class);

        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);

        Long actual = habrService.handle(source);

        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(source);
        inOrder.verify(persistenceService).makePersist(reviewedItem);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }


la anotación @MethodSource tiene la cadena generateSource, ¿qué es? este es el nombre del método que recopilará el modelo requerido para nosotros, su declaración se verá así:



   private static Stream<Arguments> generateSource() {
        HabrItem habrItem = new HabrItem();
        habrItem.setHubType(HubType.JAVA);
        habrItem.setEditCount(999L);
        
        return nextStream(() -> habrItem);
    }


Por conveniencia, moví la formación de un flujo de argumentos nextStream a una clase de prueba de utilidad separada:



public class CommonTestUtil {
    private static final Random RANDOM = new Random();

    public static <T> Stream<Arguments> nextStream(final Supplier<T> supplier) {
        return Stream.generate(() -> Arguments.of(supplier.get())).limit(nextIntBetween(1, 10));
    }

    public static int nextIntBetween(final int min, final int max) {
        return RANDOM.nextInt(max - min + 1) + min;
    }
}


Ahora, al iniciar la prueba, el modelo de solicitud de HabrItem se agregará declarativamente al parámetro del método de prueba, y la prueba se ejecutará tantas veces como la cantidad de argumentos generados por nuestra utilidad de prueba, en nuestro caso de 1 a 10.



Esto puede ser especialmente conveniente si el modelo está en el flujo de argumentos. se recopila no por hardcode, como en nuestro ejemplo, sino con la ayuda de aleatorizadores (viva las pruebas flotantes, pero si lo son, hay un problema).



En mi opinión, todo ya está genial, la prueba ahora describe solo el comportamiento de nuestros stubs y los resultados esperados.



Pero aquí está la mala suerte, se agrega un nuevo campo, texto, una matriz de cadenas al modelo HabrItem, que puede o no ser muy grande, no importa, lo principal es que no queremos desordenar nuestras pruebas, no necesitamos datos aleatorios, queremos un modelo estrictamente definido, con datos específicos, recopilándolos en una prueba o en cualquier otro lugar, no queremos. Sería genial si pudiera tomar el cuerpo de una solicitud json desde cualquier lugar, por ejemplo, de un cartero, hacer un archivo simulado basado en él y formar un modelo declarativamente en la prueba, especificando solo la ruta al archivo json con datos.



Excelente. Usamos la anotación @JsonSource, que tomará un parámetro de ruta, con una ruta de archivo relativa y una clase de destino. ¡Infierno! No existe tal anotación en las pruebas parametrizadas, pero me gustaría.



Escribámoslo nosotros mismos.



ArgumentsProvider es responsable de procesar todas las anotaciones incluidas con @ParametrizedTest en junit, escribiremos nuestro propio JsonArgumentProvider:



public class JsonArgumentProvider implements ArgumentsProvider, AnnotationConsumer<JsonSource> {

    private String path;

    private MockDataProvider dataProvider;

    private Class<?> clazz;

    @Override
    public void accept(final JsonSource jsonSource) {
        this.path = jsonSource.path();
        this.dataProvider = new MockDataProvider(new ObjectMapper());
        this.clazz = jsonSource.clazz();
    }

    @Override
    public Stream<Arguments> provideArguments(final ExtensionContext context) {
        return nextSingleStream(() -> dataProvider.parseDataObject(path, clazz));
    }
}


MockDataProvider es una clase para analizar archivos json simulados, su implementación es extremadamente simple:




public class MockDataProvider {

    private static final String PATH_PREFIX = "json/";

    private final ObjectMapper objectMapper;

     public <T> T parseDataObject(final String name, final Class<T> clazz) {
        return objectMapper.readValue(new ClassPathResource(PATH_PREFIX + name).getInputStream(), clazz);
    }

}


El proveedor simulado está listo, el proveedor de argumentos para nuestra anotación también, queda agregar la anotación en sí:




/**
 * Source-   ,
 *     json-
 */
@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ArgumentsSource(JsonArgumentProvider.class)
public @interface JsonSource {

    /**
     *   json-,   classpath:/json/
     *
     * @return     
     */
    String path() default "";

    /**
     *  ,        
     *
     * @return  
     */
    Class<?> clazz();
}


¡Hurra! Nuestra anotación está lista para usar, el método de prueba ahora es:



  
    @ParameterizedTest
    @JsonSource(path = MOCK_FILE_PATH, clazz = HabrItem.class)
    void handleTest(final HabrItem source) {
        HabrItem reviewedItem = mock(HabrItem.class);

        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);

        Long actual = habrService.handle(source);

        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(source);
        inOrder.verify(persistenceService).makePersist(reviewedItem);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }


en mock json, podemos producir tanto y muy rápidamente un montón de objetos que necesitamos, y de ahora en adelante no hay código que distraiga de la esencia de la prueba, para la formación de datos de prueba, por supuesto, a menudo se puede hacer con simulacros, pero no siempre.



Resumiendo, me gustaría decir lo siguiente: a menudo trabajamos como solíamos trabajar, durante años, sin pensar en el hecho de que algunas cosas se pueden hacer de manera hermosa y sencilla, a menudo utilizando la API estándar de bibliotecas que hemos estado usando durante años, pero no conocemos todas sus capacidades.



PD: El artículo no es un intento de conocimiento de los conceptos de TDD, quería agregar datos de prueba a la campaña de narración para hacerla un poco más clara e interesante.



All Articles