Ir al contenido

Pruebas de lógica de negocio

Hasta ahora probó el API completo con Postman: arma una petición HTTP, la envía y observa la respuesta. Esa prueba pasa por el controller, por la conversión entre DTO y entidad, y llega al Service casi de paso, como una parada más en el camino hacia la base de datos. Sirve para comprobar que el contrato HTTP funciona, pero no está pensada para forzar cada regla de negocio: hay reglas que son incómodas, o directamente imposibles, de provocar armando solo el cuerpo de una petición.

Probar la lógica de negocio es otra cosa: es invocar el Service directamente, sin pasar por HTTP ni por el controller, para verificar que cada regla que vio en Lógica de negocio de verdad consulta, evalúa, lanza la excepción correcta y no persiste nada cuando la condición falla. El service sigue necesitando una base de datos detrás, porque consulta y persiste, pero es una base de pruebas, aislada y descartable, nunca la de producción.

La estrategia: un caso por cada camino de cada regla

Sección titulada «La estrategia: un caso por cada camino de cada regla»

Recuerde el patrón que sigue toda regla de negocio: consultar, evaluar, lanzar una excepción o persistir. Probarlo bien significa recorrer los dos caminos de ese patrón, no solo el que funciona:

  • Un caso positivo: datos que cumplen la regla, la condición pasa y la entidad queda persistida.
  • Un caso negativo por regla: datos que la violan, el service lanza la excepción esperada y nada se persiste.

Esa es la cobertura mínima. Si un método valida tres reglas, necesita al menos cuatro pruebas: un positivo y tres negativos. Menos que eso deja alguna regla sin ninguna prueba que falle si, por accidente, alguien la borra del código más adelante.

Para escribir esas pruebas sin depender de una base de datos externa ni de un servidor completo, se usa una porción de Spring pensada solo para JPA (@DataJpaTest) y una librería que genera los datos de prueba sin que usted tenga que armarlos a mano (Instancio). El resto de esta sección explica, paso a paso, cómo se arma esa prueba.

  1. Levantar el contexto de prueba con @DataJpaTest e importar el Service bajo prueba.
  2. En @BeforeEach, limpiar la base de pruebas e insertar datos frescos con TestEntityManager e Instancio.
  3. Escribir un caso positivo: datos válidos, invocar el método del service y verificar tanto el resultado como lo que quedó persistido.
  4. Escribir un caso negativo por cada regla: datos que la violan y assertThrows con la excepción esperada.
  5. Repetir el ciclo para cada método del service.
@DataJpaTest(properties = "spring.sql.init.mode=never")
@Transactional
@Import(CompanyService.class)
public class CompanyServiceTest {
@Autowired
private CompanyService companyService;
@Autowired
private TestEntityManager entityManager;
private List<CompanyEntity> companyList = new ArrayList<>();
}
  • @DataJpaTest carga la configuración necesaria para JPA, sin servidor web.
  • properties = "spring.sql.init.mode=never" evita que se ejecute el data.sql del proyecto en la base de las pruebas (ver más abajo).
  • @Transactional ejecuta cada prueba en una transacción que se revierte al terminar.
  • @Import(CompanyService.class) incorpora al contexto el servicio bajo prueba.
  • @Autowired inyecta el servicio y el TestEntityManager.

Desde Spring Boot 4 las utilidades de prueba de JPA viven en su propio módulo. Hay que agregarlo al pom.xml, junto con la librería que genera los datos (Instancio, ver más abajo), ambos con scope test:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.instancio</groupId>
<artifactId>instancio-junit</artifactId>
<version>5.4.1</version>
<scope>test</scope>
</dependency>

Los imports también cambiaron de paquete:

import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;

Si el proyecto tiene un data.sql que carga datos de ejemplo, @DataJpaTest lo ejecuta sobre su base de pruebas. Un findAll en una prueba devolvería esos registros además de los que insertas tú, y los conteos dejarían de coincidir. Por eso la anotación lleva properties = "spring.sql.init.mode=never": las pruebas parten de una base vacía y controlada.

Permite preparar y verificar la base de datos de pruebas sin pasar por el servicio que se evalúa:

  • persist(entity) inserta datos.
  • find(Class, id) busca una entidad.
  • createQuery(...) ejecuta consultas.
  • la limpieza entre pruebas garantiza aislamiento.

El servicio usa su repository; la prueba usa TestEntityManager directamente para que un error en el servicio no contamine la preparación de datos.

@BeforeEach ejecuta setUp() antes de cada test. JUnit no garantiza un orden, por lo que cada prueba debe iniciar con un estado conocido.

  1. @BeforeEach: limpiar la base e insertar datos frescos.
  2. @Test: ejecutar el escenario con esos datos.
  3. @AfterEach, si existe: ejecutar tareas posteriores. Normalmente el rollback de @Transactional basta.
  4. Repetir el ciclo para cada método de prueba.

Explora el ciclo y los escenarios de una prueba

Compara la preparación común con un caso feliz y una regla violada.

@BeforeEachPaso 1 de 4
Limpiar datos

clearData elimina el estado anterior mediante una consulta JPQL.

delete from CompanyEntity
@BeforeEach
void setUp() {
clearData();
insertData();
}
private void clearData() {
entityManager.getEntityManager()
.createQuery("delete from CompanyEntity")
.executeUpdate();
}
private void insertData() {
for (int i = 0; i < 3; i++) {
CompanyEntity entity = crearCompany();
entityManager.persist(entity);
companyList.add(entity);
}
}
private CompanyEntity crearCompany() {
return Instancio.of(CompanyEntity.class)
.ignore(field(CompanyEntity::getId))
.ignore(field(CompanyEntity::getDepartments))
.create();
}

La consulta de limpieza es JPQL, no SQL nativo. Si hay cinco métodos @Test, setUp() se ejecuta cinco veces.

Instancio genera objetos Java con datos aleatorios para reducir la preparación manual. Esto independiza la creación de datos: la prueba no depende de los constructores ni de la lista completa de atributos de la entidad, y si la entidad gana un atributo nuevo, las pruebas no se tocan.

import static org.instancio.Select.field;
CompanyEntity entity = Instancio.create(CompanyEntity.class);

Pero un objeto totalmente aleatorio no siempre se puede guardar: el id lo asigna la base de datos y las relaciones apuntan a objetos que no existen. Por eso se le dice a Instancio qué ignorar y qué fijar, dentro de la propia prueba:

CompanyEntity entity = Instancio.of(CompanyEntity.class)
.ignore(field(CompanyEntity::getId))
.ignore(field(CompanyEntity::getDepartments))
.set(field(CompanyEntity::getName), "Acme")
.create();
  • ignore(field(...)) deja el atributo sin valor. Se usa con el id y con las relaciones, y en una relación bidireccional evita el ciclo: se ignora el lado que lo cierra.
  • set(field(...), valor) fija un valor. Con un valor correcto se arma un caso positivo; con uno incorrecto (por ejemplo null) se arma el caso negativo de una regla.
  • field(CompanyEntity::getName) se escribe con la referencia al método. Si la entidad cambia el nombre del atributo, el compilador avisa; con un texto "name" no pasaría.

Buenas prácticas: encapsular la creación en un método auxiliar de la clase (como crearCompany()), modificar explícitamente los datos relevantes para el caso, ignorar las relaciones que el escenario no necesita y persistir las entidades en el orden que exigen sus dependencias.

Un escenario positivo verifica el caso feliz:

@Test
void testCreateCompany() throws EntityNotFoundException,
IllegalOperationException {
CompanyEntity newEntity = crearCompany();
CompanyEntity result = companyService.createCompany(newEntity);
assertNotNull(result);
CompanyEntity entity = entityManager.find(
CompanyEntity.class, result.getId());
assertEquals(newEntity.getName(), entity.getName());
}

Un escenario negativo verifica una regla y la excepción esperada:

@Test
void testCreateInvalidCompany() {
assertThrows(IllegalOperationException.class, () -> {
CompanyEntity newEntity = crearCompany();
newEntity.setName(companyList.get(0).getName());
companyService.createCompany(newEntity);
});
}

Con estos dos escenarios por regla queda cubierta la estrategia mínima que vimos al principio: un positivo que confirma el camino feliz y un negativo por cada regla que confirma que el service la hace cumplir.

1. ¿Qué carga @DataJpaTest?
2. ¿Por qué se usa @BeforeEach?
3. ¿Por qué preparar datos con TestEntityManager y no con el servicio?
4. ¿Qué verifica assertThrows(IllegalOperationException.class, ...)?
5. Si hay tres reglas de negocio, ¿cuál es la cobertura mínima propuesta?
6. ¿Para qué se usa ignore(field(...)) al generar una entidad con Instancio?
7. ¿Por qué @DataJpaTest lleva properties = "spring.sql.init.mode=never"?