Prácticas recomendadas para las pruebas de Java





Para tener suficiente cobertura de código, y para crear nuevas funcionalidades y refactorizar las antiguas sin temor a romper algo, las pruebas deben ser fáciles de mantener y de leer. En este artículo, hablaré sobre muchas técnicas para escribir pruebas de unidad e integración en Java, que he recopilado a lo largo de los años. Confiaré en tecnologías modernas: JUnit5, AssertJ, Testcontainers, y tampoco ignoraré a Kotlin. Algunos de los consejos le parecerán obvios, mientras que otros pueden ir en contra de lo que ha leído en libros sobre desarrollo y pruebas de software.



En una palabra



  • Escriba pruebas de manera concisa y específica, utilizando funciones auxiliares, parametrización, varias primitivas de la biblioteca AssertJ, no abuse de las variables, verifique solo lo que sea relevante para la funcionalidad bajo prueba y no incluya todos los casos no estándar en una sola prueba
  • , ,
  • , -,
  • KISS DRY
  • , , , in-memory-
  • JUnit5 AssertJ —
  • : , , Clock - .




Given, When, Then (, , )



La prueba debe contener tres bloques, separados por líneas en blanco. Cada bloque debe ser lo más corto posible. Utilice métodos locales para mantener las cosas compactas.



Dado / Dado (entrada): preparación de la prueba, por ejemplo, creación de datos y configuración simulada.

When (action): llamar al método probado

Then / To (salida): verificar la exactitud del valor recibido



// 
@Test
public void findProduct() {
    insertIntoDatabase(new Product(100, "Smartphone"));

    Product product = dao.findProduct(100);

    assertThat(product.getName()).isEqualTo("Smartphone");
}


Utilice los prefijos "real *" y "esperado *"



// 
ProductDTO product1 = requestProduct(1);

ProductDTO product2 = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
assertThat(product1).isEqualTo(product2);



Si va a utilizar variables en una prueba de coincidencia, agregue los prefijos "real" y "esperado" a las variables. Esto mejorará la legibilidad de su código y aclarará el propósito de las variables. También los hace más difíciles de confundir al comparar.



// 
ProductDTO actualProduct = requestProduct(1);

ProductDTO expectedProduct = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
assertThat(actualProduct).isEqualTo(expectedProduct); //   


Utilice valores preestablecidos en lugar de aleatorios



Evite introducir valores aleatorios en la entrada de pruebas. Esto puede hacer que la prueba parpadee, lo que es muy difícil de depurar. Además, si ve un valor aleatorio en un mensaje de error, no puede rastrearlo hasta donde ocurrió el error.



// 
Instant ts1 = Instant.now(); // 1557582788
Instant ts2 = ts1.plusSeconds(1); // 1557582789
int randomAmount = new Random().nextInt(500); // 232
UUID uuid = UUID.randomUUID(); // d5d1f61b-0a8b-42be-b05a-bd458bb563ad



Utilice diferentes valores predefinidos para todo. De esta manera obtendrá resultados de prueba perfectamente reproducibles, además de encontrar rápidamente el lugar correcto en el código mediante el mensaje de error.



// 
Instant ts1 = Instant.ofEpochSecond(1550000001);
Instant ts2 = Instant.ofEpochSecond(1550000002);
int amount = 50;
UUID uuid = UUID.fromString("00000000-000-0000-0000-000000000001");



Puede escribir esto aún más corto usando funciones auxiliares (ver más abajo).



Escribe pruebas concisas y específicas.



Utilice funciones de ayuda siempre que sea posible



Aísle el código repetitivo en funciones locales y asígneles nombres significativos. Esto mantendrá sus pruebas compactas y fáciles de leer de un vistazo.



// 
@Test
public void categoryQueryParameter() throws Exception {
    List<ProductEntity> products = List.of(
            new ProductEntity().setId("1").setName("Envelope").setCategory("Office").setDescription("An Envelope").setStockAmount(1),
            new ProductEntity().setId("2").setName("Pen").setCategory("Office").setDescription("A Pen").setStockAmount(1),
            new ProductEntity().setId("3").setName("Notebook").setCategory("Hardware").setDescription("A Notebook").setStockAmount(2)
    );
    for (ProductEntity product : products) {
        template.execute(createSqlInsertStatement(product));
    }

    String responseJson = client.perform(get("/products?category=Office"))
            .andExpect(status().is(200))
            .andReturn().getResponse().getContentAsString();

    assertThat(toDTOs(responseJson))
            .extracting(ProductDTO::getId)
            .containsOnly("1", "2");
}


// 
@Test
public void categoryQueryParameter2() throws Exception {
    insertIntoDatabase(
            createProductWithCategory("1", "Office"),
            createProductWithCategory("2", "Office"),
            createProductWithCategory("3", "Hardware")
    );

    String responseJson = requestProductsByCategory("Office");

    assertThat(toDTOs(responseJson))
            .extracting(ProductDTO::getId)
            .containsOnly("1", "2");
}


  • utilice funciones auxiliares para crear datos (objetos) ( createProductWithCategory()) y comprobaciones complejas. Pase solo los parámetros a las funciones auxiliares que sean relevantes en esta prueba; para el resto, use los valores predeterminados adecuados. En Kotlin, existen valores de parámetros predeterminados para esto, y en Java, puede usar cadenas de llamadas a métodos y sobrecarga para simular parámetros predeterminados.
  • La lista de parámetros de longitud variable hará que su código sea aún más elegante ( ìnsertIntoDatabase())
  • Las funciones auxiliares también se pueden utilizar para crear valores simples. Kotlin lo hace aún mejor a través de funciones de extensión.


//  (Java)
Instant ts = toInstant(1); // Instant.ofEpochSecond(1550000001)
UUID id = toUUID(1); // UUID.fromString("00000000-0000-0000-a000-000000000001")


//  (Kotlin)
val ts = 1.toInstant()
val id = 1.toUUID()


Las funciones de ayuda en Kotlin se pueden implementar así:



fun Int.toInstant(): Instant = Instant.ofEpochSecond(this.toLong())

fun Int.toUUID(): UUID = UUID.fromString("00000000-0000-0000-a000-${this.toString().padStart(11, '0')}")


No abuses de las variables



El reflejo condicionado del programador es mover valores de uso frecuente en variables.



// 
@Test
public void variables() throws Exception {
    String relevantCategory = "Office";
    String id1 = "4243";
    String id2 = "1123";
    String id3 = "9213";
    String irrelevantCategory = "Hardware";
    insertIntoDatabase(
            createProductWithCategory(id1, relevantCategory),
            createProductWithCategory(id2, relevantCategory),
            createProductWithCategory(id3, irrelevantCategory)
    );

    String responseJson = requestProductsByCategory(relevantCategory);

    assertThat(toDTOs(responseJson))
            .extracting(ProductDTO::getId)
            .containsOnly(id1, id2);
}


Por desgracia, esto es una sobrecarga de código. Peor aún, al ver el valor en el mensaje de error será imposible rastrear hasta donde ocurrió el error.

"KISS es más importante que DRY"


// 
@Test
public void variables() throws Exception {
    insertIntoDatabase(
            createProductWithCategory("4243", "Office"),
            createProductWithCategory("1123", "Office"),
            createProductWithCategory("9213", "Hardware")
    );

    String responseJson = requestProductsByCategory("Office");

    assertThat(toDTOs(responseJson))
            .extracting(ProductDTO::getId)
            .containsOnly("4243", "1123");
}


Si está tratando de escribir pruebas lo más compactas posible (lo que recomiendo calurosamente de todos modos), entonces los valores reutilizados son claramente visibles. El código en sí se vuelve más compacto y legible. Finalmente, el mensaje de error lo llevará a la línea exacta donde ocurrió el error.



No amplíe las pruebas existentes para "agregar una pequeña cosa más"



// 
public class ProductControllerTest {
    @Test
    public void happyPath() {
        //   ...
    }
}


Siempre es tentador agregar un caso especial a una prueba existente que valida la funcionalidad básica. Pero como resultado, las pruebas se vuelven más grandes y más difíciles de entender. Los casos particulares esparcidos en una gran hoja de código son fáciles de pasar por alto. Si la prueba falla, es posible que no comprenda de inmediato qué la causó exactamente.



// 
public class ProductControllerTest {
    @Test
    public void multipleProductsAreReturned() {}
    @Test
    public void allProductValuesAreReturned() {}
    @Test
    public void filterByCategory() {}
    @Test
    public void filterByDateCreated() {}
}


En su lugar, escriba una nueva prueba con un nombre descriptivo que aclare inmediatamente qué comportamiento espera del código bajo prueba. Sí, tienes que escribir más letras en el teclado (en contra de esto, recuerda, las funciones de ayuda ayudan bien), pero obtendrás una prueba simple y comprensible con un resultado predecible. Por cierto, esta es una excelente manera de documentar las nuevas funciones.



Marque solo lo que quiere probar



Piense en la funcionalidad que está probando. Evite hacer comprobaciones innecesarias solo porque puede. Además, recuerde lo que ya se ha probado en pruebas escritas anteriormente y no lo vuelva a probar. Las pruebas deben ser compactas y su comportamiento esperado debe ser obvio y carecer de detalles innecesarios.



Digamos que queremos probar un identificador HTTP que devuelve una lista de productos. Nuestro conjunto de pruebas debe contener las siguientes pruebas:



1. Una prueba de mapeo grande que verifique que todos los valores de la base de datos se devuelvan correctamente en la respuesta JSON y se asignen correctamente en el formato correcto. Podemos escribir esto fácilmente usando las funciones isEqualTo()(para un solo elemento) o containsOnly()(para varios elementos) del paquete AssertJ, si implementa el método correctamenteequals()...



String responseJson = requestProducts();

ProductDTO expectedDTO1 = new ProductDTO("1", "envelope", new Category("office"), List.of(States.ACTIVE, States.REJECTED));
ProductDTO expectedDTO2 = new ProductDTO("2", "envelope", new Category("smartphone"), List.of(States.ACTIVE));
assertThat(toDTOs(responseJson))
        .containsOnly(expectedDTO1, expectedDTO2);


2. Varias pruebas que comprueban el correcto comportamiento del parámetro? Category. Aquí solo queremos comprobar si los filtros están funcionando correctamente, no los valores de propiedad, porque lo hicimos antes. Por lo tanto, es suficiente para nosotros verificar las coincidencias de los ID de producto recibidos:



String responseJson = requestProductsByCategory("Office");

assertThat(toDTOs(responseJson))
        .extracting(ProductDTO::getId)
        .containsOnly("1", "2");


3. Un par de pruebas más que verifican casos especiales o lógica comercial especial, por ejemplo, que ciertos valores en la respuesta se calculan correctamente. En este caso, solo estamos interesados ​​en algunos campos de la respuesta JSON completa. Por lo tanto, estamos documentando esta lógica especial con nuestra prueba. Está claro que aquí no necesitamos nada más que estos campos.



assertThat(actualProduct.getPrice()).isEqualTo(100);


Pruebas autónomas



No oculte parámetros relevantes (en funciones auxiliares)



// 
insertIntoDatabase(createProduct());
List<ProductDTO> actualProducts = requestProductsByCategory();
assertThat(actualProducts).containsOnly(new ProductDTO("1", "Office"));


Es conveniente utilizar funciones auxiliares para generar datos y verificar condiciones, pero deben llamarse con parámetros. Acepte parámetros para todo lo que sea significativo dentro de la prueba y deba controlarse desde el código de prueba. No obligue al lector a saltar a la función de ayuda para comprender el significado de la prueba. Una regla simple: el significado de la prueba debe quedar claro cuando se mira la prueba en sí.



// 
insertIntoDatabase(createProduct("1", "Office"));
List<ProductDTO> actualProducts = requestProductsByCategory("Office");
assertThat(actualProducts).containsOnly(new ProductDTO("1", "Office"));


Mantenga los datos de las pruebas dentro de las propias pruebas



Todo debería estar adentro. Es tentador transferir algunos de los datos a un método @Beforey reutilizarlos desde allí. Pero esto obligará al lector a saltar de un lado a otro del archivo para comprender qué está sucediendo exactamente aquí. Nuevamente, las funciones de ayuda lo ayudarán a evitar la repetición y harán que sus pruebas sean más fáciles de entender.



Usa composición en lugar de herencia



No cree jerarquías de clases de prueba complejas.



// 
class SimpleBaseTest {}
class AdvancedBaseTest extends SimpleBaseTest {}
class AllInklusiveBaseTest extends AdvancedBaseTest {}
class MyTest extends AllInklusiveBaseTest {}


Tales jerarquías complican la comprensión y usted, muy probablemente, se encontrará rápidamente escribiendo el próximo sucesor de la prueba básica, dentro de la cual se acumula una gran cantidad de basura que la prueba actual no necesita en absoluto. Esto distrae al lector y conduce a errores sutiles. La herencia no es flexible: ¿crees que puedes usar todos los métodos de una clase AllInclusiveBaseTest, pero ninguno de su padre ? AdvancedBaseTest?Además, el lector tendrá que saltar constantemente entre diferentes clases base para comprender el panorama general.

"Es mejor duplicar el código que elegir la abstracción incorrecta" (Sandi Metz)



Recomiendo usar composición en su lugar. Escriba pequeños fragmentos y clases para cada tarea relacionada con el dispositivo (inicie una base de datos de prueba, cree un esquema, inserte datos, inicie un servidor simulado). Reutilice estas partes en un método @BeforeAllo asignando los objetos creados a los campos de la clase de prueba. De esta manera, podrá construir cada nueva clase de prueba a partir de estos espacios en blanco, como a partir de piezas de Lego. Como resultado, cada prueba tendrá su propio conjunto de accesorios comprensible y garantizará que no ocurra nada extraño en ella. La prueba se vuelve autosuficiente, porque contiene todo lo que necesita.



// 
public class MyTest {
    //   
    private JdbcTemplate template;
    private MockWebServer taxService;

    @BeforeAll
    public void setupDatabaseSchemaAndMockWebServer() throws IOException {
        this.template = new DatabaseFixture().startDatabaseAndCreateSchema();
        this.taxService = new MockWebServer();
        taxService.start();
    }
}


//   
public class DatabaseFixture {
    public JdbcTemplate startDatabaseAndCreateSchema() throws IOException {
        PostgreSQLContainer db = new PostgreSQLContainer("postgres:11.2-alpine");
        db.start();
        DataSource dataSource = DataSourceBuilder.create()
                .driverClassName("org.postgresql.Driver")
                .username(db.getUsername())
                .password(db.getPassword())
                .url(db.getJdbcUrl())
                .build();
        JdbcTemplate template = new JdbcTemplate(dataSource);
        SchemaCreator.createSchema(template);
        return template;
    }
}


Una vez más:

"KISS es más importante que DRY"


Las pruebas sencillas son buenas. Compare el resultado con constantes



No reutilice el código de producción



Las pruebas deben validar el código de producción, no reutilizarlo. Si reutiliza el código de combate en una prueba, puede pasar por alto un error en ese código porque ya no lo está probando.



// 
boolean isActive = true;
boolean isRejected = true;
insertIntoDatabase(new Product(1, isActive, isRejected));

ProductDTO actualDTO = requestProduct(1);


//   
List<State> expectedStates = ProductionCode.mapBooleansToEnumList(isActive, isRejected);
assertThat(actualDTO.states).isEqualTo(expectedStates);


En su lugar, piense en términos de entrada y salida cuando escriba pruebas. La prueba alimenta datos a la entrada y compara la salida con constantes predefinidas. La mayoría de las veces, no es necesario reutilizar el código.



// Do
assertThat(actualDTO.states).isEqualTo(List.of(States.ACTIVE, States.REJECTED));


No copie la lógica empresarial en pruebas



El mapeo de objetos es un excelente ejemplo de un caso en el que las pruebas extraen la lógica del código de combate. Supongamos que nuestra prueba contiene un método mapEntityToDto(), cuyo resultado se usa para verificar que el DTO resultante contiene los mismos valores que los elementos que se agregaron a la base al comienzo de la prueba. En este caso, lo más probable es que copie el código de combate en la prueba, que puede contener errores.



// 
ProductEntity inputEntity = new ProductEntity(1, "envelope", "office", false, true, 200, 10.0);
insertIntoDatabase(input);

ProductDTO actualDTO = requestProduct(1);

 // mapEntityToDto()    ,   -
ProductDTO expectedDTO = mapEntityToDto(inputEntity);
assertThat(actualDTO).isEqualTo(expectedDTO);



La solución correcta es actualDTOcompararlo con un objeto de referencia creado manualmente con los valores especificados. Es extremadamente simple, directo y protege contra posibles errores.



// 
ProductDTO expectedDTO = new ProductDTO("1", "envelope", new Category("office"), List.of(States.ACTIVE, States.REJECTED))
assertThat(actualDTO).isEqualTo(expectedDTO);


Si no desea crear y verificar una coincidencia para un objeto de referencia completo, puede verificar el objeto secundario o, en general, solo las propiedades del objeto que son relevantes para la prueba.



No escribas demasiada lógica



Permítanme recordarles que las pruebas se tratan principalmente de entradas y salidas. Envíe los datos y compruebe lo que se le devuelve. No es necesario escribir lógica compleja dentro de las pruebas. Si introduce bucles y condiciones en una prueba, la hará menos comprensible y más propensa a errores. Si su lógica de validación es compleja, use las muchas funciones de AssertJ para hacer el trabajo por usted.



Realice pruebas en un entorno similar al de un combate



Pruebe el paquete de componentes más completo posible



En general, se recomienda que pruebe cada clase de forma aislada utilizando simulacros. Este enfoque, sin embargo, tiene inconvenientes: de esta manera, la interacción de las clases entre sí no se prueba y cualquier refactorización de las entidades generales romperá todas las pruebas a la vez, porque cada clase interna tiene sus propias pruebas. Además, si escribe pruebas para cada clase, simplemente habrá demasiadas.





Prueba unitaria aislada de cada clase



En cambio, recomiendo centrarse en las pruebas de integración. Por "pruebas de integración" me refiero a reunir todas las clases (como en producción) y probar todo el paquete, incluidos los componentes de la infraestructura (servidor HTTP, base de datos, lógica empresarial). En este caso, está probando el comportamiento en lugar de la implementación. Tales pruebas son más precisas, más cercanas al mundo real y resistentes a la refactorización de componentes internos. Idealmente, una clase de pruebas será suficiente.





Prueba de integración (= junte todas las clases y pruebe el paquete)



No use bases de datos en memoria para pruebas





Con una base en memoria, prueba en un entorno diferente donde funcionará su código.



Al usar una base en memoria ( H2 , HSQLDB , Fongo ) para las pruebas, sacrifica su validez y alcance. Estas bases de datos a menudo se comportan de manera diferente y producen resultados diferentes. Tal prueba puede pasar con éxito, pero no garantiza el correcto funcionamiento de la aplicación en producción. Además, puede encontrarse fácilmente en una situación en la que no puede usar o probar algún comportamiento o característica característica de su base, porque no están implementados en la base de datos en memoria o se comportan de manera diferente.



Solución: utilice la misma base de datos que en funcionamiento real. Biblioteca de Wonderful Testcontainers proporciona una API enriquecida para aplicaciones Java que le permite administrar contenedores directamente desde su código de prueba.



Java / JVM



Utilizar -noverify -XX:TieredStopAtLevel=1



Siempre agregue opciones JVM -noverify -XX:TieredStopAtLevel=1a su configuración para ejecutar pruebas. Esto le ahorrará entre 1 y 2 segundos al iniciar la máquina virtual antes de que comiencen las pruebas. Esto es especialmente útil en los primeros días de las pruebas, cuando las ejecuta a menudo desde el IDE.



Tenga en cuenta que desde que Java 13 ha -noverifyquedado obsoleto.



Consejo: agregue estos argumentos a la plantilla de configuración "JUnit" en IntelliJ IDEA para que no tenga que hacer esto cada vez que cree un nuevo proyecto.







Utilice AssertJ



AssertJ es una biblioteca extremadamente poderosa y madura con una API rica y segura, así como una amplia gama de funciones de validación de valor y mensajes de error de prueba informativos. Muchas funciones de validación convenientes liberan al programador de la necesidad de describir lógica compleja en el cuerpo de las pruebas, lo que les permite hacer pruebas concisas. Por ejemplo:



assertThat(actualProduct)
        .isEqualToIgnoringGivenFields(expectedProduct, "id");

assertThat(actualProductList).containsExactly(
        createProductDTO("1", "Smartphone", 250.00),
        createProductDTO("1", "Smartphone", 250.00)
);

assertThat(actualProductList)
        .usingElementComparatorIgnoringFields("id")
        .containsExactly(expectedProduct1, expectedProduct2);

assertThat(actualProductList)
        .extracting(Product::getId)
        .containsExactly("1", "2");

assertThat(actualProductList)
        .anySatisfy(product -> assertThat(product.getDateCreated()).isBetween(instant1, instant2));

assertThat(actualProductList)
        .filteredOn(product -> product.getCategory().equals("Smartphone"))
        .allSatisfy(product -> assertThat(product.isLiked()).isTrue());


Evite usar assertTrue()yassertFalse()



Usar mensajes de error de prueba simples assertTrue()o que assertFalse()conducen a crípticos:



// 
assertTrue(actualProductList.contains(expectedProduct));
assertTrue(actualProductList.size() == 5);
assertTrue(actualProduct instanceof Product);

expected: <true> but was: <false>


En su lugar, utilice llamadas AssertJ, que devuelven mensajes claros e informativos listos para usar.



// 
assertThat(actualProductList).contains(expectedProduct);
assertThat(actualProductList).hasSize(5);
assertThat(actualProduct).isInstanceOf(Product.class);

Expecting:
 <[Product[id=1, name='Samsung Galaxy']]>
to contain:
 <[Product[id=2, name='iPhone']]>
but could not find:
 <[Product[id=2, name='iPhone']]>


Si necesita verificar el valor booleano, haga que el mensaje sea más as()descriptivo con el método AssertJ.



Utilice JUnit5



JUnit5 es una biblioteca excelente para pruebas (unitarias). Está en constante desarrollo y proporciona al programador muchas características útiles, como pruebas parametrizadas, agrupaciones, pruebas condicionales, control del ciclo de vida.



Utilice pruebas parametrizadas



Las pruebas parametrizadas le permiten ejecutar la misma prueba con un conjunto de valores de entrada diferentes. Esto le permite verificar varios casos sin escribir código adicional. En JUnit5 de esto es las excelentes herramientas @ValueSource, @EnumSource, @CsvSourcey @MethodSource.



// 
@ParameterizedTest
@ValueSource(strings = ["§ed2d", "sdf_", "123123", "§_sdf__dfww!"])
public void rejectedInvalidTokens(String invalidToken) {
    client.perform(get("/products").param("token", invalidToken))
            .andExpect(status().is(400))
}

@ParameterizedTest
@EnumSource(WorkflowState::class, mode = EnumSource.Mode.INCLUDE, names = ["FAILED", "SUCCEEDED"])
public void dontProcessWorkflowInCaseOfAFinalState(WorkflowState itemsInitialState) {
    // ...
}


Le recomiendo que aproveche al máximo este truco, ya que le permite probar más casos con un mínimo esfuerzo.



Finalmente, quiero llamar su atención sobre @CsvSourcey @MethodSource, que se puede usar para parametrizaciones más complejas, donde también necesita controlar el resultado: puede pasarlo en uno de los parámetros.



@ParameterizedTest
@CsvSource({
    "1, 1, 2",
    "5, 3, 8",
    "10, -20, -10"
})
public void add(int summand1, int summand2, int expectedSum) {
    assertThat(calculator.add(summand1, summand2)).isEqualTo(expectedSum);
}


@MethodSourceespecialmente eficaz en combinación con un objeto de prueba independiente que contiene todos los parámetros deseados y los resultados esperados. Desafortunadamente, en Java, la descripción de tales estructuras de datos (los llamados POJO) es muy engorrosa. Por lo tanto, daré un ejemplo usando clases de datos de Kotlin.



data class TestData(
    val input: String?,
    val expected: Token?
)

@ParameterizedTest
@MethodSource("validTokenProvider")
fun `parse valid tokens`(data: TestData) {
    assertThat(parse(data.input)).isEqualTo(data.expected)
}

private fun validTokenProvider() = Stream.of(
    TestData(input = "1511443755_2", expected = Token(1511443755, "2")),
    TestData(input = "151175_13521", expected = Token(151175, "13521")),
    TestData(input = "151144375_id", expected = Token(151144375, "id")),
    TestData(input = "15114437599_1", expected = Token(15114437599, "1")),
    TestData(input = null, expected = null)
)


Pruebas grupales



La anotación @Nestedde JUnit5 es útil para agrupar métodos de prueba. Lógicamente, tiene sentido agrupar ciertos tipos de pruebas (como InputIsXY, ErrorCases) o recopilar en su grupo cada método de prueba ( GetDesigny UpdateDesign).



public class DesignControllerTest {
    @Nested
    class GetDesigns {
        @Test
        void allFieldsAreIncluded() {}
        @Test
        void limitParameter() {}
        @Test
        void filterParameter() {}
    }
    @Nested
    class DeleteDesign {
        @Test
        void designIsRemovedFromDb() {}
        @Test
        void return404OnInvalidIdParameter() {}
        @Test
        void return401IfNotAuthorized() {}
    }
}






Nombres de prueba @DisplayNamelegibles con o comillas inversas en Kotlin



En Java, puede utilizar la anotación @DisplayNamepara dar a sus pruebas nombres más legibles.



public class DisplayNameTest {
    @Test
    @DisplayName("Design is removed from database")
    void designIsRemoved() {}
    @Test
    @DisplayName("Return 404 in case of an invalid parameter")
    void return404() {}
    @Test
    @DisplayName("Return 401 if the request is not authorized")
    void return401() {}
}






En Kotlin, puede usar nombres de funciones con espacios dentro de ellos encerrándolos entre comillas simples con acento inverso. De esta manera, obtiene legibilidad de los resultados sin redundancia de código.



@Test
fun `design is removed from db`() {}


Simular servicios externos



Para probar los clientes HTTP, necesitamos simular los servicios a los que acceden. A menudo uso MockWebServer de OkHttp para este propósito . Las alternativas son WireMock o Mockserver de Testcontainers .



MockWebServer serviceMock = new MockWebServer();
serviceMock.start();
HttpUrl baseUrl = serviceMock.url("/v1/");
ProductClient client = new ProductClient(baseUrl.host(), baseUrl.port());
serviceMock.enqueue(new MockResponse()
        .addHeader("Content-Type", "application/json")
        .setBody("{\"name\": \"Smartphone\"}"));

ProductDTO productDTO = client.retrieveProduct("1");

assertThat(productDTO.getName()).isEqualTo("Smartphone");


Utilice Awaitility para probar código asincrónico



Awaitility es una biblioteca para probar código asincrónico. Puede especificar cuántas veces volver a intentar comprobar el resultado antes de declarar que una prueba no se ha realizado correctamente.



private static final ConditionFactory WAIT = await()
        .atMost(Duration.ofSeconds(6))
        .pollInterval(Duration.ofSeconds(1))
        .pollDelay(Duration.ofSeconds(1));

@Test
public void waitAndPoll(){
    triggerAsyncEvent();
    WAIT.untilAsserted(() -> {
        assertThat(findInDatabase(1).getState()).isEqualTo(State.SUCCESS);
    });
}


No es necesario resolver las dependencias de DI (Spring)



El marco DI tarda unos segundos en inicializarse antes de que puedan comenzar las pruebas. Esto ralentiza el ciclo de retroalimentación, especialmente en las primeras etapas de desarrollo.



Por lo tanto, trato de no usar DI en las pruebas de integración, sino crear los objetos necesarios manualmente y "atarlos". Si está utilizando inyección de constructor, esta es la más fácil. Normalmente, en sus pruebas, valida la lógica empresarial y no necesita DI para eso.



Además, desde la versión 2.2, Spring Boot admite la inicialización diferida de beans, lo que acelera significativamente las pruebas con DI.



Tu código debe ser comprobable



No use acceso estático. Nunca



El acceso estático es un anti-patrón. En primer lugar, oculta las dependencias y los efectos secundarios, lo que hace que todo el código sea difícil de leer y propenso a errores sutiles. En segundo lugar, el acceso estático obstaculiza las pruebas. Ya no puede reemplazar objetos, pero en las pruebas debe usar simulacros u objetos reales con una configuración diferente (por ejemplo, un objeto DAO que apunta a una base de datos de prueba).



En lugar de acceder al código de forma estática, colóquelo en un método no estático, cree una instancia de la clase y pase el objeto resultante al constructor.



// 
public class ProductController {
    public List<ProductDTO> getProducts() {
        List<ProductEntity> products = ProductDAO.getProducts();
        return mapToDTOs(products);
    }
}



// 
public class ProductController {
    private ProductDAO dao;
    public ProductController(ProductDAO dao) {
        this.dao = dao;
    }
    public List<ProductDTO> getProducts() {
        List<ProductEntity> products = dao.getProducts();
        return mapToDTOs(products);
    }
}


Afortunadamente, los marcos de DI como Spring proporcionan herramientas que hacen que el acceso estático sea innecesario al crear y vincular objetos automáticamente sin nuestra participación.



Parametrizar



Todas las partes relevantes de la clase deben ser configurables desde el lado de la prueba. Esta configuración se puede pasar al constructor de la clase.



Imagine, por ejemplo, que su DAO tiene un límite fijo de 1000 objetos por solicitud. Para verificar este límite, deberá agregar 1001 objetos a la base de datos de prueba antes de realizar la prueba. Usando el argumento del constructor, puede personalizar este valor: en producción, deje 1000, en prueba, reduzca a 2. Por lo tanto, para verificar el trabajo del límite, solo necesitará agregar solo 3 registros a la base de datos de prueba.



Usar inyección de constructor



La inyección de campo es mala y conduce a una mala capacidad de prueba del código. Necesita inicializar DI antes de las pruebas o hacer alguna magia de reflexión extraña. Por lo tanto, es preferible utilizar la inyección del constructor para controlar fácilmente los objetos dependientes durante la prueba.



En Java, debe escribir un pequeño código adicional:



// 
public class ProductController {

    private ProductDAO dao;
    private TaxClient client;

    public ProductController(ProductDAO dao, TaxClient client) {
        this.dao = dao;
        this.client = client;
    }
}


En Kotlin, lo mismo está escrito de manera mucho más concisa:



// 
class ProductController(
    private val dao: ProductDAO,
    private val client: TaxClient
){
}


No use Instant.now() onew Date()



No es necesario que obtenga la hora actual mediante llamadas Instant.now()o new Date()en el código de producción si desea probar este comportamiento.



// 
public class ProductDAO {
    public void updateDateModified(String productId) {
        Instant now = Instant.now(); // !
        Update update = Update()
            .set("dateModified", now);
        Query query = Query()
            .addCriteria(where("_id").eq(productId));
        return mongoTemplate.updateOne(query, update, ProductEntity.class);
    }
}


El problema es que la prueba no puede controlar el tiempo necesario. No podrás comparar el resultado obtenido con un valor específico, porque es diferente todo el tiempo. En su lugar, use una clase Clockde Java.



// 
public class ProductDAO {
    private Clock clock; 

    public ProductDAO(Clock clock) {
        this.clock = clock;
    }

    public void updateProductState(String productId, State state) {
        Instant now = clock.instant();
        // ...
    }
}


En esta prueba, puede crear un objeto simulado para Clock, pasarlo ProductDAOy configurar el objeto simulado para que regrese al mismo tiempo. Después de las llamadas, updateProductState()podremos verificar que el valor que especificamos haya entrado en la base de datos.



Separe la ejecución asíncrona de la lógica real



Probar código asincrónico es complicado. Bibliotecas como Awaitility son de gran ayuda, pero el proceso aún es complicado y podemos terminar con una prueba intermitente. Tiene sentido separar la lógica empresarial (normalmente sincrónica) y el código de infraestructura asincrónico, si es posible.



Por ejemplo, al colocar la lógica empresarial en ProductController, podemos probarla fácilmente de forma sincrónica. Toda la lógica asíncrona y paralela permanecerá en ProductScheduler, que se puede probar de forma aislada.



// 
public class ProductScheduler {

    private ProductController controller;

    @Scheduled
    public void start() {
        CompletableFuture<String> usFuture = CompletableFuture.supplyAsync(() -> controller.doBusinessLogic(Locale.US));
        CompletableFuture<String> germanyFuture = CompletableFuture.supplyAsync(() -> controller.doBusinessLogic(Locale.GERMANY));
        String usResult = usFuture.get();
        String germanyResult = germanyFuture.get();
    }
}


Kotlin



Mi artículo Mejores prácticas para pruebas unitarias en Kotlin contiene muchas técnicas de pruebas unitarias específicas de Kotlin. (Nota de traducción: escriba en los comentarios si está interesado en la traducción al ruso de este artículo).



All Articles