1. Frontera de las integraciones y la lógica de negocio
Cuando en una aplicación aparece una llamada externa, un desarrollador principiante suele tener dos modos. El primero — “vamos a simplemente invocar HTTP directamente desde el servicio, ¿qué podría salir mal?”. El segundo — “vamos a simular (mockear) todo por completo, y las pruebas siempre serán verdes”. Ambos modos llevan a pruebas que o bien son frágiles y lentas, o bien son verdes pero no demuestran nada.
El derecho a la acción por sí solo tampoco ayuda. Un artículo puede ser accesible para el rol adecuado, el endpoint puede estar protegido como es debido, pero la acción igual choca contra un servicio externo o genera un efecto secundario que no ocurrirá de inmediato. Hoy hablamos precisamente de esos dos bordes desagradables.
En ContentHub, el mundo exterior aparece con regularidad. Cuando un editor envía un artículo a revisión, llamamos a la moderación. Cuando un administrador publica un artículo, enviamos una notificación. Cuando un editor sube un adjunto, accedemos al sistema de archivos. En todos estos casos tenemos un miedo común: que las pruebas empiecen a fallar aleatoriamente “por el clima”, porque la red, el sistema de archivos y la asincronía saben cómo dar sorpresas incluso un viernes por la noche.
La idea clave de esta lección es simple: la integración externa debe tener un límite, y ese límite debe estar organizado de modo que podamos probar:
1) las decisiones de negocio (qué hacer con OK/WARN/BLOCK),
2) la traducción técnica (qué solicitud HTTP enviamos y cómo analizamos la respuesta),
3) el comportamiento ante fallos (cómo degradar de manera segura).
Y todo esto — no en una única prueba “monstruosa”, sino en los niveles correctos.
2. Puerto y adaptador: diferentes responsabilidades
Las palabras “puerto” y “adaptador” suenan un poco como piezas de un destornillador, pero en las pruebas ofrecen un beneficio sorprendentemente práctico. Imagina: el código de negocio quiere “obtener una decisión de moderación”, y el código técnico sabe exactamente cómo hacerlo vía HTTP. Si mezclas estas partes, la prueba será o demasiado costosa (hay que levantar media infraestructura) o demasiado poco informativa (mocks para todo excepto la fe en un milagro).
Puerto (port) — es un contrato que describe lo que necesita la aplicación. Vive del lado de la aplicación y se expresa en sus términos: ModerationRequest, ModerationDecision, “moderación no disponible”.
Adaptador (adapter) — es una implementación concreta del puerto que sabe cómo salir al exterior: qué URL, qué método HTTP, qué cabeceras, qué JSON y qué hacer con los errores de transporte.
Normalmente es útil tener esta imagen en la cabeza:
flowchart LR
S["ArticleWorkflowService
decisión de negocio"] --> P["ModerationClient
puerto"]
P --> A["RestClientModerationClient
adaptador HTTP"]
A --> X["Moderation Service
mundo externo"]
Las pruebas también se “descomponen” por sentido. Si probamos el servicio, comprobamos la reacción de negocio y usamos un doble de prueba del puerto. Si probamos el adaptador, comprobamos la corrección del contrato HTTP y el mapeo — y sustituimos el mundo externo por un simulacro controlado.
3. Límites externos en ContentHub
Muchos asocian automáticamente la palabra “integración” con “HTTP”, pero en un backend real el mundo exterior es todo lo que no pertenece a tu lógica de dominio. Incluso si está en el mismo servidor y “casi al lado”. Por eso en ContentHub consideramos como límites externos al menos tres cosas: moderación, almacenamiento de archivos y envío de notificaciones.
La moderación está representada por el puerto ModerationClient. La capa de negocio quiere obtener una decisión OK/WARN/BLOCK y (en caso de BLOCK) el motivo. La capa de negocio no tiene por qué saber que en algún lugar existe POST /api/check y un JSON como {"result":"BLOCK"}. Eso es responsabilidad del adaptador.
El almacenamiento de archivos está representado por el puerto FileStorageService. Para la lógica de negocio es importante “guardar un adjunto y obtener un enlace/identificador”. Pero cómo exactamente escribimos en el sistema de archivos, dónde se almacena la raíz, qué excepciones surgen por falta de permisos — son detalles técnicos. Si el servicio comienza a trabajar directamente con Files.copy(...), las pruebas se convertirán rápidamente en un juego de “adivina dónde en el CI no hay permisos para el directorio”.
Las notificaciones de publicación están representadas por el puerto PublicationNotificationSender. Para el dominio es importante “después de la publicación enviar una notificación”. Para el dominio no es importante si será un hilo separado, una cola, un executor o simplemente un log. Y especialmente no es importante cómo se planifica la asincronía. Las pruebas de dominio deben verificar el hecho de que “se solicitó una notificación”, no la mecánica de bajo nivel de cómo exactamente se envió.
Para que no haya la sensación de “todo esto es teoría”, fijemos el puerto para moderación en código.
// Puerto: el código de negocio depende de esta interfaz, no de la implementación HTTP.
public interface ModerationClient {
// Devuelve la decisión de moderación del dominio; los detalles de transporte están ocultos detrás del límite.
ModerationDecision moderate(ModerationRequest request);
}
Aquí el puerto es intencionadamente simple: la capa de negocio no necesita conocer códigos de estado HTTP, ni RestClient, ni ObjectMapper. Quiere una decisión en términos de la aplicación.
4. Qué verificamos a través del puerto
Un punto muy importante que ahorra mucho tiempo y nervios: el puerto en sí mismo normalmente no es un objeto de prueba. Es una interfaz. Debe ser pequeño y comprensible, y, francamente, no hay nada que probar.
El objeto de prueba es el código que toma una decisión basándose en el puerto. En ContentHub, ese código es (por ejemplo) ArticleWorkflowService, que decide: pasar el artículo a IN_REVIEW, rechazarlo a REJECTED, o permanecer en DRAFT si la moderación no está disponible.
Para que el servicio pueda probarse adecuadamente, necesita una dependencia de la interfaz (puerto), no del cliente técnico. Esto se ve muy “aburrido”, y por lo tanto es correcto:
public class ArticleWorkflowService {
// Importante: dependemos del puerto (contrato), no de RestClient/HTTP.
private final ModerationClient moderationClient;
public ArticleWorkflowService(ModerationClient moderationClient) {
// A través del constructor es más fácil sustituir el puerto en pruebas unitarias.
this.moderationClient = moderationClient;
}
}
Luego el servicio realiza pasos de negocio. Por ejemplo, “submit for review” podría verse simplificado así:
public ArticleStatus submitForReview(String title, String body) {
// El servicio forma una solicitud de dominio (no una solicitud HTTP).
ModerationDecision decision = moderationClient.moderate(new ModerationRequest(title, body));
// El servicio toma una decisión de negocio basada en el resultado.
return decision.result() == ModerationResult.BLOCK ? ArticleStatus.REJECTED : ArticleStatus.IN_REVIEW;
}
Sí, es una simplificación (en el código real habrá Article, guardar la razón, timestamps, etc.), pero la idea importante: el servicio toma la decisión, y el puerto solo “proporciona datos de entrada” para la decisión.
Y esto se prueba perfectamente con una prueba unitaria normal: sustituimos el puerto y verificamos que el servicio eligió la transición de estado correcta. Aquí no necesitamos red, ni base de datos, ni Spring. Solo necesitamos un escenario honesto.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
class ArticleWorkflowServiceTest {
// Simulamos (mock) el puerto: este es el límite del mundo exterior para el servicio.
private final ModerationClient moderationClient = mock(ModerationClient.class);
// Probamos el servicio como lógica de negocio pura.
private final ArticleWorkflowService service = new ArticleWorkflowService(moderationClient);
@Test
void submitForReview_shouldReject_whenDecisionIsBlock() {
// Configuramos el escenario: el mundo exterior "dijo" BLOCK.
given(moderationClient.moderate(new ModerationRequest("T", "B")))
.willReturn(new ModerationDecision(ModerationResult.BLOCK, "spam"));
// Ejecutamos la acción de negocio.
ArticleStatus status = service.submitForReview("T", "B");
// Verificamos solo el resultado de negocio (sin URL/HTTP/JSON).
assertThat(status).isEqualTo(ArticleStatus.REJECTED);
}
}
Observe que en esta prueba no discutimos ni la URL ni el método HTTP. Esto es intencional. Si mañana el servicio externo se muda de /api/check a /api/v2/check, la regla de negocio “BLOCK significa REJECTED” no cambia. Por lo tanto, la prueba de negocio no debería fallar.
Y otro matiz: si en una prueba de servicio empiezas a verificar “qué JSON exacto se envió al exterior”, implícitamente conviertes la prueba de servicio en una prueba de adaptador. La prueba de servicio debe verificar el sentido de la decisión, no el protocolo de comunicación.
5. Qué verificamos en el adaptador
El adaptador técnico es el lugar donde la aplicación “se quita el traje de la lógica de dominio” y se pone el “traje del protocolo”. Y aquí es donde viven errores como “olvidamos una cabecera”, “el URI no es correcto”, “el campo en JSON se llamó mal”, “el media type no es correcto”, “confundimos el enum”, “no convertimos un 500 en una excepción comprensible”.
Es importante aceptar un hecho de antemano: el adaptador no es lógica de negocio. No debe decidir qué hacer con BLOCK. Debe poder obtener del mundo exterior el hecho BLOCK y devolverlo con cuidado en forma de dominio.
Aquí nos importa solo el papel del adaptador como traductor entre el dominio y HTTP. Por lo tanto, el código siguiente es esquemático: los detalles de baseUrl, DTO externo y captura de solicitudes de prueba se omiten por ahora para no mezclar el límite con la técnica de verificación.
El esqueleto del adaptador para moderación en ContentHub podría verse así:
import org.springframework.web.client.RestClient;
public final class RestClientModerationClient implements ModerationClient {
// Dependencia técnica: Spring RestClient, HTTP, serialización, etc.
private final RestClient restClient;
public RestClientModerationClient(RestClient restClient) {
// Importante: al adaptador se le permite conocer RestClient, pero al servicio no.
this.restClient = restClient;
}
}
Y la lógica de la llamada es precisamente el “traductor técnico”:
public ModerationDecision moderate(ModerationRequest request) {
// Aquí se fija el contrato: método, URI, payload y tipo de respuesta esperada.
return restClient.post()
.uri("/api/check") // Importante probar: ruta correcta y versión de la API.
.body(request) // Importante probar: payload JSON correcto a partir del DTO de dominio.
.retrieve() // Importante probar: cómo manejamos estados y errores.
.body(ModerationDecision.class); // Esquemático: la capa completa de DTO y mapeo se omite deliberadamente aquí.
}
Aquí ya aparecen cosas que no interesan a la capa de negocio, pero que son muy relevantes para la prueba del adaptador. Por ejemplo, la ruta correcta "/api/check", el método HTTP POST, el Content-Type correcto, el payload JSON correcto, el mapeo correcto de la respuesta. Y por separado: la traducción de errores — si el servicio externo devuelve 500 o basura en lugar de JSON, el adaptador no debe solo “fallar como cayó”, sino traducir el problema técnico a una forma comprensible para la aplicación.
Si el transporte se rompe, el adaptador también debe traducir el fallo técnico en una excepción comprensible para la aplicación. El código de negocio no necesita una colección de HttpServerErrorException, SocketTimeoutException y compañía; necesita una señal clara de que la moderación no está disponible o la respuesta está corrupta.
Y aquí hay una distinción importante: en el adaptador probamos la traducción, en el servicio probamos la reacción al resultado traducido. Si el adaptador ante un error del servidor lanza ModerationUnavailableException, entonces la prueba de servicio puede verificar “ante ModerationUnavailableException permanecemos en DRAFT”, y no necesita saber si fue un 500, timeout o una caída de DNS.
6. Tabla: cambio → prueba
Cuando el proyecto crece, uno quiere “cubrirse” y verificar todo en todas partes. El resultado es predecible: las pruebas se vuelven muchas, lentas, y la caída de cualquiera de ellas no explica qué se rompió exactamente. Es mucho más rentable tener una pequeña tabla mental: qué cambió — dónde lo verificamos.
Lo siguiente no es un dogma, sino una guía práctica para ContentHub y para nuestro curso:
| Qué cambió en el código | Dónde verificarlo | Por qué allí y no en otro lugar |
|---|---|---|
| Cambiamos una regla de workflow (por ejemplo, reacción a BLOCK) | Prueba unitaria del servicio (ArticleWorkflowServiceTest) | Es una decisión de negocio y no necesita el mundo HTTP |
| Cambiamos el formato de la respuesta externa (agregamos/renombramos un campo) | Prueba del adaptador de moderación | Es una cuestión de mapeo del payload externo |
| Cambiamos URI/método/cabeceras de la solicitud externa | Prueba del adaptador de moderación | Es el contrato de comunicación con el servicio externo |
| Cambiamos la traducción de errores de transporte (cómo envolvemos 500/timeout) | Prueba del adaptador + prueba unitaria de reacción del servicio | Traducción — adaptador, reacción — servicio |
| Cambiamos “dónde escribir el archivo” o la estructura de guardado de adjuntos | Prueba del adaptador de almacenamiento | Es el límite técnico del sistema de archivos |
| Cambiamos “cuándo enviar la notificación” | Prueba unitaria del servicio (sustituimos el puerto) | La decisión de enviar la notificación es parte del negocio |
Si te fijas, aquí se repite constantemente la misma idea: no intentamos demostrarlo todo con una sola prueba. Demostramos exactamente lo que corresponde a la responsabilidad elegida.
7. Efectos observables: capturing fake
Una de las principales razones por las que las pruebas sobre cosas externas se vuelven extrañas es la ausencia de un efecto observable. Si un método “envía algo”, pero no podemos ver qué se envió exactamente, empezamos a verificar los detalles internos de la implementación. Y eso es un camino directo a pruebas frágiles: cambiamos la implementación — las pruebas fallan, aunque el comportamiento no haya cambiado.
Un truco simple que suele salvar a los principiantes: capturing fake. No es un mock que verifica la interacción mediante verify(...), sino una pequeña implementación real de la interfaz que simplemente registra el hecho de la llamada en una colección. Es agradable de leer y no se rompe porque hayas cambiado el orden de las líneas.
Por ejemplo, para PublicationNotificationSender:
import java.util.ArrayList;
import java.util.List;
public class CapturingNotificationSender implements PublicationNotificationSender {
// Almacenamos el efecto observable: qué articleIds "solicitaron enviar".
private final List<Long> sentIds = new ArrayList<>();
@Override
public void sendPublished(long articleId) {
// Registramos el hecho de la llamada sin hilos, colas ni llamadas de red.
sentIds.add(articleId);
}
public List<Long> sentIds() {
// Devolvemos una copia para que las pruebas no puedan "accidentalmente" alterar el estado.
return List.copyOf(sentIds);
}
}
Con este objeto, la prueba del servicio se vuelve “humana”. No “creemos” que el envío ocurrió, ni entramos en detalles de hilos. Verificamos el efecto observable: el identificador del artículo apareció en sentIds().
Para no arrastrar aquí todo el workflow real, tomemos un mini-corte separado del servicio de publicación: ya tiene tanto moderationClient como sender. Para este escenario, la moderación no participa, así que colocaremos un stub explícito, no null.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PublicationFlowTest {
@Test
void shouldSendNotificationRequestWhenPublished() {
CapturingNotificationSender sender = new CapturingNotificationSender();
ModerationClient unusedModerationClient =
request -> new ModerationDecision(ModerationResult.OK, null);
ArticleWorkflowService service =
new ArticleWorkflowService(unusedModerationClient, sender);
// Acción de dominio: publicación del artículo.
service.publish(42L);
// Verificamos el efecto observable: se solicitó el envío de la notificación.
assertThat(sender.sentIds()).containsExactly(42L);
}
}
Sí, en el código real ArticleWorkflowService tendrá más dependencias, y publish(...) trabajará con la entidad/repositorio. Pero la técnica en sí es importante: hicimos que el efecto fuera observable sin convertir la prueba en un “escáner de internos”.
El mismo razonamiento funciona para el almacenamiento: un buen puerto “devolverá un descriptor del resultado” (por ejemplo, StoredFile o AttachmentId), y ese será su efecto observable. Entonces la prueba del servicio no tiene que verificar si el archivo está en el disco: verifica que el servicio utilizó correctamente el resultado del puerto.
8. Errores típicos al trabajar con límites
Error nº1: el servicio depende directamente de RestClient, Files o alguna clase “técnica”.
Parece un ahorro de código (“¿para qué un interfaz si solo tengo un cliente?”), pero la testabilidad se desploma al instante. Cualquier prueba unitaria del servicio de repente requiere red, sistema de archivos o un mock complejo de detalles de bajo nivel. Al final, o te refugias en @SpringBootTest “por si acaso”, o dejas de escribir pruebas sobre decisiones de negocio por completo.
Error nº2: el adaptador se vuelve “inteligente” y empieza a tomar decisiones de negocio.
A veces un desarrollador piensa: “ya que obtuve BLOCK, entonces aquí mismo convierto el artículo a REJECTED”. Resulta una mezcla: la capa técnica conoce los estados de dominio, la capa de dominio comienza a depender del formato de la respuesta externa, y las pruebas se ven obligadas a verificar todo a la vez. El esquema correcto es el contrario: el adaptador traduce los datos, el servicio toma la decisión.
Error nº3: una sola prueba intenta verificar a la vez la reacción de negocio, el contrato HTTP y la traducción de errores.
Esa prueba suele ser larga, costosa y explica mal la caída. Si falla, no está claro si se rompió la URL, el mapeo JSON o la rama de negocio. Es mucho mejor tener dos pruebas cortas: una sobre el servicio (reacción) y otra sobre el adaptador (contrato y traducción).
Error nº4: en la prueba del servicio se verifican demasiados “detalles de llamada” al puerto.
Verificar que el servicio realmente llamó a moderationClient.moderate(...) normalmente tiene sentido. Pero si empiezas a afirmar “la solicitud tenía exactamente 17 espacios” o “el body se serializó exactamente así”, estás convirtiendo imperceptiblemente la prueba del servicio en una prueba de protocolo. Esto hace que el suite sea frágil: cambiaste el DTO o la forma de construir la solicitud, y la prueba de negocio falla sin una razón real.
Error nº5: los efectos externos no se hacen observables, y las pruebas se ven forzadas a meterse en la implementación interna.
Cuando no hay nada que verificar desde fuera, la mano tiende a usar verify(...) para cualquier cosa, verificar el orden de las llamadas y mockear media infraestructura. Al final, la prueba no verifica el comportamiento, sino el guion de ejecución de las líneas. Capturing fake, un resultado explícito del puerto y DTO de dominio bien diseñados suelen resolver este problema de manera mucho más simple y barata.
GO TO FULL VERSION