Un proceso de pedido puede pasar todos los tests unitarios y aun así enviar paquetes que nadie ha pagado. La causa no es un descuido, sino el sitio donde vive la lógica: en un modelo de proceso, en el montaje de un job, en una configuración. Este artículo muestra, sobre una tienda online de camisetas, cómo montas ese nivel de prueba, con Flowable 8 para el proceso de pedido, con Spring Batch 6 para la ejecución nocturna de envíos, y cómo conviertes los escenarios de calidad del capítulo 10 de arc42 en tests ejecutables.
Contenido
- El pedido que nadie llegó a entregar
- Por qué todos los tests unitarios seguían en verde
- La regla de selección para este nivel de prueba
- Dos clases de promesas
- Requisitos
- El proceso del carrito al paquete
- Paso 1: arrancar el proceso en un motor real
- Paso 2: el camino que sale bien
- Paso 3: el pago falla
- Paso 4: la camiseta no está en stock
- Paso 5: la entrega fracasa
- El punto de inflexión: test en verde, proceso incorrecto
- La clase de test completa
- Las consultas que necesitas en el test
- El segundo artefacto: la ejecución nocturna de envíos
- Escenarios de calidad de arc42 como test ejecutable
- ¿Necesitas Cucumber para esto?
- ¿Cuántos tests corresponden a este nivel?
- Límites: lo que este nivel de prueba no hace
- FAQ
- Conclusión
- Fuentes
El pedido que nadie llegó a entregar
La tienda vende camisetas estampadas. Un pedido recorre siempre las mismas estaciones: cobrar el pago, reservar stock, generar la etiqueta de envío, entregar el paquete al transportista, esperar la confirmación de entrega. Todo eso está modelado como proceso BPMN y lo ejecuta un motor de procesos. La lógica de negocio vive en beans de Spring que cuelgan de los Service Tasks.
Un martes se descubre que un pedido con la tarjeta rechazada recibió igualmente una etiqueta. El paquete salió, el importe no se cobró nunca. Es un caso aislado, pero se puede reproducir: cuando el servicio de pago comunica el rechazo como resultado de negocio en lugar de como excepción, el proceso toma la salida equivocada en el Gateway.
El fallo está en una condición del modelo de proceso. No está en ninguna clase de Java.
Por qué todos los tests unitarios seguían en verde
La cobertura de tests del proyecto es buena. El servicio de pago tiene tests para tarjetas aceptadas y rechazadas. El servicio de stock tiene tests para artículos disponibles y agotados. El servicio de etiquetas tiene tests para direcciones válidas e inválidas. Cada uno de esos tests comprueba exactamente aquello de lo que su clase es responsable, y cada uno está justificado.
Ninguno de ellos ejecuta el modelo de proceso. El orden de las estaciones, las condiciones en los Gateways, el comportamiento ante el temporizador: todo eso está descrito en un fichero XML que el motor interpreta en tiempo de ejecución. Para el compilador de Java ese fichero es un recurso, igual que una imagen.
La misma laguna aparece allí donde un artefacto al lado del código carga con las decisiones. Un job por lotes cuyo comportamiento surge de la combinación de Reader, Processor, Writer y tamaño del Chunk. Una migración de base de datos cuyo efecto solo se ve sobre el esquema real. Una tabla de reglas, un chart de Helm, una configuración de enrutado. El código de alrededor está probado, el artefacto no lo ha ejecutado nunca nadie.
La regla de selección para este nivel de prueba
¿Qué afirmación sobre el producto solo se vuelve cierta cuando todas las piezas funcionan juntas? En esa pregunta se decide qué pertenece a este nivel.
Que una tarjeta rechazada lleve a un rechazo es una afirmación sobre el servicio de pago. Va en un test unitario, y ahí queda comprobada en milisegundos.
Que una tarjeta rechazada no lleve nunca a una etiqueta de envío es una afirmación sobre el conjunto. Solo se vuelve cierta cuando el modelo de proceso, la condición del Gateway y el servicio de pago juegan juntos. Ninguna clase por separado puede cumplirla, y ningún mock puede demostrarla, porque el mock es justo la suposición que aquí está en discusión.
Todo lo que tiene la primera forma se queda abajo en la pirámide, donde es rápido y barato. Solo la segunda forma justifica un test que arranca un motor.
Dos clases de promesas
Si aplicas la regla con constancia, quedan dos grupos, y se comportan de forma distinta.
Las promesas de negocio solo surgen en el conjunto de varios componentes. Expresamente no todos los criterios de aceptación, sino solo aquellos que cruzan fronteras de componente: ninguna etiqueta sin pago, ninguna devolución sin reembolso, ninguna reserva que se quede colgada tras una cancelación. Esta capa es cara, porque cada test necesita un entorno de ejecución, y por eso puede quedarse pequeña.
Las promesas de calidad no van sobre el qué, sino sobre el cómo de bien. Tiempo de respuesta, rendimiento, comportamiento al reiniciar, recuperación tras una caída. Si en tu proyecto hay una documentación arc42, esa parte ya está escrita. El capítulo 10 se llama requisitos de calidad y se divide en el árbol de calidad y los escenarios de calidad. Un escenario de calidad deja fijado cómo debe comportarse el sistema ante un evento concreto, y lo bastante concreto como para poder decidir después si funcionó. Un test necesita justo eso mismo: un estímulo, un comportamiento esperado y una cifra contra la que comprobar ambos.
En la mayoría de proyectos este segundo grupo se queda en un documento. Un objetivo de calidad que nadie vuelve a medir es una declaración de intenciones.
Requisitos
El artículo usa el estado de agosto de 2026.
- Java 25. Spring Batch 6 exige como mínimo Java 21, Flowable se apaña con menos.
- JUnit 5. Flowable 8 eliminó el soporte para JUnit 3 y 4, obsoleto desde 7.2. Spring Batch 6 también dejó de admitir JUnit 4.
- Flowable 8.0.0, publicado el 27 de febrero de 2026, con soporte oficial para Spring Boot 4 y Spring 7.
- Spring Boot 4.1, publicado el 10 de junio de 2026.
- Spring Batch 6.0.4 para la segunda parte.
- Docker, aunque no para el motor. El motor de procesos corre embebido en el test. Docker lo necesitas para la base de datos de Testcontainers, porque una base de datos en memoria dentro del test no dice nada sobre el esquema real.
El núcleo es una única dependencia que trae todos los motores y la integración con Spring.
<dependency>
<groupId>org.flowable</groupId>
<artifactId>flowable-spring-boot-starter</artifactId>
</dependency>El starter despliega las definiciones de proceso de forma automática en cuanto están bajo classpath*:/processes/ y terminan en .bpmn20.xml o .bpmn. Así que en el test no necesitas ninguna anotación de despliegue propia.
Un ajuste tiene que ir en la configuración de test, si no el test se vuelve poco fiable:
flowable.async-executor-activate=falseCon eso el executor asíncrono no procesa temporizadores en segundo plano. Suena a limitación, pero es la condición para que en el test seas tú quien tiene el tiempo en la mano. Cómo se ve eso está más abajo, en el punto de inflexión.
El proceso del carrito al paquete
El proceso se llama shirt-order y tiene este aspecto. Las cajas son Service Tasks, los rombos son Gateways, y la caja discontinua espera un evento externo. Bajo cada etiqueta figura la ID de elemento del XML de BPMN, y esas mismas IDs reaparecen enseguida en los tests.
Lo interesante de este modelo no es el camino de arriba, sino que el reembolso cuelga de dos sitios y que hay tres estados finales distintos. Justo ahí se decide si el proceso es correcto.
Los Service Tasks llaman a beans de Spring mediante expresiones y escriben el resultado en una variable de proceso. Para el pago, en el modelo se ve así:
<serviceTask id="charge-payment" name="Cobrar el pago"
flowable:expression="${paymentService.charge(orderId, amount)}"
flowable:resultVariable="paymentAccepted"/>El Gateway que viene detrás decide sobre ${paymentAccepted}. Justo esa línea estaba mal en el caso del fallo, y justo esa no la comprueba ningún test unitario.
Paso 1: arrancar el proceso en un motor real
El test carga el contexto completo de Spring, sustituye los beans de negocio por mocks y habla con el motor a través de sus servicios. Fíjate en @MockitoBean: el antiguo @MockBean desapareció con Spring Boot 4, y la anotación vive ahora bajo org.springframework.test.context.bean.override.mockito.
@SpringBootTest
class ShirtOrderProcessTest {
@Autowired
private RuntimeService runtimeService;
@Autowired
private HistoryService historyService;
@MockitoBean
private PaymentService paymentService;
@MockitoBean
private StockService stockService;
private ProcessInstance startOrder() {
return runtimeService.startProcessInstanceByKey("shirt-order",
Map.of("orderId", "A-1001", "sku", "shirt-navy-l", "amount", 2990L));
}
}El motor ejecuta el modelo real, con sus Gateways, sus temporizadores y su manejo de errores. Lo único que se sustituye es el trabajo que hay detrás de una estación, no el flujo en sí. Esa es la diferencia decisiva con un test unitario con mocks: aquí el objeto bajo prueba es el modelo, no la clase.
Dos pequeños helpers te quitan la repetición en todos los tests siguientes.
private boolean passed(String processInstanceId, String activityId) {
return historyService.createHistoricActivityInstanceQuery()
.processInstanceId(processInstanceId)
.activityId(activityId)
.finished()
.count() > 0;
}
private boolean isFinished(String processInstanceId) {
return historyService.createHistoricProcessInstanceQuery()
.processInstanceId(processInstanceId)
.finished()
.count() == 1;
}Paso 2: el camino que sale bien
El primer test fija que un pedido pagado con una camiseta en stock llega al cliente. El proceso espera la confirmación de entrega en un Receive Task que se dispara desde fuera.
@Test
void shouldShipOrderWhenPaymentAcceptedAndShirtInStock() {
when(paymentService.charge("A-1001", 2990L)).thenReturn(true);
when(stockService.reserve("shirt-navy-l")).thenReturn(true);
ProcessInstance instance = startOrder();
Execution waiting = runtimeService.createExecutionQuery()
.processInstanceId(instance.getId())
.activityId("await-delivery")
.singleResult();
runtimeService.trigger(waiting.getId(), Map.of("delivered", true));
assertTrue(isFinished(instance.getId()));
assertTrue(passed(instance.getId(), "create-label"));
assertTrue(passed(instance.getId(), "hand-over-parcel"));
}Este test es el menos importante de todo el artículo. Demuestra que el proceso funciona en lo básico, y también habría seguido en verde con el modelo roto del primer capítulo.
Paso 3: el pago falla
El fallo de la escena se puede formular como test. Lo importante son las tres últimas líneas.
@Test
void shouldNotCreateLabelWhenPaymentDeclined() {
when(paymentService.charge("A-1001", 2990L)).thenReturn(false);
ProcessInstance instance = startOrder();
assertTrue(isFinished(instance.getId()));
assertTrue(passed(instance.getId(), "payment-declined"));
assertFalse(passed(instance.getId(), "reserve-stock"));
assertFalse(passed(instance.getId(), "create-label"));
assertFalse(passed(instance.getId(), "hand-over-parcel"));
}La contraprueba es la assertion que más falta en la práctica. Un test que solo comprueba que el proceso terminó de alguna manera se queda igual de verde con un Gateway equivocado que con uno correcto. Solo la demostración de que la otra rama no se pisó siquiera convierte el test en una promesa.
Quédate con la forma: para cada rama va al test un camino recorrido y la contraprueba de todos los caminos que están prohibidos.
Paso 4: la camiseta no está en stock
El segundo camino de error es más interesante, porque aquí tiene que volver dinero. La promesa no es solo que el pedido se cancele, sino que el reembolso ocurra de verdad, y exactamente una vez.
@Test
void shouldRefundPaymentWhenShirtOutOfStock() {
when(paymentService.charge("A-1001", 2990L)).thenReturn(true);
when(stockService.reserve("shirt-navy-l")).thenReturn(false);
ProcessInstance instance = startOrder();
assertTrue(isFinished(instance.getId()));
assertTrue(passed(instance.getId(), "refund-payment"));
assertFalse(passed(instance.getId(), "create-label"));
verify(paymentService, times(1)).refund("A-1001");
}Aquí el times(1) es más que cosmética. Un reembolso doble es un daño real, y un modelo que tenga el task de reembolso colgado en un bucle sin querer no se nota de otra forma.
Paso 5: la entrega fracasa
La última rama une los dos caminos de error, porque usa el mismo task de reembolso que el paso 4.
@Test
void shouldBookReturnAndRefundWhenDeliveryFails() {
when(paymentService.charge("A-1001", 2990L)).thenReturn(true);
when(stockService.reserve("shirt-navy-l")).thenReturn(true);
ProcessInstance instance = startOrder();
Execution waiting = runtimeService.createExecutionQuery()
.processInstanceId(instance.getId())
.activityId("await-delivery")
.singleResult();
runtimeService.trigger(waiting.getId(), Map.of("delivered", false));
assertTrue(isFinished(instance.getId()));
assertTrue(passed(instance.getId(), "book-return"));
assertTrue(passed(instance.getId(), "refund-payment"));
verify(paymentService, times(1)).refund("A-1001");
}El punto de inflexión: test en verde, proceso incorrecto
A esta altura la clase de test parece completa. Cuatro caminos, cuatro tests en verde. Y aun así el modelo se puede cambiar de forma que los cuatro sigan en verde y el proceso esté mal.
Supón que alguien añade un temporizador: si la confirmación de entrega no llega en catorce días, el proceso debe anotar la devolución de forma automática. Ninguno de los tests de arriba cambia su resultado por eso, porque todos disparan el Receive Task de inmediato. El nuevo camino existe en el modelo y ninguna promesa lo toca.
Como el executor asíncrono está apagado en el test, el temporizador queda como Job en la base de datos y espera. Lo recoges y lo ejecutas sin esperar catorce días.
@Test
void shouldBookReturnWhenDeliveryIsNotConfirmedWithinFourteenDays() {
when(paymentService.charge("A-1001", 2990L)).thenReturn(true);
when(stockService.reserve("shirt-navy-l")).thenReturn(true);
ProcessInstance instance = startOrder();
Job timer = managementService.createTimerJobQuery()
.processInstanceId(instance.getId())
.singleResult();
assertNotNull(timer, "no hay temporizador en la entrega");
managementService.moveTimerToExecutableJob(timer.getId());
managementService.executeJob(timer.getId());
assertTrue(isFinished(instance.getId()));
assertTrue(passed(instance.getId(), "book-return"));
verify(paymentService, times(1)).refund("A-1001");
}La lección es que la lista de tests tiene que salir del modelo. Más tests no ayudan mientras nadie cuente las salidas. Cada salida, cada temporizador, cada rama de error es una promesa. Si a alguna le falta el test, esa promesa existe solo como dibujo.
La clase de test completa
Todo junto y con los imports queda así.
package com.example.shop.order;
import org.flowable.engine.HistoryService;
import org.flowable.engine.ManagementService;
import org.flowable.engine.RuntimeService;
import org.flowable.engine.runtime.Execution;
import org.flowable.engine.runtime.ProcessInstance;
import org.flowable.job.api.Job;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringBootTest
class ShirtOrderProcessTest {
private static final String ORDER_ID = "A-1001";
private static final String SKU = "shirt-navy-l";
private static final long AMOUNT = 2990L;
@Autowired
private RuntimeService runtimeService;
@Autowired
private HistoryService historyService;
@Autowired
private ManagementService managementService;
@MockitoBean
private PaymentService paymentService;
@MockitoBean
private StockService stockService;
@Test
void shouldShipOrderWhenPaymentAcceptedAndShirtInStock() {
acceptPaymentAndStock();
ProcessInstance instance = startOrder();
confirmDelivery(instance, true);
assertTrue(isFinished(instance.getId()));
assertTrue(passed(instance.getId(), "create-label"));
assertTrue(passed(instance.getId(), "hand-over-parcel"));
assertFalse(passed(instance.getId(), "book-return"));
assertFalse(passed(instance.getId(), "refund-payment"));
}
@Test
void shouldNotCreateLabelWhenPaymentDeclined() {
when(paymentService.charge(ORDER_ID, AMOUNT)).thenReturn(false);
ProcessInstance instance = startOrder();
assertTrue(isFinished(instance.getId()));
assertTrue(passed(instance.getId(), "payment-declined"));
assertFalse(passed(instance.getId(), "reserve-stock"));
assertFalse(passed(instance.getId(), "create-label"));
assertFalse(passed(instance.getId(), "hand-over-parcel"));
}
@Test
void shouldRefundPaymentWhenShirtOutOfStock() {
when(paymentService.charge(ORDER_ID, AMOUNT)).thenReturn(true);
when(stockService.reserve(SKU)).thenReturn(false);
ProcessInstance instance = startOrder();
assertTrue(isFinished(instance.getId()));
assertTrue(passed(instance.getId(), "refund-payment"));
assertFalse(passed(instance.getId(), "create-label"));
verify(paymentService, times(1)).refund(ORDER_ID);
}
@Test
void shouldBookReturnAndRefundWhenDeliveryFails() {
acceptPaymentAndStock();
ProcessInstance instance = startOrder();
confirmDelivery(instance, false);
assertTrue(isFinished(instance.getId()));
assertTrue(passed(instance.getId(), "book-return"));
assertTrue(passed(instance.getId(), "refund-payment"));
verify(paymentService, times(1)).refund(ORDER_ID);
}
@Test
void shouldBookReturnWhenDeliveryIsNotConfirmedWithinFourteenDays() {
acceptPaymentAndStock();
ProcessInstance instance = startOrder();
Job timer = managementService.createTimerJobQuery()
.processInstanceId(instance.getId())
.singleResult();
assertNotNull(timer, "no hay temporizador en la entrega");
managementService.moveTimerToExecutableJob(timer.getId());
managementService.executeJob(timer.getId());
assertTrue(isFinished(instance.getId()));
assertTrue(passed(instance.getId(), "book-return"));
verify(paymentService, times(1)).refund(ORDER_ID);
}
private void acceptPaymentAndStock() {
when(paymentService.charge(ORDER_ID, AMOUNT)).thenReturn(true);
when(stockService.reserve(SKU)).thenReturn(true);
}
private ProcessInstance startOrder() {
return runtimeService.startProcessInstanceByKey("shirt-order",
Map.of("orderId", ORDER_ID, "sku", SKU, "amount", AMOUNT));
}
private void confirmDelivery(ProcessInstance instance, boolean delivered) {
Execution waiting = runtimeService.createExecutionQuery()
.processInstanceId(instance.getId())
.activityId("await-delivery")
.singleResult();
runtimeService.trigger(waiting.getId(), Map.of("delivered", delivered));
}
private boolean passed(String processInstanceId, String activityId) {
return historyService.createHistoricActivityInstanceQuery()
.processInstanceId(processInstanceId)
.activityId(activityId)
.finished()
.count() > 0;
}
private boolean isFinished(String processInstanceId) {
return historyService.createHistoricProcessInstanceQuery()
.processInstanceId(processInstanceId)
.finished()
.count() == 1;
}
}Cinco tests, cinco promesas, y los nombres son las promesas.
Las consultas que necesitas en el test
Flowable no trae una biblioteca de assertions propia. Eso no es una carencia, solo significa que trabajas con las consultas normales del motor y formulas tú mismo la afirmación. El repertorio es abarcable.
| Consulta | Responde a |
|---|---|
historyService.createHistoricProcessInstanceQuery().finished() |
Si la instancia ha terminado |
historyService.createHistoricActivityInstanceQuery().activityId(id).finished() |
Si este elemento se ha recorrido |
historyService.createHistoricVariableInstanceQuery().variableName(name) |
Qué valor tenía una variable |
runtimeService.createProcessInstanceQuery().processInstanceId(id) |
Si la instancia sigue en marcha |
runtimeService.createExecutionQuery().activityId(id) |
Dónde espera la instancia justo ahora |
taskService.createTaskQuery().processInstanceId(id) |
Qué User Tasks quedan abiertas |
managementService.createTimerJobQuery().processInstanceId(id) |
Qué temporizador está esperando |
managementService.createDeadLetterJobQuery().processInstanceId(id) |
Qué ha fallado de forma definitiva |
La última fila es aquella en la que casi nadie piensa. Un Job acaba en la cola de mensajes fallidos cuando se agotan los reintentos, y la instancia se queda parada sin que nada se ponga en rojo. Por eso, en todo test que describa un camino correcto va la comprobación de que ahí no hay nada.
assertEquals(0, managementService.createDeadLetterJobQuery()
.processInstanceId(instance.getId())
.count());El segundo artefacto: la ejecución nocturna de envíos
El modelo de proceso no es el único artefacto de esta tienda. Por la noche corre un job por lotes que da de alta ante el transportista todos los paquetes listos para entregar, escribe de vuelta los números de envío y pone los pedidos en enviado. La lógica de negocio por pedido está encapsulada en un Processor y bien cubierta con tests unitarios. Lo que ahí no está es el comportamiento de la ejecución en conjunto: tamaño del Chunk, límites de commit, comportamiento ante un registro roto, reanudación tras una interrupción.
En Spring Batch 6 la API de test ha cambiado. JobLauncherTestUtils.launchJob() está marcado como obsoleto desde 6.0 y va a desaparecer en 6.2 o más adelante. En su lugar entra JobOperatorTestUtils.startJob(). La anotación @SpringBatchTest pone a disposición las dos clases de ayuda, es decir JobOperatorTestUtils y JobRepositoryTestUtils.
@SpringBootTest
@SpringBatchTest
class ParcelHandoverJobTest {
@Autowired
private JobOperatorTestUtils jobOperatorTestUtils;
@Autowired
private OrderRepository orders;
@Test
void shouldHandOverAllReadyParcelsInOneRun() {
orders.saveAll(readyOrders(250));
JobParameters parameters = new JobParametersBuilder()
.addLocalDate("runDate", LocalDate.of(2026, 8, 6))
.toJobParameters();
JobExecution execution = jobOperatorTestUtils.startJob(parameters);
assertEquals(ExitStatus.COMPLETED.getExitCode(), execution.getExitStatus().getExitCode());
assertEquals(250, orders.countByStatus(OrderStatus.HANDED_OVER));
}
}Pero la promesa de verdad está en el siguiente test. Una ejecución que se interrumpe con un registro roto no puede volver a dar de alta los paquetes ya entregados cuando arranca otra vez. Las altas dobles cuestan dinero y confianza.
@Test
void shouldNotHandOverParcelsTwiceAfterRestart() {
orders.saveAll(readyOrders(120));
orders.save(orderWithInvalidPostcode());
JobParameters parameters = new JobParametersBuilder()
.addLocalDate("runDate", LocalDate.of(2026, 8, 6))
.toJobParameters();
JobExecution failed = jobOperatorTestUtils.startJob(parameters);
assertEquals(ExitStatus.FAILED.getExitCode(), failed.getExitStatus().getExitCode());
orders.fixPostcode(orderWithInvalidPostcode());
JobExecution restarted = jobOperatorTestUtils.startJob(parameters);
assertEquals(ExitStatus.COMPLETED.getExitCode(), restarted.getExitStatus().getExitCode());
assertEquals(121, carrier.countRegistrations());
}Esta promesa solo se vuelve cierta cuando el Reader, el Writer, el límite del Chunk y el repositorio de jobs juegan juntos. Es el caso modelo de este nivel de prueba, y es la razón por la que la base de datos viene de Testcontainers y no de memoria: una reanudación que corre contra un esquema distinto del de producción no demuestra nada.
Escenarios de calidad de arc42 como test ejecutable
Hasta aquí se trataba de promesas de negocio. La segunda parte son las promesas de calidad, y el camino hasta allí es más corto de lo que la mayoría de proyectos supone.
El capítulo 10 organiza los objetivos de calidad como un árbol, ordenados por importancia, y de las hojas de ese árbol cuelgan los escenarios. Cada uno nombra un evento, el comportamiento esperado y la cifra con la que se comprueban ambos. Tres componentes, siempre los mismos:
| Parte del escenario | En el test |
|---|---|
| Estímulo | El montaje, es decir lo que el test prepara y dispara |
| Reacción | El comportamiento esperado, es decir la assertion |
| Medida | El número en la assertion |
Un escenario de la ejecución de envíos podría decir que una ejecución nocturna sobre todo el stock listo para entregar termina dentro de la ventana de mantenimiento. El estímulo es el arranque de la ejecución con un volumen de tamaño realista, la reacción es la finalización regular, la medida es la duración de la ventana de mantenimiento.
@Test
void shouldFinishNightlyRunWithinMaintenanceWindow() {
orders.saveAll(readyOrders(50_000));
Instant start = Instant.now();
JobExecution execution = jobOperatorTestUtils.startJob(parametersFor(LocalDate.now()));
Duration elapsed = Duration.between(start, Instant.now());
assertEquals(ExitStatus.COMPLETED.getExitCode(), execution.getExitStatus().getExitCode());
assertTrue(elapsed.compareTo(MAINTENANCE_WINDOW) < 0,
"la ejecución tardó " + elapsed + ", el máximo es " + MAINTENANCE_WINDOW);
}Para que esto no acabe siendo una molestia, esos tests no van en la misma pasada que los rápidos. Un grupo propio, separado con un tag de JUnit, y un sitio en la pipeline donde un tiempo de ejecución más largo no moleste a nadie.
La medida en un agente de build es de todos modos otra que en producción. Un test así sirve como umbral de regresión. Lo que atrapa es el empeoramiento respecto al último estado.
Y para los escenarios de calidad vale la misma regla de selección que para las promesas de negocio. Aquí solo entra lo que surge en el conjunto. El tiempo de ejecución de un método suelto lo mide mejor un microbenchmark.
Si mantienes en el nombre del test la numeración del árbol de calidad, en la lista de tests ves al momento qué objetivo está en rojo. Aun así, más valiosos que los tests en rojo son los objetivos para los que no hay ninguno: esa laguna es el verdadero rédito del ejercicio.
¿Necesitas Cucumber para esto?
Quien prueba criterios de aceptación acaba tarde o temprano en la pregunta por Gherkin. Cucumber traduce frases en forma Given-When-Then a definiciones de paso en el código, y las frases son legibles para la gente de negocio.
Lo valioso es el catálogo de promesas en sí. Un equipo que recoge escenarios en lenguaje de negocio tiene la mayor parte del trabajo hecha, sea cual sea la herramienta. Esa parte es valiosa y tiene poco que ver con Cucumber.
El precio es una capa adicional. Cada frase necesita una definición de paso, la correspondencia se desvía cuando la aplicación cambia, y en la práctica esa capa la mantienen los desarrolladores solos de todos modos. La legibilidad viene de la frase, no del formato:
@Test
void shouldNotCreateLabelWhenPaymentDeclined() { }Escenario: Sin etiqueta cuando el pago es rechazado
Dado que el pago fue rechazado
Cuando se procesa el pedido
Entonces no se genera ninguna etiqueta de envíoLas dos cosas dicen lo mismo. La segunda versión necesita para ello un fichero más, una biblioteca más y un nivel de correspondencia que hay que mantener.
Cucumber compensa cuando personas sin acceso al código leen o escriben de verdad los escenarios, por ejemplo en entornos regulados con una aceptación por parte de negocio. Si los escenarios solo los leen desarrolladores, un método de test con nombre expresivo es el medio más sencillo. Está siempre al día, porque es el mismo código que además se ejecuta.
¿Cuántos tests corresponden a este nivel?
Menos de los que sugiere el primer impulso. Una medida útil es el número de salidas del artefacto: cada estado final del proceso y cada temporizador. El proceso de ejemplo tiene tres estados finales y un temporizador, de ahí salen cinco tests, y esos cubren el modelo por completo.
Todo lo que se puede responder con un test unitario no pertenece aquí. La comprobación de si un código postal es válido pertenece al validador. El cálculo del franqueo pertenece al servicio de tarifas. Repetir esos tests en el conjunto cuesta tiempo de ejecución y no aporta ninguna afirmación adicional.
Un buen indicador de un nivel hinchado es la búsqueda de fallos: si un test en rojo no te acerca a la causa, sino que solo dice que algo en el conjunto no cuadra, probablemente esté colocado demasiado arriba.
Límites: lo que este nivel de prueba no hace
No sustituye a los tests unitarios. Un test del conjunto te dice que la promesa está rota, pero no qué línea tiene la culpa. Esa resolución la dan los tests rápidos de debajo, y sin ellos cada búsqueda de fallos se convierte en una excavación.
La observabilidad en producción tampoco sobra por esto. Ninguna ejecución de tests cubre los casos que solo aparecen con datos reales: la dirección con el salto de línea, el transportista que manda dos veces la misma confirmación. Lo que aquí está en verde está comprobado, no demostrado.
Es lento. Un contexto de Spring completo con motor y base de datos necesita tiempo, y el tiempo de ejecución sube con cada test. Por eso la capa se queda pequeña, y por eso la regla de selección importa más que cualquier assertion suelta.
Y no convierte un proceso mal modelado en un producto correcto. Un test fija lo que habéis prometido. Si esa promesa tenía sentido de negocio, eso no lo decide él.
FAQ
¿Cuál es la diferencia entre un test de integración y este nivel de prueba?
Un test de integración comprueba la interacción de piezas técnicas, por ejemplo aplicación y base de datos. Este nivel comprueba una promesa hecha al cliente que solo surge en el conjunto. Lo decisivo es la pregunta que el test responde. La técnica de detrás puede ser la misma.
¿Flowable es gratuito?
Los motores centrales para BPMN, DMN y CMMN están bajo Apache 2.0 y se pueden usar libremente, también de forma comercial. Lo que cuesta dinero son los añadidos Enterprise y el soporte del fabricante.
¿Por qué el executor asíncrono está apagado en el test?
Para que los temporizadores no se procesen en segundo plano mientras el test corre. El Job se queda entonces en la base de datos y tú lo ejecutas de forma dirigida a través del ManagementService. Eso hace el test determinista en lugar de dependiente del tiempo.
¿Necesito Docker para los tests de proceso?
Para el motor no, ese corre embebido. Para la base de datos sí, si como aquí pruebas con Testcontainers contra el mismo sistema de base de datos que en producción.
¿Tengo que pasarme ya a JobOperatorTestUtils en Spring Batch 6?
Ahora mismo no, pero pronto. JobLauncherTestUtils.launchJob() está marcado como obsoleto desde Spring Batch 6.0 y previsto para su eliminación en 6.2 o más adelante. El cambio afecta en lo esencial al nombre del método: launchJob pasa a ser startJob.
¿Cómo mantengo unidos los escenarios de calidad y los tests?
Por el nombre. Si el test lleva el número del escenario del árbol de calidad, cualquiera encuentra el camino del documento al código y de vuelta. Una referencia en ambos sentidos cuesta una línea y ahorra la búsqueda.
¿Vale esto también sin motor de procesos?
Sí. El patrón no depende de BPMN, sino de que un artefacto al lado del código cargue con las decisiones. Las tablas de reglas, las migraciones, las configuraciones de enrutado y los manifiestos de infraestructura tienen la misma propiedad.
Conclusión
La pirámide de tests sigue siendo correcta, solo le falta una punta pequeña para las afirmaciones que únicamente se vuelven ciertas en el conjunto. Bastan dos preguntas para llenarla: qué promesas surgen solo cuando todas las piezas funcionan juntas, y cuáles de ellas ya están como escenario de calidad en vuestra documentación.
El siguiente paso es pequeño. Coge el artefacto que en vuestro sistema carga con más decisiones, cuenta sus salidas y escribe para cada una un test cuyo nombre sea la promesa. Después coge el capítulo 10 de vuestra documentación de arquitectura y convierte el escenario de calidad de más arriba en un test. Si no, un objetivo de calidad que nadie vuelve a medir se queda en una declaración de intenciones.
Fuentes
- arc42, capítulo 10 requisitos de calidad, árbol de calidad y escenarios de calidad: arc42.de
- DokChess, proyecto de ejemplo público de arc42 con árbol de calidad completo y escenarios numerados: dokchess.de
- Flowable Open Source, integración con Spring Boot y soporte para tests: flowable.com
- Código de Flowable Open Source y licencia Apache 2.0: flowable.com
- Referencia de Spring Batch, unit testing y novedades de la versión 6: docs.spring.io
Todos los ejemplos de código son propios y están escritos para el estado de versiones indicado en los requisitos. El proceso de pedido es inventado y no refleja ningún proyecto real.