Primer servicio inyectado y su prueba unitaria
De la ficha al servicio: crea un repositorio en memoria, conecta las piezas por constructor y comprueba cada decisión con Mockito. Sigue el recorrido, intenta cada paso y despliega el código cuando necesites ayuda.
0Qué vas a construir y por dónde empezar
Ya tienes los tipos de A.03. Ahora vas a darles comportamiento: guardar un expediente, recuperarlo y calcular cuánto se paga con bonificación. Al terminar entregarás código y pruebas con ./mvnw test en verde.
- Prepara el proyecto y revisa tu ExpedienteComprueba los datos de A.03 que necesitará el servicio.
- Define el contrato y el archivador en memoriaQué operaciones se pueden pedir y cómo se guardan los datos.
- Pon nombre a los erroresCrea las excepciones antes de usarlas.
- Construye el servicio y decide el plazoRecibe el repositorio por constructor y aplica las reglas.
- Prueba cada caso con MockitoPrepara una situación, llama al método y comprueba el resultado.
- Ejecuta, corrige y revisa la entregaComprueba los cinco criterios del enunciado.
ExpedienteDuplicadoException son decisiones de esta propuesta.1Antes de escribir: localiza tus archivos
Abre el proyecto MULTAGAL de A.02, con los modelos de A.03. Trabaja desde la carpeta que contiene pom.xml y el Maven Wrapper. Los ejemplos usan el paquete base es.edu.multagal; si el tuyo cambia, ajusta los package y los import.
| Carpeta | Qué debe haber al terminar |
|---|---|
src/main/java/es/edu/multagal/domain/model/ | Tu Expediente y EstadoExpediente de A.03. |
src/main/java/es/edu/multagal/repository/ | RepositorioExpedientes.java y RepositorioExpedientesMemoria.java. |
src/main/java/es/edu/multagal/domain/exception/ | Las tres excepciones de esta propuesta, cada una en su archivo. |
src/main/java/es/edu/multagal/service/ | ServicioExpedientes.java. |
src/test/java/es/edu/multagal/service/ | ServicioExpedientesTest.java. |
El puente con A.03
El servicio necesita leer numero(), estado(), importe() y fechaNotificacion(). El importe es BigDecimal y la fecha es LocalDate. Si elegiste una clase con getters, sustituye esas llamadas por tus getNumero(), getEstado(), etc.
Los tests usan una ficha mínima de cuatro campos. Conserva tu modelo de A.03: si añadiste Denuncia u otros campos, adapta la función auxiliar expediente(...) de los tests para construir una ficha válida. No borres relaciones para que encaje el ejemplo.
Ver la forma mínima que asumen los ejemplos (referencia, no reemplazo de A.03)
package es.edu.multagal.domain.model;
import java.math.BigDecimal;
import java.time.LocalDate;
public record Expediente(String numero, EstadoExpediente estado,
BigDecimal importe, LocalDate fechaNotificacion) { }Comprueba: tu pom.xml conserva la dependencia de pruebas generada en A.02 (spring-boot-starter-test, ámbito test). Necesitaremos JUnit, Mockito y AssertJ. Recarga Maven en el IDE si no reconoce sus imports.
.\mvnw.cmd compile en PowerShell o ./mvnw compile en Linux/macOS. Debe aparecer BUILD SUCCESS. Aquí solo compruebas que el modelo y los paquetes compilan; todavía no necesitas arrancar la aplicación.2Primero el contrato; después, el archivador
2.1 · Escribe qué podrás pedir al repositorio
Una interface enumera operaciones sin decidir cómo se hacen. Optional<Expediente> expresa «puede haber una ficha o puede no haberla»; las búsquedas múltiples devuelven una lista, vacía si no hay coincidencias.
Hazlo: crea la interfaz en repository con los cuatro métodos del enunciado. En esta propuesta, guardar devuelve la ficha guardada.
Comparar con la interfaz completa
package es.edu.multagal.repository;
import es.edu.multagal.domain.model.EstadoExpediente;
import es.edu.multagal.domain.model.Expediente;
import java.util.List;
import java.util.Optional;
public interface RepositorioExpedientes {
Expediente guardar(Expediente expediente);
Optional<Expediente> buscarPorNumero(String numero);
List<Expediente> buscarTodos();
List<Expediente> buscarPorEstado(EstadoExpediente estado);
}2.2 · Implementa las operaciones en memoria
implements obliga a cumplir el contrato. El mapa relaciona un número con una ficha: "X-1" → expediente. put guarda, get consulta y values permite listar. Los datos desaparecen al reiniciar: este repositorio es provisional.
Hazlo: crea RepositorioExpedientesMemoria, usa ConcurrentHashMap y anótalo con @Repository para que Spring lo encuentre.
Ver el repositorio completo con imports
package es.edu.multagal.repository;
import es.edu.multagal.domain.model.EstadoExpediente;
import es.edu.multagal.domain.model.Expediente;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Repository;
@Repository
public class RepositorioExpedientesMemoria implements RepositorioExpedientes {
private final Map<String, Expediente> datos = new ConcurrentHashMap<>();
@Override
public Expediente guardar(Expediente expediente) {
datos.put(expediente.numero(), expediente);
return expediente;
}
@Override
public Optional<Expediente> buscarPorNumero(String numero) {
return Optional.ofNullable(datos.get(numero));
}
@Override
public List<Expediente> buscarTodos() {
return List.copyOf(datos.values());
}
@Override
public List<Expediente> buscarPorEstado(EstadoExpediente estado) {
return datos.values().stream()
.filter(e -> e.estado() == estado)
.toList();
}
}Lee dos líneas con calma: Optional.ofNullable(...) convierte la ausencia de ficha en Optional.empty(); filter(e -> e.estado() == estado) conserva solo las fichas del estado solicitado.
final impide sustituir la referencia datos por otro mapa. No impide añadir fichas al mapa. El repositorio guarda datos; el servicio solo conservará una referencia a su dependencia.Comprueba: vuelve a ejecutar compile. La implementación debe cumplir los cuatro métodos. En la UD3 se sustituirá por JDBC sin reescribir la lógica del servicio.
3Pon nombre a lo que puede salir mal
Consultar una ficha inexistente no devuelve una ficha vacía: el servicio lanza ExpedienteNoEncontradoException. Todas las excepciones propias comparten una raíz, MultagalException. extends expresa esa herencia; super(...) entrega el mensaje al constructor padre.
Hazlo: crea un archivo por clase pública en domain/exception. Añadimos ExpedienteDuplicadoException para rechazar duplicados de forma reconocible en los tests. El enunciado exige rechazarlos, pero no fija el nombre de esa excepción.
Ver los tres archivos completos
package es.edu.multagal.domain.exception;
public class MultagalException extends RuntimeException {
public MultagalException(String mensaje) {
super(mensaje);
}
}package es.edu.multagal.domain.exception;
public class ExpedienteNoEncontradoException extends MultagalException {
public ExpedienteNoEncontradoException(String numero) {
super("No existe el expediente " + numero);
}
}package es.edu.multagal.domain.exception;
public class ExpedienteDuplicadoException extends MultagalException {
public ExpedienteDuplicadoException(String numero) {
super("Ya existe el expediente " + numero);
}
}4El servicio decide; el repositorio guarda
4.1 · Entiende el constructor antes de copiar
El servicio recibe un RepositorioExpedientes, no crea su propia implementación. Durante el arranque, Spring le entrega el bean de memoria. En el test, tú le entregarás un mock. La clase de servicio será la misma en los dos casos.
@Service identifica la clase como lógica de negocio. Un único constructor no necesita @Autowired. El campo es final: la dependencia queda asignada al construir el servicio.
4.2 · Traduce cada método a una decisión
| Método | Pregunta | Resultado |
|---|---|---|
obtener | ¿Existe ese número? | Devuelve la ficha; si no existe, lanza la excepción de no encontrado. |
registrar | ¿Está libre ese número? | Guarda y devuelve la ficha; si ya existe, rechaza sin guardar. |
calcularImporteConBonificacion | ¿El pago cae en el plazo elegido? | Devuelve la mitad o el importe completo. No modifica la ficha. |
4.3 · Decide los límites antes de programarlos
R1 establece un 50 % de bonificación en los 20 días naturales siguientes a la notificación, con renuncia a alegaciones. La actividad te pide resolver, documentar y probar el límite. Aquí elegimos los días 1 a 20, ambos incluidos; «naturales» incluye fines de semana. No es una interpretación única impuesta por el enunciado.
| Fecha de pago | Días transcurridos | Resultado de esta propuesta |
|---|---|---|
| 31 de diciembre de 2025 | −1 | Rechazar: el pago es anterior. |
| 1 de enero de 2026 | 0 | 100 €: elegimos contar desde el día siguiente. |
| 2 de enero de 2026 | 1 | 50 €: primer día incluido. |
| 21 de enero de 2026 | 20 | 50 €: último día incluido. |
| 22 de enero de 2026 | 21 | 100 €: fuera de plazo. |
También elegimos rechazar el cálculo si falta alguna fecha. Un expediente aún no notificado puede existir en A.03; eso no significa que podamos calcular su plazo. Estas guardas son decisiones adicionales del ejemplo, no nuevos requisitos oficiales.
Escribe tu decisión en la documentación del proyecto (por ejemplo, en docs/adr/0002-plazo-bonificacion.md; este nombre es una sugerencia): qué días incluyes, cómo tratas el día 0 y las fechas ausentes o anteriores, y qué tests lo demuestran.
Ver el servicio completo, con imports y las decisiones anteriores
package es.edu.multagal.service;
import es.edu.multagal.domain.exception.ExpedienteDuplicadoException;
import es.edu.multagal.domain.exception.ExpedienteNoEncontradoException;
import es.edu.multagal.domain.model.Expediente;
import es.edu.multagal.repository.RepositorioExpedientes;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import org.springframework.stereotype.Service;
@Service
public class ServicioExpedientes {
private final RepositorioExpedientes repositorio;
public ServicioExpedientes(RepositorioExpedientes repositorio) {
this.repositorio = repositorio;
}
public Expediente obtener(String numero) {
return repositorio.buscarPorNumero(numero)
.orElseThrow(() -> new ExpedienteNoEncontradoException(numero));
}
public Expediente registrar(Expediente expediente) {
if (repositorio.buscarPorNumero(expediente.numero()).isPresent()) {
throw new ExpedienteDuplicadoException(expediente.numero());
}
return repositorio.guardar(expediente);
}
public BigDecimal calcularImporteConBonificacion(Expediente expediente,
LocalDate fechaPago) {
if (expediente.fechaNotificacion() == null || fechaPago == null) {
throw new IllegalArgumentException("Se necesitan ambas fechas");
}
long dias = ChronoUnit.DAYS.between(expediente.fechaNotificacion(), fechaPago);
if (dias < 0) {
throw new IllegalArgumentException("El pago no puede preceder a la notificación");
}
// Decisión de esta propuesta: días 1 a 20 incluidos; día 0 sin descuento.
if (dias >= 1 && dias <= 20) {
return expediente.importe().multiply(new BigDecimal("0.50"));
}
return expediente.importe();
}
}Lee el código: orElseThrow devuelve el contenido del Optional o lanza la excepción. isPresent() detecta el duplicado. ChronoUnit.DAYS.between calcula los días entre las fechas. new BigDecimal("0.50") representa exactamente la mitad.
Alcance del ejemplo: el mapa permite operaciones concurrentes, pero «consultar y después guardar» son dos operaciones separadas. Esta vertical didáctica comprueba duplicados en ejecución secuencial; no demuestra unicidad ante dos altas simultáneas.
5Prueba el servicio sin arrancar Spring
Una prueba unitaria comprueba una pieza aislada. Aquí la pieza real es ServicioExpedientes. Mockito crea un repositorio simulado: no usa el mapa de memoria ni una base de datos. Así sabes que estás probando las decisiones del servicio.
5.1 · Entiende las tres fases de un test
- Preparar: crea la ficha e indica qué responderá el mock.
- Actuar: llama al método real del servicio.
- Comprobar: verifica el resultado, la excepción o la llamada a guardar.
| Instrucción | En palabras sencillas |
|---|---|
mock(RepositorioExpedientes.class) | Crea un doble que cumple la interfaz. |
when(...).thenReturn(...) | «Cuando te pidan esto, responde aquello». No inserta fichas reales. |
new ServicioExpedientes(repo) | Inyecta tú mismo el doble por el constructor. |
assertThat(...) | Comprueba el resultado; si no coincide, el test falla. |
assertThatThrownBy(...) | Comprueba que la operación lanza la excepción esperada. |
verify(repo).guardar(nuevo) | Comprueba que el servicio pidió guardar esa ficha. |
@BeforeEach | Prepara un mock y un servicio nuevos antes de cada test. |
5.2 · Escribe los casos antes de desplegar la solución
| Caso | Preparación | Qué debes comprobar |
|---|---|---|
| Obtener existente | La búsqueda devuelve una ficha. | Recibes esa ficha. |
| Registrar nuevo | No existe; guardar devuelve la ficha. | Se guarda y se devuelve. Es el caso favorable de registrar. |
| Obtener inexistente | La búsqueda devuelve Optional.empty(). | Se lanza la excepción de no encontrado. |
| Registrar duplicado | La búsqueda ya encuentra el número. | Se rechaza y no se llama a guardar. |
| Cálculo dentro del plazo | 100 €, notificación y pago 10 días después. | 50 €. |
| Días 20 y 21 | Misma ficha, dos fechas de pago. | 50 € y 100 €, según la decisión documentada. |
Hazlo: crea la clase en src/test/java/es/edu/multagal/service/. Empieza por obtener una ficha. Cuando pase, añade el registro correcto y los errores. Termina con las fechas.
Ver la clase de pruebas completa (12 tests, incluidos los casos adicionales)
package es.edu.multagal.service;
import es.edu.multagal.domain.exception.ExpedienteDuplicadoException;
import es.edu.multagal.domain.exception.ExpedienteNoEncontradoException;
import es.edu.multagal.domain.model.EstadoExpediente;
import es.edu.multagal.domain.model.Expediente;
import es.edu.multagal.repository.RepositorioExpedientes;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.*;
class ServicioExpedientesTest {
private RepositorioExpedientes repo;
private ServicioExpedientes servicio;
private final LocalDate notificacion = LocalDate.of(2026, 1, 1);
@BeforeEach
void preparar() {
repo = mock(RepositorioExpedientes.class);
servicio = new ServicioExpedientes(repo);
}
// Adapta SOLO esta construcción a los campos de tu modelo de A.03.
private Expediente expediente(LocalDate fecha) {
return new Expediente("X-1", EstadoExpediente.ABIERTO,
new BigDecimal("100.00"), fecha);
}
@Test
void devuelveElExpedienteCuandoExiste() {
Expediente esperado = expediente(notificacion);
when(repo.buscarPorNumero("X-1")).thenReturn(Optional.of(esperado));
Expediente resultado = servicio.obtener("X-1");
assertThat(resultado).isEqualTo(esperado);
}
@Test
void registraUnNumeroNuevo() {
Expediente nuevo = expediente(notificacion);
when(repo.buscarPorNumero("X-1")).thenReturn(Optional.empty());
when(repo.guardar(nuevo)).thenReturn(nuevo);
Expediente resultado = servicio.registrar(nuevo);
assertThat(resultado).isEqualTo(nuevo);
verify(repo).guardar(nuevo);
}
@Test
void lanzaExcepcionSiNoExiste() {
when(repo.buscarPorNumero("X-1")).thenReturn(Optional.empty());
assertThatThrownBy(() -> servicio.obtener("X-1"))
.isInstanceOf(ExpedienteNoEncontradoException.class);
}
@Test
void rechazaDuplicadoSinGuardar() {
Expediente existente = expediente(notificacion);
when(repo.buscarPorNumero("X-1")).thenReturn(Optional.of(existente));
assertThatThrownBy(() -> servicio.registrar(existente))
.isInstanceOf(ExpedienteDuplicadoException.class);
verify(repo, never()).guardar(any(Expediente.class));
}
private void comprobarImporte(int dias, String esperado) {
BigDecimal importe = servicio.calcularImporteConBonificacion(
expediente(notificacion), notificacion.plusDays(dias));
assertThat(importe).isEqualByComparingTo(esperado);
verifyNoInteractions(repo);
}
@Test
void bonificaUnDiaInteriorDelPlazo() {
comprobarImporte(10, "50.00");
}
@Test
void bonificaElDia20() {
comprobarImporte(20, "50.00");
}
@Test
void cobraCompletoElDia21() {
comprobarImporte(21, "100.00");
}
// Casos adicionales para las decisiones de esta propuesta.
@Test
void bonificaElPrimerDiaSiguiente() {
comprobarImporte(1, "50.00");
}
@Test
void noBonificaElMismoDia() {
comprobarImporte(0, "100.00");
}
@Test
void rechazaPagoAnterior() {
assertThatThrownBy(() -> servicio.calcularImporteConBonificacion(
expediente(notificacion), notificacion.minusDays(1)))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void rechazaCalculoSinNotificacion() {
assertThatThrownBy(() -> servicio.calcularImporteConBonificacion(
expediente(null), notificacion.plusDays(1)))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void rechazaCalculoSinFechaDePago() {
assertThatThrownBy(() -> servicio.calcularImporteConBonificacion(
expediente(notificacion), null))
.isInstanceOf(IllegalArgumentException.class);
}
}plusDays(20) evita confundir «día 20 del plazo» con «20 de enero». Para dinero, isEqualByComparingTo compara el valor: 50.0 y 50.00 representan el mismo importe.El helper expediente(...) concentra la adaptación a A.03. Si tu constructor exige una denuncia, constrúyela con datos ficticios válidos y pásala allí; si tu modelo prohíbe una fecha nula, prueba esa restricción en el modelo y adapta el caso adicional correspondiente.
6Ejecuta y comprueba qué estás entregando
Primero la clase de pruebas; después, toda la suite
Ejecuta desde la carpeta de pom.xml. El primer comando aísla esta actividad; el segundo comprueba el entregable completo.
.\mvnw.cmd "-Dtest=ServicioExpedientesTest" test
.\mvnw.cmd test./mvnw -Dtest=ServicioExpedientesTest test
./mvnw testResultado esperado con la propuesta sin adaptar: 12 tests de ServicioExpedientesTest, cero fallos y cero errores, y BUILD SUCCESS. La suite completa puede tener más tests. No des por terminada la entrega si solo pasa el comando filtrado.
Si algo falla, busca el síntoma
| Síntoma | Qué revisar |
|---|---|
| No encuentra una clase o un método | Paquete, imports, nombre de archivo y accesores del modelo. Las tres excepciones deben existir. |
El constructor de Expediente no encaja | Adapta el helper de pruebas a los campos que elegiste en A.03. |
registrar devuelve null en el test | Configura también when(repo.guardar(nuevo)).thenReturn(nuevo): el mock no guarda de verdad. |
| El día 20 falla | Compara la fecha de notificación, plusDays(20) y el operador <=. Revisa que test y decisión coincidan. |
| Intenta conectar a una base de datos | Esta clase debe usar Mockito y new, sin @SpringBootTest. Si falla otro test generado en A.02 que carga el contexto, revisa su configuración por separado; no ocultes el fallo de la suite ni borres pruebas para conseguir verde. |
| Maven no encuentra pruebas | Usa src/test/java, nombre terminado en Test, imports de JUnit y métodos con @Test. |
Los cinco criterios oficiales, con evidencia
| Criterio del enunciado | Dónde lo demuestras |
|---|---|
Inyección por constructor y campos final | Constructor de ServicioExpedientes y su campo repositorio. |
| El servicio depende de la interfaz | Su import y el tipo del parámetro son RepositorioExpedientes. |
Pruebas sin Docker ni @SpringBootTest | La clase de tests instancia el servicio con un mock. |
| Casos límite del plazo cubiertos | Pruebas de los días 20 y 21 y decisión documentada. |
| Servicio sin estado mutable | No guarda fichas ni resultados en campos; trabaja con parámetros y retornos. |
Entregable: interfaz, repositorio en memoria, servicio, excepciones y pruebas pasando con ./mvnw test, junto con la explicación de tu decisión sobre el plazo. Todo forma parte de la rama hito/ud0-arranque de la ENTREGA 1.
Comprueba que puedes explicarlo sin mirar el código
¿Qué cambia al sustituir memoria por JDBC? La implementación que cumple el contrato. El servicio sigue recibiendo la misma interfaz.
¿Quién inyecta en el test? Tú, al llamar al constructor con el mock. Spring no participa.
¿Por qué el duplicado se comprueba en el servicio? Es una decisión de negocio que necesita consultar si el número ya existe.
¿Por qué dos tests para 20 y 21? Para demostrar exactamente dónde termina el descuento y detectar errores de un día.
Fuente única: A0.4 — actividad y criterios y sus enlaces a f — Inyección de dependencias y b — Regla R1. Consultados el 26/09/2026. Código y decisiones adicionales señalados como propuesta didáctica.