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¶
- concepts/test-infrastructure — infraestructura interna
- concepts/property-based-testing — property-based tests
- adrs/adr-019-replay-determinism-invariant — invariante testeable
- zeebe-process-test source
- testcontainers-go