Saltar a contenido

Property Based Testing

Zeebe usa property-based testing con BPMN models generados aleatoriamente y execution paths aleatorios. Por defecto: 6 procesos × 30 paths = 180 test cases. La propiedad universal: "todo proceso BPMN válido, ejecutado por cualquier path válido, debe completarse y tener tree paths poblados". Detecta bugs en processors raramente ejercitados que tests específicos pueden no cubrir.

La idea fundamental

En vez de testear casos específicos (test-by-example), property tests generan inputs aleatorios y verifican que una propiedad universal se mantenga.

Comparación

// Test-by-example (típico)
@Test
public void shouldCompleteSimpleProcess() {
    deploy(simpleBpmn);
    var pid = createInstance(simpleBpmn);
    assertCompletes(pid);
}

// Property-based (Zeebe approach)
@Test
@Parameterized
public void shouldExecuteProcessToEnd(TestDataRecord record) {  // record = random
    record.getBpmnModels().forEach(deployment::withXmlResource);
    deployment.deploy();

    record.getExecutionPath().getSteps().forEach(processExecutor::applyStep);

    // Propiedad: cualquier proceso válido + cualquier path válido debe completar
    assertProcessCompletes(record);
    assertAllActivatingElementsHaveTreePathPopulated(record.getProcessId());
}

Configuración en Zeebe

private static final String PROCESS_COUNT = System.getProperty("processCount", "6");
private static final String EXECUTION_PATH_COUNT = System.getProperty("executionCount", "30");

Defaults: 6 procesos × 30 paths = 180 test cases por run.

Comentario del código:

"With 10 processes and 100 paths there is a theoretical maximum of 1000 records. However, in tests the number of actual records was around 300, which can execute in about 1 m."

Conclusión: scaling más allá de 6×30 da retornos decrecientes porque pocos procesos tienen 100 paths únicos.

Las dos propiedades testeadas

1. ProcessExecutionRandomizedPropertyTest

Propiedad: ejecución completa

∀ valid_bpmn, ∀ valid_path: 
    deploy(bpmn) ∧ execute(path) ⇒ ProcessCompletedEvent

Plus: todos los activating elements deben tener tree paths poblados.

2. ReplayStateRandomizedPropertyTest

Propiedad: replay determinism

∀ valid_bpmn, ∀ valid_path:
    processing_state(bpmn, path) = replay_state(log_of(processing(bpmn, path)))

Ver concepts/replay-determinism para detalles.

Generación de procesos

TestDataGenerator.generateTestRecords(processCount, pathCount) genera:

  1. BPMN models aleatorios con elementos válidos combinados aleatoriamente
  2. Execution paths aleatorios dentro de cada model (cómo se va a ejecutar)

Sin código del generator a la vista, pero por el nombre se infiere: - Bloques de elementos (tasks, gateways, events) - Profundidad limitada - Branching limitado (para evitar explosión exponencial)

Reproducibilidad de failures

Cuando un property test falla, no es obvio cómo reproducir el bug — el input fue aleatorio.

Solución: FailedPropertyBasedTestDataPrinter

@Rule
public TestWatcher failedTestDataPrinter =
    new FailedPropertyBasedTestDataPrinter(this::getDataRecord);

JUnit TestWatcher que se activa en failure y imprime los datos del test (BPMN XML + execution path) para reproducción manual.

Hay también un mecanismo para re-ejecutar un test específico con seed conocido:

// (comentado en el código pero disponible)
return List.of(
    TestDataGenerator.regenerateTestRecord(-8532388551768899121L, -6565756334590616537L));

Dos seeds (long) deterministicamente reproducen un test case específico.

Ventajas vs test-by-example

Aspecto Test-by-example Property-based
Coverage Casos específicos elegidos Espacio de inputs (sample)
Bugs sutiles Difíciles de anticipar Encontrados frecuentemente
Mantenimiento Tests rotos al refactorizar Propiedades estables
Documentación Casos individuales Invariantes universales
Costo Bajo por test Alto por test (más tiempo)

Limitaciones

  1. No reemplaza tests específicos: bugs conocidos deben tener regression tests con casos exactos
  2. Random seed dependency: un bug intermitente solo se encuentra si el seed lo expone
  3. Lentos: 180 tests × ~300 records cada uno = minutos de ejecución
  4. Difíciles de debug: input aleatorio requiere herramientas para capturarlo

Implicaciones para el MVP

Tests que el MVP debería tener

Property tests prioritarios:

  1. Process completion: cualquier proceso válido + path válido → completes
  2. Replay determinism: processing state = replay state
  3. State consistency: variable counts, element counts coherentes después de execution
  4. Idempotency: re-procesar el mismo command (mismo position) no cambia state

Frameworks recomendados: - Java: jqwik, junit-quickcheck - Python: Hypothesis - TypeScript: fast-check - Rust: proptest, quickcheck

Pattern de implementación

# Pseudo-code para el MVP
@given(
    bpmn_model=valid_bpmn_models(max_elements=10, max_depth=4),
    execution_path=valid_paths()
)
def test_process_completes(bpmn_model, execution_path):
    engine.deploy(bpmn_model)
    pid = engine.create_instance(bpmn_model.process_id)

    for step in execution_path.steps:
        execute_step(step, pid)

    assert process_completed(pid), f"Failed for {bpmn_model}, {execution_path}"

Trade-off

Property-based testing requiere construir: - Un generator de BPMN models válidos (no trivial — debe respetar BPMN semantics) - Un generator de execution paths (debe ser consistente con el model) - Helpers para reproducción de failures

Esto es inversión inicial significativa. Para un MVP minimalista, empezar con test-by-example + replay determinism test. Agregar property-based testing cuando el modelo BPMN soportado se estabilice.