← Arquitectura por capas con Spring

Sesión 17 · Semana 9

Reglas de negocio probadas

Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado comprobar el contrato publicado. En Servidor continúas la implementación del mismo producto.

Se explica

25 minutos · explicación y demostración

Las capas ya se conectan por constructor. Hoy comprobarás las reglas de negocio sin arrancar el servidor web. Un test automatizado prepara datos, ejecuta una operación y comprueba su resultado; un repositorio falso permite controlar el estado del almacenamiento durante esa prueba.

Tres cosas que no son la misma

«Lógica de negocio» se usa para todo, y dentro de un service conviven tres cosas distintas:

Es… Responde a… Ejemplo
Coordinación ¿En qué orden se hacen los pasos? Buscar el proyecto, crear la tarea, guardarla
Regla de negocio ¿Se puede hacer esto? ¿Qué valor toma? Una tarea nace sin completar; no puede haber dos proyectos con el mismo nombre
Acceso a datos ¿Cómo lo guardo y lo recupero? Recorrer la lista, o mañana una consulta SQL

La tercera ya está fuera desde la sesión 15. Hoy separamos las dos primeras, que siguen mezcladas.

Caso de uso

Una cosa completa que alguien quiere hacer con la aplicación: «crear una tarea», «cerrar una incidencia», «listar los proyectos activos». Es la unidad en la que se piensa un service: un método público por caso de uso, con el nombre que usaría una persona.

Validación o regla de negocio

Esta es la distinción de la sesión, y tiene una prueba que la decide:

La pregunta que las separa

¿Se puede responder mirando solo el objeto que ha llegado?

Si , es validación: una regla de formato, y va en el DTO con una anotación. «El título no puede estar vacío» se decide mirando el título.

Si no, porque hace falta consultar datos, es una regla de negocio: va en el service. «El proyecto debe existir» exige mirar los proyectos que hay.

Regla ¿Basta con el objeto? Dónde vive
El título no está vacío DTO, @NotBlank
La prioridad es baja, media o alta DTO, anotación propia
La fecha de fin no es anterior a la de inicio Sí, mira dos campos DTO, restricción de clase
El proyecto al que pertenece existe No Service
No hay otro proyecto con ese nombre No Service
Un proyecto archivado no admite tareas nuevas No Service
Solo el autor puede borrar su comentario No Service, y en la UD9 con seguridad

Fíjate en que la frontera no es la dificultad de la regla, sino de dónde sale la información para decidirla.

Las dos respuestas de antes se cobran hoy

Aquellas dos decisiones de la sesión 16 parecían burocracia. Hoy se ve para qué eran:

Por qué se puede probar tu service
  1. Declara sus dependencias por constructor, de modo que el test puede instanciarlo
  2. Pide una interfaz, así que puedo darle lo que quiera que la cumpla
  3. No sabe nada de HTTP, así que no hace falta arrancar nada

Sin esas tres, lo de hoy sería imposible. Con ellas, un test cabe en diez líneas.

Qué es y qué no es un test

Test unitario

Un trozo de código que ejecuta otro trozo de código y comprueba automáticamente que el resultado es el esperado. Si lo es, calla. Si no, avisa.

Lo importante es «automáticamente». Comprobar a mano en Postman también es probar, y tiene tres problemas: lo haces tú, lo haces cuando te acuerdas, y mañana ya no queda constancia de que lo hiciste.

Comprobar en Postman

Arrancar, escribir un JSON, enviar, leer. Diez segundos si todo va bien, y si falla no sabes si es la regla, la ruta, el mapper o el JSON.

Un test unitario

Milisegundos, sin arrancar nada, señalando exactamente qué regla ha fallado y con qué valores.

Los dos hacen falta: la colección comprueba el contrato HTTP, y los tests comprueban la lógica. Comprueban cosas distintas.

Se trabaja

140 minutos · implementación guiada sobre el proyecto propio

Paso 1 · Retomar el proyecto y preparar la comprobación

  1. Abre el servicio y la interfaz del repositorio. Escribe qué reglas dependen de los datos existentes, como una transición prohibida o un nombre duplicado.
  2. Localiza src/test/java y la dependencia de pruebas del pom.xml. La clase de test irá bajo el paquete correspondiente al servicio.
  3. Para cada regla, prepara un caso permitido y otro prohibido e indica qué dato debe quedar guardado después.

Paso 2 · Implementar en el servicio la regla pendiente del dominio

Amplía el constructor existente de TareaService para recibir también ProyectoRepository; conserva el resto de sus métodos. En crear, comprueba el proyecto antes de guardar la tarea. Crea ReglaDeNegocioException.java en error y añade su método @ExceptionHandler al manejador que ya existe. Prueba primero un proyecto válido y después uno inexistente o inactivo: el rechazo debe impedir la inserción.

@Service
public class TareaService {

    private final TareaRepository repositorio;
    private final ProyectoRepository proyectos;

    public TareaService(TareaRepository repositorio, ProyectoRepository proyectos) {
        this.repositorio = repositorio;
        this.proyectos = proyectos;
    }

    public Tarea crear(Tarea tarea) {
        // Regla: toda tarea pertenece a un proyecto que existe.
        if (!proyectos.existsById(tarea.getProyectoId())) {
            throw new ReglaDeNegocioException(
                    "No existe el proyecto " + tarea.getProyectoId());
        }
        // Regla: una tarea nace sin completar.
        tarea.setCompletada(false);
        return repositorio.save(tarea);
    }
}

La excepción se declara junto a las de la UD3:

package com.ejemplo.gestor.error;

public class ReglaDeNegocioException extends RuntimeException {

    public ReglaDeNegocioException(String mensaje) {
        super(mensaje);
    }
}

Con su manejador, que ya sabes escribir:

@ExceptionHandler(ReglaDeNegocioException.class)
public ProblemDetail reglaIncumplida(
        ReglaDeNegocioException ex, HttpServletRequest peticion) {

    return construir(HttpStatus.CONFLICT, ex.getMessage(), peticion, List.of());
}

Fíjate en lo que no hay en el service

No aparece 409, ni ResponseEntity, ni nada de HTTP. El service dice qué regla se ha incumplido; el manejador decide con qué código se cuenta eso.

Si mañana esta misma regla la aplica una importación de CSV, la excepción tendrá exactamente el mismo sentido y nadie hablará de códigos de estado.

¿400, 404 o 409?

Es la discusión que dejaste abierta en la sesión 13, y ahora toca cerrarla. Las tres posturas son defendibles:

404 Not Found

«El proyecto que nombras no existe.» Tiene el problema de que la ruta era /tareas: quien lo reciba puede creer que no existe la ruta de tareas.

409 Conflict

«Tu petición es válida pero choca con el estado de los datos.» Encaja con la definición y no se confunde con la ruta. Es la que usamos aquí.

También se defiende un 400, argumentando que el cliente ha enviado un dato incorrecto. Elige una, escríbela en las decisiones técnicas y aplícala igual en toda la API. Lo que no vale es que una regla parecida responda 409 en un recurso y 400 en otro.

Paso 3 · El service que solo coordina

Cuando un caso de uso crece, conviene ver sus dos mitades separadas:

public Tarea crear(Tarea tarea) {
    comprobarQueElProyectoExiste(tarea.getProyectoId());
    comprobarQueElProyectoAdmiteTareas(tarea.getProyectoId());
    tarea.setCompletada(false);
    return repositorio.save(tarea);
}

private void comprobarQueElProyectoExiste(int proyectoId) {
    if (!proyectos.existsById(proyectoId)) {
        throw new ReglaDeNegocioException("No existe el proyecto " + proyectoId);
    }
}

private void comprobarQueElProyectoAdmiteTareas(int proyectoId) {
    Proyecto proyecto = proyectos.findById(proyectoId).orElseThrow();
    if (!proyecto.isActivo()) {
        throw new ReglaDeNegocioException(
                "El proyecto " + proyectoId + " está archivado y no admite tareas");
    }
}

El método público se lee como el enunciado del caso de uso: comprueba esto, comprueba aquello, aplica esta regla, guarda. Cada regla tiene un nombre, y ese nombre es documentación que no se desactualiza.

Un método público por caso de uso, con nombre de negocio

crear, cerrar, archivar, reasignar. No procesarDatos, no gestionar, no hacerTodo.

La prueba: si le lees el nombre del método a alguien que conoce el negocio pero no programa, ¿entiende qué hace? Si no, el nombre está describiendo el código en lugar del propósito.

Paso 4 · Las reglas que dependen del estado

Hay una familia entera que solo aparece cuando algo puede estar en varias situaciones. Son las que más se olvidan:

Estados de una incidencia y qué se permite en cada uno
  1. abierta
    se edita, se cierra
  2. en curso
    se edita, se cierra
  3. cerrada
    solo se reabre

De ahí salen reglas que ninguna anotación puede expresar: «una incidencia cerrada no se puede editar», «no se puede cerrar dos veces», «solo se reabre lo que está cerrado».

De ahí se deriva una pregunta que conviene plantear pronto: si alguien envía un PATCH cambiando el estado de abierta a cerrada, ¿es eso una modificación cualquiera o es un caso de uso propio? Casi siempre lo segundo, y por eso POST /incidencias/41/cierre de la sesión 9 tenía sentido: porque detrás hay reglas que un cambio de campo genérico se salta.

Paso 5 · El inventario de reglas

  1. Escribe la tabla de todas las reglas de tu aplicación, con este formato:
Regla ¿Basta el objeto? Dónde vive hoy Dónde debería vivir
  1. Mueve al service todas las que no estén en su sitio.
  2. Implementa como mínimo estas tres:
    • Una tarea pertenece a un proyecto que existe.
    • No hay dos proyectos con el mismo nombre.
    • Un proyecto inactivo no admite tareas nuevas.
  3. Dales nombre propio: un método privado por regla, con un nombre que se lea.
  4. Añade a la colección una petición por regla que la incumpla, comprobando código y mensaje.

Primeros tests del service con JUnit

Paso 6 · Localizar JUnit y preparar la clase de test

Mira el pom.xml: spring-boot-starter-test está desde el primer día, porque lo puso start.spring.io. Trae JUnit 5 y todo lo necesario.

Conviene examinar src/test/java: existe desde la UD1, con una clase generada en su interior. Es la carpeta gemela de src/main/java, y el código de tests no se empaqueta con la aplicación.

La estructura se copia, no se inventa

El test de com.ejemplo.gestor.service.TareaService va en el mismo paquete, dentro de src/test/java, y se llama TareaServiceTest.

Mismo paquete, mismo nombre más Test. Así se encuentran sin buscarlos y Maven los ejecuta sin configurar nada.

Paso 7 · Implementar un repositorio de prueba con estado controlado

Crea los dobles en src/test/java/com/ejemplo/gestor/service, no en src/main/java, y no les pongas anotaciones de Spring. Implementa todos los métodos de las interfaces de la sesión 16. El falso de proyectos del bloque permite preparar los proyectos que verá el servicio. Prepara también el falso de tareas antes de escribir el test que lo utiliza, copiando la lógica de almacenamiento en memoria y manteniendo sus ids controlados.

Doble de prueba

Una implementación de mentira que se pone en lugar de la de verdad, para controlar exactamente qué datos ve el código que estás probando.

Como tu repositorio es una interfaz, escribir uno es directo:

package com.ejemplo.gestor.service;

import com.ejemplo.gestor.model.Proyecto;
import com.ejemplo.gestor.repository.ProyectoRepository;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

class ProyectoRepositorioFalso implements ProyectoRepository {

    private final List<Proyecto> proyectos = new ArrayList<>();

    /** Prepara el escenario del test: estos proyectos existen y ninguno más. */
    void con(Proyecto... iniciales) {
        proyectos.addAll(List.of(iniciales));
    }

    @Override
    public boolean existsById(int id) {
        return findById(id).isPresent();
    }

    @Override
    public Optional<Proyecto> findById(int id) {
        return proyectos.stream().filter(p -> p.getId() == id).findFirst();
    }

    @Override
    public List<Proyecto> findAll() {
        return new ArrayList<>(proyectos);
    }

    @Override
    public Proyecto save(Proyecto proyecto) {
        proyectos.add(proyecto);
        return proyecto;
    }

    @Override
    public boolean deleteById(int id) {
        return proyectos.removeIf(p -> p.getId() == id);
    }
}

Vive en src/test/java, así que no se publica con la aplicación.

Existen librerías que generan estos dobles

La más extendida es Mockito, ya incluida en el proyecto dentro de spring-boot-starter-test. Con ella, lo de arriba se escribe en una línea por comportamiento.

Lo hacemos a mano primero porque un doble escrito por ti se entiende sin aprender una sintaxis nueva, y porque así ves que no hay magia: es una clase normal que implementa la misma interfaz. Cuando en la UD11 se ordene la estrategia de pruebas, sabrás qué te está generando la librería.

Paso 8 · Tu primer test

Crea TareaServiceTest.java en el mismo paquete de pruebas que los falsos. En cada test construye repositorios nuevos y pasa ambos al constructor del servicio: así un test no hereda datos de otro. En el caso permitido, crea previamente un proyecto activo con el id que pondrás en la tarea; en el rechazado no lo prepares. Ejecuta primero un solo método desde el IDE y después la clase completa. Revisa los imports estáticos de JUnit si no reconoce las aserciones.

package com.ejemplo.gestor.service;

import com.ejemplo.gestor.error.ReglaDeNegocioException;
import com.ejemplo.gestor.model.Proyecto;
import com.ejemplo.gestor.model.Tarea;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;

class TareaServiceTest {

    @Test
    @DisplayName("Una tarea nueva nace sin completar")
    void unaTareaNuevaNaceSinCompletar() {
        // Preparar
        ProyectoRepositorioFalso proyectos = new ProyectoRepositorioFalso();
        proyectos.con(new Proyecto(7, "Web corporativa", "", true));
        TareaService servicio =
                new TareaService(new TareaRepositorioFalso(), proyectos);

        Tarea entrada = new Tarea();
        entrada.setTitulo("Revisar el login");
        entrada.setProyectoId(7);
        entrada.setCompletada(true);   // el cliente insiste

        // Actuar
        Tarea creada = servicio.crear(entrada);

        // Comprobar
        assertFalse(creada.isCompletada());
    }

    @Test
    @DisplayName("No se puede crear una tarea en un proyecto que no existe")
    void noSePuedeCrearEnUnProyectoInexistente() {
        ProyectoRepositorioFalso proyectos = new ProyectoRepositorioFalso();
        // Escenario: no hay ningún proyecto.
        TareaService servicio =
                new TareaService(new TareaRepositorioFalso(), proyectos);

        Tarea entrada = new Tarea();
        entrada.setTitulo("Revisar el login");
        entrada.setProyectoId(999);

        ReglaDeNegocioException error = assertThrows(
                ReglaDeNegocioException.class,
                () -> servicio.crear(entrada));

        assertEquals("No existe el proyecto 999", error.getMessage());
    }
}
Las tres zonas · preparar, actuar, comprobar
Todo test tiene esta forma: se monta el escenario, se ejecuta una sola acción, y se comprueba el resultado. Si te cuesta separarlas, normalmente es que el test está probando dos cosas a la vez.
@DisplayName
El nombre que se ve al ejecutar. Escríbelo como una frase que afirme lo que debe pasar: cuando falle, ese texto es lo que vas a leer, y quieres que te diga qué se ha roto sin abrir el código.
assertThrows
Comprueba que algo falla, y falla como debe. Devuelve la excepción capturada, así que se puede comprobar además el mensaje. Un test que verifica los errores vale tanto como uno que verifica los aciertos.
Por qué no hay ninguna anotación de Spring
Porque no hace falta. Este test no arranca la aplicación, no levanta el contenedor y no toca un puerto: construye tres objetos Java y llama a un método. Por eso tarda milisegundos.

Paso 9 · Ejecútalos

Desde el IDE, con el botón de la clase. Desde el terminal:

./mvnw test
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

Fíjate en el tiempo total. Ahora compáralo con arrancar la aplicación y comprobar esas dos cosas en Postman.

Ve a TareaService y borra la línea tarea.setCompletada(false). Ejecuta los tests.

[ERROR] Una tarea nueva nace sin completar
expected: <false> but was: <true>

El nombre te dice qué regla se ha roto, y el mensaje qué valor esperaba. Sin arrancar nada, sin escribir un JSON y sin acordarte de comprobarlo. Devuelve la línea.

Qué merece un test y qué no

Sí: las reglas de negocio, los casos de error, los límites, y todo lo que alguien pueda romper sin darse cuenta.

No: los getters, los mappers triviales, ni que Spring funcione. Un test que solo comprueba que Java asigna un campo no protege de nada y hay que mantenerlo igual.

El criterio: ¿me enteraría si alguien rompiera esto? Si la respuesta es no, hay que escribir el test.

Paso 10 · Cubre tus reglas

  1. Crea TareaRepositorioFalso y ProyectoRepositorioFalso en src/test/java.
  2. Escribe un test por cada regla de la tabla de la sesión 17. Como mínimo cinco.
  3. Cada uno con su @DisplayName en forma de frase afirmativa.
  4. Al menos dos deben usar assertThrows y comprobar el mensaje.
  5. Ejecuta ./mvnw test y deja todo en verde.

Paso 11 · Comprobar y registrar el resultado del proyecto

  1. Ejecuta los tests con el wrapper de Maven usando el objetivo test. Deben poder probar el servicio sin escuchar en el puerto 8080.
  2. Comprueba que un caso rechazado conserva el estado previo y que el permitido lo cambia correctamente. Ejecuta además la colección para comprobar que las capas siguen conectadas.

Ampliación si has completado el trabajo

Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.

Reto · La regla que se cuela por la puerta de atrás

Tienes la regla «un proyecto inactivo no admite tareas nuevas» implementada en crear. Ahora piensa:

  1. ¿Qué pasa si alguien crea una tarea en un proyecto activo y después se archiva el proyecto?
  2. ¿Qué pasa si alguien usa PUT /tareas/41 para cambiarle el proyectoId a uno inactivo? ¿Pasa por tu regla?
  3. Localiza todos los caminos por los que una tarea puede acabar asociada a un proyecto inactivo. Hay al menos tres.
  4. Decide qué hacer con cada uno: ¿se prohíbe, se permite, se avisa? No todas las respuestas tienen que ser «prohibir».
  5. Implementa tu decisión y añade una prueba por camino.

La pregunta de fondo se responde en dos frases:

Cuando una regla hay que repetirla en tres métodos distintos, ¿es que la regla está mal puesta, o es que falta un concepto en tu modelo?

Objetivo mínimoLa tabla de reglas completa y las tres reglas implementadas en el service.
Si lo tienesCada regla con su método privado y su nombre de negocio, y una petición de incumplimiento por regla en la colección.
RetoLos tres caminos hacia el proyecto inactivo localizados y resueltos con una decisión escrita.
Ver respuestas

1 · Si se puede decidir mirando solo el objeto que ha llegado, es validación y va en el DTO. Si hace falta consultar datos, es una regla de negocio y va en el service.

2 · Porque el service no sabe que existe HTTP. Dice qué regla se ha incumplido, y traducir eso a un código es trabajo de la capa web, que es la única que habla ese idioma.

3 · Algo completo que alguien quiere hacer con la aplicación. Se refleja como un método público del service, con un nombre que entendería alguien del negocio.

4 · Que una incidencia cerrada no se pueda editar, o que solo se reabra lo que está cerrado. Dependen de la situación actual del recurso, no de los datos enviados.

Reto · Rompe y demuestra

Este reto mide si tus tests valen algo.

  1. Haz una lista de las cinco reglas de tu aplicación.
  2. Para cada una, ve al código y rómpela a propósito, de una en una: invierte una condición, borra una línea, cambia un valor.
  3. Ejecuta los tests y anota: ¿falló alguno? ¿Cuál? ¿El mensaje te dijo qué se había roto?
  4. Rellena esta tabla:
Regla ¿La detectó un test? ¿El mensaje era útil?
  1. Toda fila con un «no» en la primera columna es un agujero en tu suite. Escribe el test que falta.
  2. Restaura el código y comprueba que todo vuelve a verde.

Una reflexión final, que debe redactarse:

Si un compañero entra mañana en tu proyecto, cambia una línea y ejecuta los tests, ¿qué le protegería y qué no?

Objetivo mínimoLos dos dobles escritos y dos tests en verde, uno de ellos con assertThrows.
Si lo tienesCinco tests, uno por regla, con nombres legibles y mensajes comprobados.
RetoLas cinco reglas rotas una a una, la tabla completa y los agujeros tapados.
Ver respuestas

1 · Pedir las dependencias por constructor, que permite construirlo a mano, y depender de una interfaz, que permite pasarle una implementación de mentira.

2 · Para probar una sola clase cada vez. Con el repositorio real estarías probando dos, y si el test fallara no sabrías cuál de las dos tiene el fallo.

3 · Preparar el escenario, ejecutar una sola acción y comprobar el resultado.

4 · La colección comprueba el contrato HTTP: rutas, códigos y formato. Los tests comprueban la lógica de negocio sin pasar por la web. Ninguno sustituye al otro.

Cierre

15 minutos · resultado comprobable y explicación individual

Al terminar la sesión:

Una regresión deliberada hace fallar una prueba y, al corregirla, vuelve a pasar.

Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.