En el mundo del desarrollo de software, especialmente bajo la filosofía del Test Driven Development (TDD), la calidad y efectividad de las pruebas son fundamentales. Recientemente, vi un video muy interesante de Emily Bache que respondía a Kent Beck sobre los beneficios del Approval Testing. Me parecieron interesantes los recursos que ella puso a disposición, siendo uno de ellos un artículo que discute la anatomía que debería tener una buena prueba. El objetivo de este artículo es profundizar sobre el tema de “la prueba ideal”. Obtener una prueba ideal es realmente difícil. Piensa por unos segundos en una y ahora continuemos con el artículo.
Kent Beck propone una serie de puntos clave para lograr esta prueba ideal. Vamos a repasar estos puntos, considerando que las pruebas deberían ser…
Aisladas Las pruebas aisladas garantizan resultados consistentes, independientemente del orden en que se ejecuten. Esto es crucial porque elimina las dependencias entre pruebas, asegurando que cada una evalúe un aspecto específico del código sin interferencia de otras.
Componibles La capacidad de ejecutar cualquier número de pruebas y obtener los mismos resultados refuerza su fiabilidad. La componibilidad es vital para manejar grandes suites de pruebas, permitiendo a los desarrolladores ejecutar subconjuntos específicos sin perder confianza en la integridad de los resultados.
Rápidas La velocidad es esencial para mantener el ciclo de desarrollo ágil. Las pruebas rápidas permiten a los desarrolladores obtener retroalimentación inmediata, facilitando la iteración rápida y la detección temprana de errores.
Inspiradoras Una prueba que inspira confianza es aquella que, al pasar, asegura al desarrollador que el código cumple con los requisitos y es de alta calidad. Esto es fundamental para la moral del equipo y para fomentar una cultura de calidad en el desarrollo.
Fáciles de Escribir La facilidad para escribir pruebas significa que su coste de desarrollo es proporcional al beneficio que aportan. Esto fomenta una mayor cobertura de pruebas, ya que los desarrolladores están más dispuestos a escribir pruebas para nuevo código o para cubrir casos de uso adicionales.
Legibles Una prueba legible es fácil de entender y comunica claramente su propósito. Esto es crucial para el mantenimiento a largo plazo, ya que permite a otros desarrolladores entender y modificar las pruebas cuando el código subyacente cambia.
Comportamentales Las pruebas comportamentales se centran en el funcionamiento del código, no en su implementación específica. Esto significa que son sensibles a cambios que afectan el comportamiento del código, pero son resistentes a cambios en la implementación que no afectan la funcionalidad.
Insensibles a la Estructura Las pruebas no deben fallar si la estructura interna del código cambia, siempre y cuando el comportamiento externo se mantenga. Esta propiedad permite refactorizaciones y mejoras en el código sin la necesidad de reescribir las pruebas.
Automatizadas La automatización es clave en las pruebas modernas. Las pruebas que se ejecutan automáticamente con cada build o integración reducen la posibilidad de errores humanos y aseguran que se ejecuten de manera consistente.
Específicas Cuando una prueba falla, debe ser claro qué está mal. Las pruebas específicas facilitan la depuración al señalar directamente hacia el problema, reduciendo el tiempo y esfuerzo necesario para encontrar y corregir errores.
Determinísticas Una prueba determinística ofrece los mismos resultados bajo las mismas condiciones. Esta consistencia es crucial para evitar los llamados “flaky tests”, que pueden fallar de manera aleatoria y minar la confianza en la suite de pruebas.
Predictivas La capacidad de predecir es crucial. Si todas las pruebas pasan, debería haber una alta confianza en que el código funcionará correctamente en producción. Esto reduce el riesgo de introducir errores y mejora la confianza en el proceso de despliegue.
Seguramente ya conocías alguno, pero ¿cuántos de ellos aplicas en el día a día? Es difícil pensar cuando todo es para ayer. Por eso, voy a intentar ilustrar muchos problemas que se pueden observar a diario. Por suerte, cada vez hay más tests en los proyectos, pero eso no significa que sean necesariamente buenos. Estos suelen necesitar diseño de software para poder ser mantenibles y que no te den ganas de borrarlos cada vez que fallen.
Voy a usar algunos ejemplos del post de Anatomy of a Good Test, pero los explicaré de manera algo diferente. Aquí tenemos un ejemplo que suele resaltarse cuando el diseño de los componentes es malo o la granularidad del test es demasiado alta.
public class ShoppingCartTest {
@Test
public void add() throws InsufficientUnitsInStockException {
var stock = mock(Stock.class);
var article1 = mock(Article.class);
when(stock.availableUnits(article1)).thenReturn(2);
var currentUser = mock(CurrentUser.class);
when(currentUser.customerStatus()).thenReturn(CustomerStatus.GOLD);
var priceCalculator = mock(PriceCalculator.class);
when(priceCalculator.calculatePrice(article1, CustomerStatus.GOLD))
.thenReturn(BigDecimal.valueOf(9.95));
var shippingCalculator = mock(ShippingCalculator.class);
when(shippingCalculator.calculateShipping(BigDecimal.valueOf(9.95)))
.thenReturn(BigDecimal.valueOf(3.5));
var shoppingCart = new ShoppingCart(stock, currentUser,
priceCalculator, shippingCalculator);
shoppingCart.add(article1, 1);
var article2 = mock(Article.class);
when(stock.availableUnits(article2)).thenReturn(3);
when(priceCalculator.calculatePrice(article2, CustomerStatus.GOLD))
.thenReturn(BigDecimal.valueOf(7.5));
when(shippingCalculator.calculateShipping(BigDecimal.valueOf(32.45)))
.thenReturn(BigDecimal.valueOf(3.5));
shoppingCart.add(article2, 3);
verify(stock).availableUnits(article1);
verify(stock).availableUnits(article2);
verify(currentUser, times(2)).customerStatus();
verify(priceCalculator).calculatePrice(article1, CustomerStatus.GOLD);
verify(priceCalculator).calculatePrice(article2, CustomerStatus.GOLD);
verify(shippingCalculator).calculateShipping(BigDecimal.valueOf(9.95));
verify(shippingCalculator).calculateShipping(BigDecimal.valueOf(32.45));
assertEquals(2, shoppingCart.items().size());
assertEquals(1, shoppingCart.items().get(0).quantity());
assertEquals(BigDecimal.valueOf(9.95),
shoppingCart.items().get(0).amount());
assertEquals(3, shoppingCart.items().get(1).quantity());
assertEquals(BigDecimal.valueOf(22.50),
shoppingCart.items().get(1).amount());
assertEquals(BigDecimal.valueOf(32.45), shoppingCart.subtotalAmount());
assertEquals(BigDecimal.valueOf(3.5), shoppingCart.shippingAmount());
assertEquals(BigDecimal.valueOf(35.95), shoppingCart.totalAmount());
}
}
¿Qué opinas de este test? ¿Te recuerda a alguno que hayas visto? El nombre nos indica que se añade algo, en este caso, parecen ser artículos, de los cuales tenemos multitud de comprobaciones para confirmar que efectivamente es así. Por otro lado, cuidado, porque podría devolver una excepción; un código que solo habla a nivel de dominio. Finalmente, tenemos assertEquals(BigDecimal.valueOf(35.95), shoppingCart.totalAmount())
, lo cual me indica que, muy probablemente, la intención de este test no era solo añadir sino también calcular el total de todos los artículos. Por tanto, este test está evaluando dos comportamientos. Esto suele ocurrir por vagancia, pensando ¿para qué voy a escribir otro test si me vale con agregar una aserción por aquí?
De acuerdo con el artículo original, se han ido mejorando los tests. Sin embargo, es importante entender que cada refactorización genera una cierta indirección en el código, la cual en ocasiones puede resultar costosa. Pasaremos directamente al último ejemplo del post original para comentar qué aspectos se han pasado por alto.
@Test
public void should_calculate_subtotal_and_total_amount_ \
if_two_items_with_different_prices_and_quantities_are_added() throws Exception {
// given
Article article1 = givenAnArticle()
.withPrice(9.95)
.availableInStock()
.andGetIt();
Article article2 = givenAnArticle()
.withPrice(7.5)
.availableInStock()
.andGetIt();
givenShippingAmount(3.5);
// when
shoppingCart.add(article1, 1);
shoppingCart.add(article2, 3);
// then
assertThat(shoppingCart).containsNumberOfItems(2);
assertThat(shoppingCart).containsItemFor(article1)
.withQuantity(1).withAmount(9.95);
assertThat(shoppingCart).containsItemFor(article2)
.withQuantity(3).withAmount(22.50);
assertThat(shoppingCart).hasSubtotalAmount(32.45);
assertThat(shoppingCart).hasShippingAmount(3.5);
assertThat(shoppingCart).hasTotalAmount(35.95);
}
@BeforeEach
private void setupCurrentUser() {
when(currentUser.customerStatus()).thenReturn(CustomerStatus.REGULAR);
}
private ArticleMocker givenAnArticle() {
return new ArticleMocker(stock, priceCalculator);
}
private void givenShippingAmount(double amount) {
when(shippingCalculator.calculateShipping(any(BigDecimal.class))).thenReturn(BigDecimal.valueOf(amount));
}
Aquí, como opinión personal, lo primero que me chirría es un and
en el nombre de un test should_calculate_subtotal_and_total_amount
. Aquí claramente no estamos testeando un comportamiento, por lo que sugiero separar en 2 tests o más si hiciera falta. Además de que el nombre no refleja un comportamiento sino un caso de uso que ya la información del given nos indica.
should_calculate_subtotal_amount
should_calculate_total_amount
Es una buena práctica generar un builder que desacople la API de nuestra clase Article
de los tests.
Article article1 = givenAnArticle()
.withPrice(9.95)
.availableInStock()
.andGetIt();
Los métodos privados nos ayudan a mantener la simetría en el código, ocultando detalles que no son realmente relevantes en los tests. Si en el futuro el tipo o estado del cliente influye en el precio, recuerda reflejarlo en los tests debidamente, evitando valores por defecto.
@BeforeEach
private void setupCurrentUser() {
when(currentUser.customerStatus()).thenReturn(CustomerStatus.REGULAR);
}
Finalmente, quiero decir que me encantan las comprobaciones que se extienden de AssertJ, aumentando la legibilidad y comprensión de lo que estamos mirando. Aunque si nuestro test mira el subtotal y el total, nuestras aserciones únicamente deberían mirar lo que dice nuestro test. De esta manera, vuelves a tu test determinista sin salirse del contexto.
// Remove other assertions
assertThat(shoppingCart).hasShippingAmount(3.5);
assertThat(shoppingCart).hasTotalAmount(35.95);
Parece que no todo lo que reluce es oro, por lo que os recomiendo visitar los recursos que he incluido en las referencias más abajo. Es crucial que siempre busquen una manera más eficiente de realizar las pruebas. En este caso, hemos explorado tres prácticas, que pueden ayudar a generar un impacto significativo en la búsqueda de la prueba ideal. Estas prácticas son:
¿Quieres más? te invitamos a suscribirte a nuestro boletín para avisarte cada vez que recopilemos contenido de calidad que compartir.
Si disfrutas leyendo nuestro blog, ¿imaginas lo divertido que sería trabajar con nosotros? ¿te gustaría?
Pero espera 🖐 que tenemos un conflicto interno. A nosotros las newsletter nos parecen 💩👎👹 Por eso hemos creado la LEAN LISTA, la primera lista zen, disfrutona y que suena a rock y reggaeton del sector de la programación. Todos hemos recibido newsletters por encima de nuestras posibilidades 😅 por eso este es el compromiso de la Lean Lista