Test Infrastructure
El framework de tests del engine Zeebe se basa en 3 abstracciones:
EngineRule(JUnit Rule que setupea un engine in-memory),RecordingExporter(mock exporter que captura todos los events para assertions), y fluent builders (BPMN, deployment, processInstance). La API permite que cada test sea conciso y declarativo.
EngineRule — el rule de JUnit¶
EngineRule es el centro del framework. Setupea un engine completo in-memory:
Configuraciones disponibles:
// Single partition (más común)
EngineRule.singlePartition()
// Multi-partition
EngineRule.multiplePartitions(3)
// Shared storage (para tests de replay)
EngineRule.withSharedStorage(sharedStorage)
// Con modo específico
.withStreamProcessorMode(StreamProcessorMode.REPLAY)
.withStreamProcessorMode(StreamProcessorMode.PROCESSING)
Lo que provee¶
- Stream processor inicializado
- State store in-memory (in-memory RocksDB o equivalente)
- Writers (StateWriter, CommandWriter, etc.)
- Exporter mock (RecordingExporter)
- Time control (clock inyectable)
- API fluent para acciones
Fluent API para BPMN¶
Antes de tests, los modelos BPMN se construyen con Bpmn builder:
Bpmn.createExecutableProcess("process")
.startEvent()
.serviceTask("task", t -> t.zeebeJobType("type"))
.exclusiveGateway("decision")
.condition("=amount > 100", "approve")
.endEvent("approved")
.moveToLastGateway()
.defaultFlow().endEvent("rejected")
.done()
Cada método retorna un builder fluent. Ventajas vs XML literal: - Type-safe (errores en compile time, no en runtime) - Refactor-friendly - Conciso (10 líneas vs 50 líneas de XML)
Fluent API para deployment¶
O con varios resources:
engineRule
.deployment()
.withXmlResource(bpmn1)
.withXmlResource(bpmn2)
.withDmnResource(dmn)
.deploy();
Fluent API para process instances¶
final var processInstanceKey =
engineRule.processInstance()
.ofBpmnProcessId("process")
.withVariable("amount", 150)
.create();
Variantes:
- .withVariables(map) — múltiples variables
- .ofProcessDefinitionKey(key) — por key en vez de bpmnProcessId
- .atSpecificStartEvent("eventId") — start instructions
- .createWithAwaitingResult() — espera resultado
RecordingExporter — assertions sobre events¶
Todos los events generados por el engine pasan por RecordingExporter, que los acumula en memoria y provee una API fluent para filtrarlos:
RecordingExporter.processInstanceRecords(ProcessInstanceIntent.ELEMENT_COMPLETED)
.withProcessInstanceKey(pid)
.withElementType(BpmnElementType.PROCESS)
.await();
Filtros disponibles¶
.withProcessInstanceKey(key)
.withBpmnProcessId("process")
.withElementType(BpmnElementType.SERVICE_TASK)
.withElementId("task1")
.withIntent(ProcessInstanceIntent.ELEMENT_ACTIVATED)
.withValueType(ValueType.PROCESS_INSTANCE)
.withTenantId("tenant1")
Operaciones¶
.await() // espera hasta que aparezca el primer match
.limit(n) // toma los primeros N matches
.findFirst() // optional con el primero
.count() // cuenta sin esperar
.toList() // todos los matches actuales
Multiple records assertion¶
List<Record<ProcessInstanceRecordValue>> records =
RecordingExporter.processInstanceRecords()
.withProcessInstanceKey(pid)
.limit(10)
.asList();
assertThat(records).hasSize(10);
assertThat(records).extracting(Record::getIntent)
.containsExactly(
ACTIVATE_ELEMENT, ELEMENT_ACTIVATING, ELEMENT_ACTIVATED, ...
);
Time control¶
Clock es inyectable en EngineRule:
engineRule.getClock().pinTime(Instant.parse("2024-01-01T00:00:00Z"));
engineRule.getClock().advanceTime(Duration.ofMinutes(10));
Esto permite testear timers sin tener que esperar realmente:
@Test
public void shouldFireTimerAfter5Minutes() {
deployTimerProcess();
createInstance();
// avanzar el clock virtualmente
engineRule.getClock().advanceTime(Duration.ofMinutes(5));
RecordingExporter.timerRecords(TimerIntent.TRIGGERED)
.await();
}
Job activation¶
final var activatedJobs = engineRule.jobs()
.ofType("payment")
.activate(maxJobs=5);
assertThat(activatedJobs).hasSize(3);
engineRule.job()
.ofKey(activatedJobs.get(0).getKey())
.complete(Map.of("result", "approved"));
Test typical pattern¶
@Test
public void shouldCompleteServiceTask() {
// GIVEN: deploy un proceso con service task
engineRule.deployment()
.withXmlResource(Bpmn.createExecutableProcess("process")
.startEvent()
.serviceTask("task", t -> t.zeebeJobType("payment"))
.endEvent()
.done())
.deploy();
// WHEN: crear instance + completar job
final var pid = engineRule.processInstance()
.ofBpmnProcessId("process")
.create();
final var job = engineRule.jobs()
.ofType("payment")
.activate(1)
.get(0);
engineRule.job().ofKey(job.getKey()).complete();
// THEN: proceso completa
RecordingExporter.processInstanceRecords(ELEMENT_COMPLETED)
.withProcessInstanceKey(pid)
.withElementType(BpmnElementType.PROCESS)
.await();
}
15 líneas para un test completo end-to-end. La fluent API hace que el código de test sea casi self-documenting.
Implicaciones para el MVP¶
Inversión inicial: construir el framework de tests¶
Antes de escribir tests, el MVP necesita:
- Engine harness: arrancar el engine in-memory con state limpio
- BPMN builder fluent: construir modelos sin XML literal
- Recording exporter: capturar events para assertions
- Time control: avanzar clock virtualmente
- Job/process instance APIs: fluent builders
Esto es ~500-1000 LOC de test infrastructure antes del primer test productivo.
Beneficios a largo plazo¶
- Tests cortos y legibles (10-15 líneas cada uno)
- Refactoring sin romper tests (api fluent es estable)
- Property-based testing más fácil (random inputs sobre la misma API)
- Onboarding de nuevos desarrolladores acelerado
Trade-off¶
Para un MVP minimalista, considerar: - Empezar con tests simples directos contra la API REST - Construir la infrastructure cuando hayan ~30+ tests - Refactorizar tests existentes a la nueva API en batch
Esto evita over-engineering temprano cuando el design del engine aún cambia frecuentemente.