Buenas prácticas y recomendaciones para tus pruebas unitarias con Spring Boot

Miguel Manjarres
Pragma
Published in
8 min readFeb 27, 2024
Escoger un estándar es fundamental a la hora de escribir pruebas unitarias ya que permitirá una mayor cohesión y un mayor entendimiento del funcionamiento del proyecto por parte de el equipo de desarrollo

Conforme vamos agregando nuevas funcionalidades a nuestras aplicaciones la necesidad de realizar pruebas que verifiquen y certifiquen su correcto funcionamiento.

Hay distintos tipos de pruebas que podemos hacer, en distintos momentos del ciclo de vida del software. Del que hablaremos en el presente artículo son de las pruebas unitarias; como su nombre lo indica estas pruebas están destinadas a probar de forma programática una funcionalidad específica de nuestra aplicación. Idealmente esta funcionalidad es atómica, es decir, no es un flujo completo sino, más bien, un paso dentro de un flujo más grande (de ahí la palabra “unitaria” o “unidad”).

Escribir pruebas unitarias no es dificil siempre y cuando conozcamos el flujo que queremos probar, sin embargo y con frecuencia olvidamos seguir algún tipo de estándar al momento de realizarlas.

¿Porqué es importante un estándar? Es de vital importancia que todas las personas que trabajen en el proyecto u aplicación (y aún las personas que en un futuro vendrán) sean capaces de entender el propósito de la prueba con solo leerla o echarle un vistazo. Ten en cuenta lo siguiente:

Siempre debemos tratar de ser SOLIDARIOS con el próximo desarrollador que ocupará nuestro puesto

Lo anterior significa que siempre debemos procurar que nuestro código sea auto-explicable, esto es, que sea entendible sin necesidad de acudir a una fuente de información externa, de modo que si algún día abandonamos el proyecto, el desarrollador que mire nuestro código sepa, por lo menos, que se supone que hace.

En Pragma nos esforzamos por siempre desarrollar código de calidad que sea legible y mantenible; es por eso que en esta oportunidad y en pro de #SaberMasParaResolverMejor te compartimos algunas de las mejores prácticas y recomendaciones a la hora de escribir tus pruebas unitarias con Java y Spring Boot.

Sin nada más que agregar, ¡comencemos!

1. Escoge una buena convención de nombres

Procura que los nombres que le des a tus pruebas sigan alguna clase de estándar. Esto traerá consigo los siguientes beneficios:

  • Los nombres de las pruebas serán coherentes sin importar la funcionalidad que se esté probando
  • Todos los miembros del equipo nombrarán sus pruebas de la misma forma, nuevos o antiguos
  • Elegir un estándar facilita el entendimiento del propósito de la prueba ya que podemos inferir el propósito por la estructura del nombre

Hay muchos estándares que tú o tú equipo podrían definir, al final del día el estándar en si no es lo que importa sino que todos los miembros del equipo estén de acuerdo. Sin embargo, te recomendamos la siguiente estructura para comenzar.

Si vas a probar un flujo de éxito, es decir, en el que no contemples errores o excepciones entonces puedes aplicar esta nomenclatura:

When_UseCase_Expect_SuccessfulResult

Donde “UseCase” es el escenario que esperas probar y “SuccessfulResult” es el resultado (exitoso) que esperas obtener.

Por ejemplo, si estamos verificando que la inserción de un usuario a nuestra base de datos se haga de forma exitosa, nuestra prueba podría llamarse:

When_UserInformationIsCorrect_Expect_UserToBeSavedSuccessfully

Por el contrario, si vas probar un flujo de error, es decir, verificar si el sistema está lanzando las excepciones cuando corresponde, entonces puedes aplicar esta nomenclatura:

Expect_Error_When_ErrorScenario

Donde “Error” es la excepción que esperamos que el sistema arroje y “ErrorScenario” es el escenario que queremos probar.

Por ejemplo, si estamos verificando que el sistema arroje la excepción adecuada si valida que un usuario es menor de edad, nuestra prueba podría llamarse:

Expect_UserAgeException_When_UserIsUnderage

2. Estructura tus pruebas

Procura que el código dentro de tus pruebas tenga una estructura clara. Debe de ser posible identificar, por lo menos, las siguientes secciones en orden: los datos de prueba, las operaciones y las validaciones. Hoy en día existen muchos estándares o patrones bien documentados que puedes usar para estructurar tus pruebas de modo que respeten estas tres secciones y puedes elegir aquel con el que te sientas más cómodo.

Nuestra recomendación, para el caso de las pruebas unitarias tradicionales, es el patrón AAA, que consta exactamente de las tres secciones que mencionamos hace un momento y que se describen a continuación:

  • A — Arrange: En esta sección coloca tus variables, instancias y todo aquello que sirva de insumo para ejecutar tus pruebas.
  • A — Act: En esta sección invoca al método que quieres probar. (¿Puede ser más de un método? Si. Pero recuerda, buscamos probar funcionalidades puntuales, no flujos)
  • A — Assert: En esta sección verifica si los resultados después de haber invocado al método fueron los esperados.

Por ejemplo, si estuviésemos validando que nuestro método que suma dos números esté funcionando correctamente, podríamos hacerlo de la siguiente forma:

@Test
public void When_AddingTwoNumbers_Expect_SumToBeCorrect() {
// Arrange
int numberA = 1;
int numberB = 1;
int expectedResult = 2;

// Act
int actualResult = addingService.sum(numberA, numberB);

// Assert
assertThat(actualResult).isEqualTo(expectedResult);
}

3. Evita crear instancias dentro de tus pruebas

Acabamos de ver hace un momento que dentro de la sección Arrange de nuestras pruebas es donde inicializamos los datos que vamos a requerir, luego entonces, ¿podemos crear/instanciar objetos aquí también?

La recomendación es que no, dado que limita la mantenibilidad de nuestras pruebas en el tiempo. Imagina que dentro de nuestro proyecto tenemos una cierta clase User que actualmente consta de dos (2) campos obligatorios en su constructor. Ahora imagina que el día de mañana agregamos un nuevo campo, esto provocará errores en todos los lugares donde hayamos utilizado el constructor de la clase incluídas nuestras pruebas, errores que deberemos arreglar uno por uno, como se ve en el siguiente ejemplo:

// Evita hacer esto ❌
@Test
public void When_UserInformationIsCorrect_Expect_UserToBeSavedCorrectly() {
// Arrange
String username = "Test";
User user = new User();
user.setUsername(username);

// Act
User savedUser = userService.save(user);

// Assert
assertThat(savedUser).isNotNull();
}

Para evitar esto, recomendamos el uso de algún patrón de diseño que nos permita la creación de objetos mediante parámetros, como por ejemplo, el patrón Factory.

// Aplicando el patrón factory ✅
@Test
public void When_UserInformationIsCorrect_Expect_UserToBeSavedCorrectly() {
// Arrange
User user = UserFactory.createUser();

// Act
User savedUser = userService.save(user);

// Assert
assertThat(savedUser).isNotNull();
}

4. Evita crear instancias manuales de los servicios

El principio de inversión de dependencias también aplica a la hora de escribir nuestras pruebas. Debemos procurar no crear manualmente las instancias concretas de nuestros servicios o repositorios y en su lugar hacer uso de las interfaces dejando que Spring Boot se encargue de cargar las implementaciones correspondientes.

Hay casos en los que si o si se hace necesario crear las instancias manuales. ¡Pero que estos casos sean la excepción y no la regla!

Como recomendación adicional, si queremos tener un mayor control de las invocaciones que hagamos sobre nuestras instancias podemos hacer uso de las anotaciones de Mockito.

Imagina que tenemos el siguiente código:

// Evita hacer esto ❌

private UserService userService;

@Test
public void When_UserInformationIsCorrect_Expect_UserToBeSavedCorrectly() {

// Arrange
User user = UserFactory.createUser();
this.userService = new UserUseCase();

// Act
User savedUser = userService.save(user);

// Assert
assertThat(savedUser).isNotNull();
}

Podemos reescrbirlo haciendo uso de la anotación @SpyBean de Mockito de la siguiente forma:

@SpyBean es una anotación que nos permite instanciar un mock parcial de un bean a través de Mockito que posea las “implementaciones reales” de los métodos dentro de nuestra clase

// Usando Mockito ✅

@SpyBean
private UserService userService;

@Test
public void When_UserInformationIsCorrect_Expect_UserToBeSavedCorrectly() {

// Arrange
User user = UserFactory.createUser();

// Act
User savedUser = userService.save(user);

// Assert
assertThat(savedUser).isNotNull();
}

5. Evita hacer inserciones manuales a la base de datos

Hay escenarios de prueba que requieren que ya existan datos guardados dentro de la base de datos que estemos usando, por ejemplo, si necesitamos validar el correcto guardado de un cierto producto al cual necesitamos asignarle una categoría que provenga de un catálogo de categorías previamente creadas.

Para estos casos debemos evitar insertar la data directamente en nuestras pruebas y en su lugar hacer uso de la anotación @Sql de Spring Data.

La anotación @Sql solo es compatible para bases de datos relacionales. Otras implementaciones para bases de datos no relacionales como Spring MongoDB disponen de otras formas de llenar la base de datos

// Evita hacer esto ❌

@SpyBean
private ProductCategoryRepsository productCategoryRepository;

@SpyBean
private ProductService productService;

@Test
public void When_ProductInformationIsCorrect_Expect_ProductToBeSavedCorrectly() {

// Arrange
Integer categoryId = 1;
Product product = ProductFactory.createProductWithCategoryId(categoryId);

// Act
ProductCategory category = new ProductCategory();
category.setId(categoryId);
this.productCategoryRepository.save(category);
Product savedProduct = productService.save(product);

// Assert
assertThat(savedProduct).isNotNull();
}

La idea es que tengamos uno o más archivos SQL con las instrucciones para insertar la información necesaria que necesitemos en nuestras pruebas. Luego podemos referenciar esos archivos en cada uno de los métodos de prueba que necesiten de esos datos con la anotación antes mencionada. Lo que sucederá es que al momento de que inicien nuestras pruebas, el contexto de Spring ejecutará los archivos SQL que le hayamos indicado, y nuestra base de datos estará llena para esa prueba en particular.

También es posible indicarle a Spring que queremos que los datos se apliquen para el contexto de la base de datos de todas las pruebas en una misma clase si colocamos la anotación a nivel de clase y no de un sólo método

// Usando la anotación @Sql ✅

@SpyBean
private ProductService productService;

@Test
@Sql("/sql/products/insert_categories_test_data.sql")
public void When_ProductInformationIsCorrect_Expect_ProductToBeSavedCorrectly() {

// Arrange
Integer categoryId = 1;
Product product = ProductFactory.createProductWithCategoryId(categoryId);

// Act
Product savedProduct = productService.save(product);

// Assert
assertThat(savedProduct).isNotNull();
}

6. Crea abstracciones para las configuraciones de tus pruebas (si aplican)

Conforme vayamos agregando nuevas funcionalidades a nuestra aplicación, el número de clases para pruebas de flujos concretos también crecerá. Cada una de estas clases poseerá una cierta configuración en forma de anotaciones.

El problema (u oportinidad de mejora) surge cuando empezamos a notar que algunas clases comparten una misma configuración por la similitud que existe entre ellas. Por ejemplo, dos clases que estén probando dos flujos distintos relacionados al manejo de usuarios podrían necesitar de la misma información en base de datos que creamos con cierto archivo SQL (y que usamos con la anotación @Sql que vimos en la sección anterior).

// Evita hacer esto ❌

// UserUseCaseTest.java
@Transactional
@SpringBootTest
@ActiveProfiles("test")
@RunWith(SpringRunner.class)
@Sql("/sql/users/insert_user_test_data.sql")
public class UserUseCaseTest {
// Tests...
}

// UserPaginatedSearchUseCaseTest.java
@Transactional
@SpringBootTest
@ActiveProfiles("test")
@RunWith(SpringRunner.class)
@Sql("/sql/users/insert_user_test_data.sql")
public class UserPaginatedSearchUseCaseTest {
// Tests...
}

Para evitar copiar y pegar estas anotaciones en cada clase, lo que podríamos hacer es agruparlas en una clase abstracta base, de esa forma ambas clases (en el ejemplo) podrán heredar de ella y, por extensión, heredar todas sus anotaciones, esto es, heredan esa misma configuración.

// Usando una clase abstracta base ✅

// UserBaseSpringTest.java
@Transactional
@SpringBootTest
@ActiveProfiles("test")
@RunWith(SpringRunner.class)
@Sql("/sql/users/insert_user_test_data.sql")
public abstract class UserBaseSpringTest {
}

// UserUseCaseTest.java
public class UserUseCaseTest extends UserBaseSpringTest {
// Tests...
}

// UserPaginatedSearchUseCaseTest.java
public class UserPaginatedSearchUseCaseTest extends UserBaseSpringTest {
// Tests...
}

Conclusión

Hagamos un repaso de todo lo que hicimos:

  • Entendimos porqué es necesario utilizar estándares de codificación en nuestras pruebas unitarias y la necesidad de que nuestro código sea legible y entendible por cualquier miembro del equipo
  • Revisamos algunos de los estándares que hay para estructuras nuestras pruebas, como el patrón AAA y la nomenclatura de nombres según el flujo
  • Revisamos algunas recomendaciones y herramientas que podemos usar a la hora de escribir pruebas con Spring Boot haciendo referencia a escenearios concretos

¡Muchas gracias por llegar hasta aquí! Espero que esta guía haya sido de tu agrado. Si tienes alguna pregunta o tienes alguna otra recomendación que no esté en esta lista por favor no dudes en dejarla en los comentarios.

¡Hasta pronto! 👋

--

--

Miguel Manjarres
Pragma
Writer for

Ingeniero de Software con experiencia en desarrollo de aplicaciones web y de escritorio