Saltar a contenido

Process testing framework

Tests para procesos BPMN: unit (sin engine), integration (engine in-memory), scenario (end-to-end con workers reales). Inspirado en zeebe-process-test pero adaptado a stack Go + Postgres.

El problema: BPMN sin tests = bugs en producción

Un proceso BPMN es código. Sin tests: - Cambios rompen happy path sin que nadie note. - Boundary errors no probados → uncaught en prod. - Variables typos detectados sólo cuando un cliente reporta el bug. - Refactors imposibles.

Zeebe community resolvió esto con zeebe-process-test — engine in-memory + JUnit assertions. Replicamos en Go con mejor DX.

Tres niveles de test

Nivel Engine Workers DB Velocidad Uso
Unit Mock / Stub Mock None < 1ms Validar lógica de un handler
Integration Engine in-memory Mock SQLite o stub 10-100ms Validar flow BPMN completo
Scenario Engine real Workers reales (Go) Postgres testcontainer 1-10s End-to-end pre-release
Load Engine real Workers reales Postgres prod-like minutos Performance, scaling

Nivel 1: Unit test del handler

El handler es Go puro. Se testea con mock del cliente:

package myworker_test

func TestChargePaymentSuccess(t *testing.T) {
    stripe := &mockStripe{
        chargeFunc: func(ctx context.Context, customer string, amount float64) (*Charge, error) {
            return &Charge{ID: "ch_123"}, nil
        },
    }
    handler := newChargeHandler(stripe)

    job := wfclienttest.NewJob().
        WithVariables(map[string]any{
            "customerId": "cus_abc",
            "amount":     99.99,
        }).
        Build()

    out, err := handler(context.Background(), job)
    require.NoError(t, err)
    require.Equal(t, "ch_123", out["chargeId"])
}

func TestChargePaymentInsufficientFunds(t *testing.T) {
    stripe := &mockStripe{
        chargeFunc: func(_ context.Context, _ string, _ float64) (*Charge, error) {
            return nil, stripeerrors.ErrInsufficientFunds
        },
    }
    handler := newChargeHandler(stripe)
    job := wfclienttest.NewJob().WithVariables(...).Build()

    _, err := handler(context.Background(), job)
    require.Error(t, err)
    require.True(t, wfclient.IsBPMNError(err))
    require.Equal(t, "insufficient-funds", wfclient.ErrorCode(err))
}

Cobertura típica: 100% del handler (todos los paths).

Nivel 2: Integration test (engine in-memory)

Aquí probamos el BPMN completo. Engine in-memory, workers mockeados, DB SQLite (o stub).

API target

package processtest

// NewEngine crea un engine in-memory para tests.
func NewEngine(t *testing.T, opts ...Option) *TestEngine

// TestEngine API
func (e *TestEngine) DeployProcess(path string) DeployedProcess
func (e *TestEngine) StartInstance(processID string, vars map[string]any) *Instance
func (e *TestEngine) StubJob(jobType string, handler StubHandler) // mock worker
func (e *TestEngine) PublishMessage(name, correlationKey string, vars map[string]any)
func (e *TestEngine) AdvanceTime(d time.Duration) // dispara timers
func (e *TestEngine) AssertInstance(i *Instance) InstanceAssertions

Test típico

func TestOrderHappyPath(t *testing.T) {
    eng := processtest.NewEngine(t)
    eng.DeployProcess("processes/order-flow.bpmn")

    // Stub workers: comportamiento esperado
    eng.StubJob("validate-order", processtest.Completes(map[string]any{
        "valid": true,
    }))
    eng.StubJob("charge-payment", processtest.Completes(map[string]any{
        "chargeId": "ch_test",
    }))
    eng.StubJob("ship-order", processtest.Completes(map[string]any{
        "trackingNumber": "TRK-123",
    }))

    inst := eng.StartInstance("order-flow", map[string]any{
        "orderId":    "O-1",
        "amount":     99.99,
        "customerId": "cust-42",
    })

    eng.AssertInstance(inst).
        IsCompleted().
        HasPassedElements("StartEvent_1", "validate-order", "charge-payment", "ship-order", "EndEvent_1").
        HasVariable("trackingNumber", "TRK-123")
}

func TestOrderInsufficientFunds(t *testing.T) {
    eng := processtest.NewEngine(t)
    eng.DeployProcess("processes/order-flow.bpmn")

    eng.StubJob("validate-order", processtest.Completes(map[string]any{"valid": true}))
    eng.StubJob("charge-payment", processtest.FailsWithBPMNError("insufficient-funds", "balance too low"))
    eng.StubJob("notify-customer-decline", processtest.Completes(nil))

    inst := eng.StartInstance("order-flow", map[string]any{"amount": 99.99})

    eng.AssertInstance(inst).
        IsCompleted().
        HasPassedElements("BoundaryEvent_InsufficientFunds", "notify-customer-decline").
        HasNotPassed("ship-order")
}

func TestTimerEscalation(t *testing.T) {
    eng := processtest.NewEngine(t)
    eng.DeployProcess("processes/approval-flow.bpmn")

    eng.StubUserTask("manager-approval", processtest.Pending())  // no se completa

    inst := eng.StartInstance("approval-flow", map[string]any{"requestId": "R-1"})

    eng.AdvanceTime(24 * time.Hour)  // dispara el timer

    eng.AssertInstance(inst).
        IsActive().
        HasPassedElements("BoundaryTimer_Escalate", "director-approval")
}

Assertions disponibles

type InstanceAssertions interface {
    IsActive()
    IsCompleted()
    IsCancelled()
    IsInIncident()
    HasPassedElements(...string)
    HasNotPassed(...string)
    HasActiveElement(string)
    HasVariable(name string, expected any)
    HasIncident(errorType string)
    AtPath(elementID string) ElementAssertions
}

type ElementAssertions interface {
    HasCompletedNTimes(n int)
    HasFailedWith(errorMessage string)
}

Engine in-memory: implementación

Stack: - Engine core: misma implementación pero con adapter de storage in-memory (no SQL). - SQLite alternativo si necesitamos persistencia para tests reproducible. - Time control: clock.NewMock() (similar a benbjohnson/clock). - No exporters externos; eventos buffereados en memoria para assertions.

// Build target en go.mod
//go:build test_inmemory

package storage

func New() Store {
    return &inMemoryStore{
        instances: make(map[int64]*Instance),
        elements:  make(map[int64]*ElementInstance),
        // ...
    }
}

Trade-off: mantenemos paridad de comportamiento con Postgres-backed engine. Tests de paridad ejecutan el mismo escenario en ambos adapters y comparan resultados.

Nivel 3: Scenario test (engine real + workers)

Usa testcontainers-go para levantar Postgres real + engine real.

func TestScenarioOrderEndToEnd(t *testing.T) {
    ctx := context.Background()

    pgContainer, err := postgres.RunContainer(ctx, ...)
    require.NoError(t, err)
    defer pgContainer.Terminate(ctx)

    engine := startEngineWithPostgres(t, pgContainer.ConnectionString())
    defer engine.Shutdown()

    client, _ := wfclient.New(engine.URL())

    // Register workers reales (no stubs)
    client.RegisterWorker(wfclient.WorkerOptions{Type: "validate-order"}, validateOrderHandler)
    client.RegisterWorker(wfclient.WorkerOptions{Type: "charge-payment"}, chargePaymentHandler)
    client.RegisterWorker(wfclient.WorkerOptions{Type: "ship-order"}, shipOrderHandler)
    go client.Run(ctx)

    // Deploy + start
    proc, _ := client.Processes().Deploy("processes/order-flow.bpmn")
    inst, _ := client.Processes().Start("order-flow", map[string]any{
        "amount":     99.99,
        "customerId": "real-cust-1",
    })

    // Await completion
    result, err := client.Processes().AwaitCompletion(ctx, inst.Key, 30*time.Second)
    require.NoError(t, err)
    require.Contains(t, result.Variables, "trackingNumber")
}

Test slow (10s+), pero confianza máxima. Correr en CI nightly o pre-release, no en cada commit.

Niveles 4: Load testing

Ver analysis/performance-testing-methodology (próxima iter).

DX para autores de procesos

Lint

wf process lint order-flow.bpmn
# → Warnings:
#   [WARN] Task "charge-payment" missing retry policy (default=3 may be insufficient for payment)
#   [WARN] Boundary error "insufficient-funds" exists but no error end event after it
#   [INFO] Suggestion: add log capture for variable "customerId" for observability

Snapshot test

Para procesos críticos, snapshotear el grafo de transiciones esperadas:

func TestOrderFlowSnapshot(t *testing.T) {
    snap := processtest.GenerateSnapshot("processes/order-flow.bpmn")
    expectedSnap, _ := os.ReadFile("testdata/order-flow.snapshot.json")

    if !bytes.Equal(snap, expectedSnap) {
        // diff-friendly output
        t.Fatalf("snapshot diff:\n%s", diff(expectedSnap, snap))
    }
}

Cualquier cambio al BPMN (renombrar element, mover boundary) rompe el snapshot. Actualizarlo intencionalmente.

Property-based tests para el engine (no procesos del usuario)

Para verificar la implementación del engine (no procesos de usuarios), property tests con gopter:

func TestEngineReplayDeterminism(t *testing.T) {
    properties := gopter.NewProperties(nil)
    properties.Property("replay produces same state", prop.ForAll(
        func(cmds []Command) bool {
            state1 := replay(cmds)
            state2 := replay(cmds)
            return state1.Hash() == state2.Hash()
        },
        genRandomCommandSequence(),
    ))
    properties.TestingRun(t)
}

Ver concepts/property-based-testing.

CI/CD integration

Pipeline típica:

jobs:
  test-unit:
    runs-on: ubuntu-latest
    steps:
      - run: go test -tags=test_inmemory ./internal/handlers/...

  test-integration:
    runs-on: ubuntu-latest
    steps:
      - run: go test -tags=test_inmemory ./internal/processes/...

  test-scenario:
    runs-on: ubuntu-latest
    services:
      docker: required
    steps:
      - run: go test -timeout=10m ./scenario/...

  test-load:
    runs-on: self-hosted-perf
    steps:
      - run: k6 run load/order-flow-baseline.js
        only: schedule  # nightly

Targets de duración: - Unit suite total: < 10s - Integration suite total: < 2min - Scenario suite: < 10min - Load suite: 30min (nightly)

Patterns para procesos testeables

1. Variables explícitas

<!-- BIEN: define qué variables espera -->
<bpmn:serviceTask id="charge">
  <bpmn:extensionElements>
    <zeebe:ioMapping>
      <zeebe:input source="=customerId" target="customer"/>
      <zeebe:input source="=amount" target="amount"/>
      <zeebe:output source="=chargeId" target="chargeId"/>
    </zeebe:ioMapping>
  </bpmn:extensionElements>
</bpmn:serviceTask>

Sin mapping: el handler depende de variables ambient → test frágil.

2. Boundary errors documentados

# processes/order-flow.errors.yaml
boundary_errors:
  charge-payment:
    - code: insufficient-funds
      handler: notify-customer-decline
    - code: fraud-suspected
      handler: manual-review

Tests pueden parsear y verificar coverage.

3. Procesos pequeños y compuestos

Procesos < 15 elementos. Si crece, refactor a sub-procesos.

Cobertura mínima recomendada

  • Happy path: ✅ obligatorio.
  • Cada boundary error: ✅ obligatorio.
  • Cada timer event: ✅ obligatorio (con AdvanceTime).
  • Cancel path: ✅ obligatorio si tiene compensation.
  • Multi-instance edge cases (0 elementos, 1 elemento, N elementos): ✅ si aplica.

Comparativa con alternativas

Tool Pros Cons
zeebe-process-test (Java) Maduro, AssertJ-style JVM heavy, requires SpringBoot, slow startup
process-test (nosotros) Go nativo, fast, in-memory A construir desde cero
BPMN simulation tools (Camunda Optimize, etc.) Visual No automatable, no useful para CI
End-to-end con engine real Confianza total Lento, brittle, costoso

Roadmap

  • M1: Unit + Integration en-memory.
  • M2: Scenario tests con testcontainers.
  • M3: Lint integrado en CLI, snapshots, property tests del engine.
  • M4: Coverage report visual ("¿qué % del proceso BPMN está testeado?").

Referencias