go-bpm

Тестирование процессов

В go-bpm процесс — это Go-код: методы задач, условия шлюзов, решения DMN и обработчики service task — обычные функции в пакетах вашего сервиса. Поэтому их тестируют обычным go test, без моделера, отдельного сервера процессов и специального фреймворка. Процесс целиком проверяется e2e-тестом на настоящем PostgreSQL за секунды.

УровеньЧто проверяетНужна базаСколько длится
Генерациясхему, условия, типы переменных, сигнатуры методовнетgo generate
Юнит-тестыметоды, условия шлюзов, решения DMN, обработчики с заглушками сервисовнетмиллисекунды
E2Eпроцесс целиком: очередь, переходы, задачи пользователей, повторы, статусы экземпляраPostgreSQLсекунды

Весь код ниже — из примера examples/orders в репозитории сайта. CI сайта собирает его и прогоняет все тесты на PostgreSQL при каждой сборке, так что код на этой странице рабочий.

Пример: согласование заказа#

Процесс OrderApproval из Быстрого старта: проверить заказ, крупный — отдать на согласование менеджеру, затем уведомить клиента.

Процесс согласования заказа: «Проверить заказ», шлюз «Крупный заказ?», user task «Согласовать заказ» для группы managers, service task «Уведомить клиента»

Модель данных и внешние сервисы — рукописные части. Почта — интерфейс: в тестах вместо неё подставляется заглушка.

// orders/internal/bpmgen/model/data_model.go
// DataModel - переменные процессов. С ней работают методы и скрипты задач, обработчики jobType,
// условия шлюзов и решения DMN: любая переменная, которую они используют, должна быть полем модели.
// Поле получает переменную процесса по тегу json, например:
//
//	Amount float64 `json:"amount"`
//
// Вложенные структуры дают пути: client.account.number в BPMN и DMN - поле data.Client.Account.Number.
// После метода поля модели записываются поверх переменных процесса. Поле с omitempty
// в нулевом значении не меняет переменную
type DataModel struct {
	Amount   float64 `json:"amount"`
	Email    string  `json:"email"`
	Checked  bool    `json:"checked"`
	Notified bool    `json:"notified"`
}
// orders/internal/bpmgen/services/services.go
// Services - внешние сервисы, которые получают методы task и обработчики jobType.
// Заполняются при старте и передаются в сгенерированные Processes и Queues.
// Поля удобнее делать интерфейсами, чтобы в тестах подставлять заглушки
type Services struct {
	Mailer Mailer
}

// Mailer - отправка писем клиентам
type Mailer interface {
	Send(ctx context.Context, to, subject, body string) error
}

Метод задачи#

Метод «Проверить заказ» выполняется синхронно в шаге процесса. Он получает модель и сервисы и меняет поля модели — движок запишет их в переменные процесса.

// orders/internal/bpmgen/order_approval/method_check_order.go
// CheckOrder - метод задачи CheckOrder «Проверить заказ»
func CheckOrder(ctx context.Context, svc *services.Services, data *model.DataModel) error {
	if data.Amount <= 0 {
		return fmt.Errorf("сумма заказа должна быть положительной: %v", data.Amount)
	}
	data.Checked = true
	return nil
}

Тест — вызвать функцию и проверить поля. Движок, очередь и база не нужны:

// orders/internal/bpmgen/order_approval/method_check_order_test.go
func TestCheckOrder(t *testing.T) {
	ctx, svc := context.Background(), &services.Services{}

	data := &model.DataModel{Amount: 500}
	if err := CheckOrder(ctx, svc, data); err != nil {
		t.Fatal(err)
	}
	if !data.Checked {
		t.Error("заказ не отмечен проверенным")
	}
	if err := CheckOrder(ctx, svc, &model.DataModel{}); err == nil {
		t.Error("пустой заказ прошёл проверку")
	}
}

Условие шлюза#

Условие =amount > 1000 генератор превратил в Go-функцию, а не в выражение, которое вычисляется в рантайме:

// orders/internal/bpmgen/order_approval/cond_is_big.go
// isBig возвращает id следующего элемента после IsBig
func isBig(data *model.DataModel) string {
	// в процессе: amount > 1000
	if data.Amount > 1000 {
		return "Flow_big"
	}
	return "Flow_small"
}

Функция неэкспортируемая, поэтому тест лежит в пакете процесса (package order_approval). Границы условия проверяются таблицей:

// orders/internal/bpmgen/order_approval/method_check_order_test.go
// Условие шлюза сгенерировано в Go-функцию isBig: её можно проверить из пакета процесса.
func TestIsBig(t *testing.T) {
	tests := []struct {
		amount float64
		want   string
	}{
		{amount: 1000, want: "Flow_small"},
		{amount: 1000.01, want: "Flow_big"},
		{amount: 5000, want: "Flow_big"},
	}
	for _, tt := range tests {
		if got := isBig(&model.DataModel{Amount: tt.amount}); got != tt.want {
			t.Errorf("isBig(%v) = %s, want %s", tt.amount, got, tt.want)
		}
	}
}

Обработчик service task и заглушки#

Обработчик «Уведомить клиента» выполняется асинхронно, отдельной задачей очереди с повторами. Внешний мир он видит только через services.Services:

// orders/internal/bpmgen/handlers/notify_client.go
// NotifyClient - обработчик service task с jobType "notifyClient" во всех процессах, где он встречается
// (job.ProcessName, job.ElementId). Вызывается асинхронно из очереди задач и при ошибке повторяется,
// поэтому должен быть идемпотентным (ключ - job.InstanceUid + job.ElementId)
func NotifyClient(ctx context.Context, svc *services.Services, job bpm.Job, data *model.DataModel) error {
	body := fmt.Sprintf("Заказ на сумму %.2f принят", data.Amount)
	if err := svc.Mailer.Send(ctx, data.Email, "Заказ "+job.BusinessKey, body); err != nil {
		return fmt.Errorf("письмо клиенту: %w", err)
	}
	data.Notified = true
	return nil
}

В тесте почта — заглушка, которая запоминает письма или возвращает ошибку. bpm.Job — обычная структура, её заполняют нужными полями:

// orders/internal/bpmgen/handlers/notify_client_test.go
package handlers

import (
	"context"
	"errors"
	"testing"

	"example.com/orders/internal/bpmgen/model"
	"example.com/orders/internal/bpmgen/services"
	"github.com/nicolas-vp/go-bpm/bpm"
)

// fakeMailer - заглушка services.Mailer: запоминает письма или возвращает заданную ошибку.
type fakeMailer struct {
	err  error
	sent []string
}

func (m *fakeMailer) Send(ctx context.Context, to, subject, body string) error {
	if m.err != nil {
		return m.err
	}
	m.sent = append(m.sent, to+": "+subject)
	return nil
}

func TestNotifyClient(t *testing.T) {
	mailer := &fakeMailer{}
	svc := &services.Services{Mailer: mailer}
	job := bpm.Job{InstanceUid: "01a17411-e69b-7ea7", ElementId: "Notify", BusinessKey: "order-2"}
	data := &model.DataModel{Amount: 500, Email: "client@example.com"}

	if err := NotifyClient(context.Background(), svc, job, data); err != nil {
		t.Fatal(err)
	}
	if !data.Notified || len(mailer.sent) != 1 || mailer.sent[0] != "client@example.com: Заказ order-2" {
		t.Errorf("notified = %v, sent = %v", data.Notified, mailer.sent)
	}
}

// Ошибка почты - ошибка обработчика: очередь повторит задачу, а клиент не отмечен уведомлённым.
func TestNotifyClientMailerDown(t *testing.T) {
	svc := &services.Services{Mailer: &fakeMailer{err: errors.New("smtp: connection refused")}}
	data := &model.DataModel{Amount: 500, Email: "client@example.com"}

	if err := NotifyClient(context.Background(), svc, bpm.Job{}, data); err == nil {
		t.Fatal("ошибка почты потерялась")
	}
	if data.Notified {
		t.Error("клиент отмечен уведомлённым без письма")
	}
}

Решение DMN#

Таблица решений тоже генерируется в Go — каждое правило становится веткой if. Пример из демо go-bpm, процесс TestDecision:

// go-bpm/examples/demo/bpmgen/test_decision/decision_decision_1.go
// decideDecision1 - решение Decision_1 «Test Decision» из businessRule.dmn, hit policy UNIQUE.
// Возвращает значение выхода c первого подошедшего правила; если ни одно не подошло - нулевое значение и пустой список правил.
// Второе значение - id сработавших правил для истории
func decideDecision1(data *model.DataModel) (string, []string) {
	a := data.A // Некая переменная a
	b := data.B // Некая переменная b

	// DecisionRule_04e4bk8: "foo" | "bar" => "correct foobar"
	if a == "foo" && b == "bar" {
		return "correct foobar", []string{"DecisionRule_04e4bk8"}
	}
	// DecisionRule_1a2n073: "some" | - => "some a"
	if a == "some" {
		return "some a", []string{"DecisionRule_1a2n073"}
	}
	// DecisionRule_0t56a5i: - | "some" => "some b"
	if b == "some" {
		return "some b", []string{"DecisionRule_0t56a5i"}
	}
	// DecisionRule_1kb19kv: - | - => "default"
	return "default", []string{"DecisionRule_1kb19kv"}
}

Тест проходит по строкам таблицы и проверяет и результат, и сработавшее правило — то, что потом видно в истории правил консоли. Задачу бизнес-правила тоже можно вызвать напрямую: вне движка запись в историю правил (bpm.RecordDecision) просто пропускается.

// demo/decision_test.go
package test_decision

import (
	"context"
	"slices"
	"testing"

	"github.com/nicolas-vp/go-bpm/examples/demo/bpmgen/model"
	"github.com/nicolas-vp/go-bpm/examples/demo/bpmgen/services"
)

// Таблица Decision_1 (hit policy UNIQUE) сгенерирована в decideDecision1: каждое правило - ветка if.
func TestDecideDecision1(t *testing.T) {
	tests := []struct {
		a, b, want, rule string
	}{
		{a: "foo", b: "bar", want: "correct foobar", rule: "DecisionRule_04e4bk8"},
		{a: "some", b: "x", want: "some a", rule: "DecisionRule_1a2n073"},
		{a: "z", b: "some", want: "some b", rule: "DecisionRule_0t56a5i"},
		{a: "z", b: "z", want: "default", rule: "DecisionRule_1kb19kv"},
	}
	for _, tt := range tests {
		got, rules := decideDecision1(&model.DataModel{A: tt.a, B: tt.b})
		if got != tt.want || !slices.Equal(rules, []string{tt.rule}) {
			t.Errorf("a=%s, b=%s: %q %v, want %q [%s]", tt.a, tt.b, got, rules, tt.want, tt.rule)
		}
	}
}

// Задача бизнес-правила кладёт результат решения в модель. Вне движка запись в историю правил пропускается.
func TestBusinessRuleTask(t *testing.T) {
	data := &model.DataModel{A: "foo", B: "bar"}
	if err := Activity0xzljrt(context.Background(), &services.Services{}, data); err != nil {
		t.Fatal(err)
	}
	if data.C != "correct foobar" {
		t.Errorf("c = %q", data.C)
	}
}

Процесс целиком: e2e#

Юнит-тесты не проверяют, что части собраны в процесс правильно: что шлюз ведёт куда нужно, задача пользователя появляется у нужной группы, а упавший обработчик повторяется и роняет экземпляр. Это проверяет e2e-тест: настоящие очередь и движок на тестовой базе PostgreSQL, как в main.go, только с заглушками сервисов.

Процесс идёт асинхронно: StartProcess только ставит старт в очередь. Поэтому тест ждёт результата — статуса экземпляра через HistoryReader.GetInstance или задачи пользователя через UserTaskService.ListTasks.

// orders/internal/e2e/order_test.go
func TestSmallOrder(t *testing.T) {
	env := start(t, &fakeMailer{})

	id, err := env.engine.StartProcess(env.ctx, "OrderApproval",
		map[string]any{"amount": 500, "email": "client@example.com"}, engine.WithBusinessKey("order-1"))
	if err != nil {
		t.Fatal(err)
	}
	// маленький заказ идёт мимо согласования сразу к уведомлению
	env.waitStatus(id, engine.InstanceCompleted)
	if sent := env.mailer.Sent(); len(sent) != 1 || sent[0] != "client@example.com: Заказ order-1" {
		t.Errorf("sent = %v", sent)
	}
}

func TestBigOrderNeedsApproval(t *testing.T) {
	env := start(t, &fakeMailer{})

	id, err := env.engine.StartProcess(env.ctx, "OrderApproval",
		map[string]any{"amount": 5000, "email": "client@example.com"}, engine.WithBusinessKey("order-2"))
	if err != nil {
		t.Fatal(err)
	}
	// крупный заказ ждёт менеджера: задача в очереди группы managers, письма ещё нет
	task := env.waitTask(id, "Approve")
	if len(env.mailer.Sent()) != 0 {
		t.Fatal("письмо ушло до согласования")
	}
	if err := env.tasks.Claim(env.ctx, task.TaskUid, "bob", []string{"accounting"}); !errors.Is(err, engine.ErrNotCandidate) {
		t.Errorf("взял задачу не из своей группы: err = %v", err)
	}
	if err := env.tasks.Claim(env.ctx, task.TaskUid, "bob", []string{"managers"}); err != nil {
		t.Fatal(err)
	}
	if err := env.tasks.Complete(env.ctx, task.TaskUid, "bob", nil); err != nil {
		t.Fatal(err)
	}
	env.waitStatus(id, engine.InstanceCompleted)
	if len(env.mailer.Sent()) != 1 {
		t.Errorf("sent = %v", env.mailer.Sent())
	}
}

func TestMailerDown(t *testing.T) {
	env := start(t, &fakeMailer{err: errors.New("smtp: connection refused")})

	id, err := env.engine.StartProcess(env.ctx, "OrderApproval", map[string]any{"amount": 500})
	if err != nil {
		t.Fatal(err)
	}
	// у notifyClient retries="3": после трёх неудачных попыток экземпляр упал
	env.waitStatus(id, engine.InstanceFailed)
}

func TestInvalidOrder(t *testing.T) {
	env := start(t, &fakeMailer{})

	id, err := env.engine.StartProcess(env.ctx, "OrderApproval", map[string]any{"amount": 0})
	if err != nil {
		t.Fatal(err)
	}
	// CheckOrder отвергает пустой заказ: шаг повторяется SystemMaxAttempts раз, затем экземпляр падает
	env.waitStatus(id, engine.InstanceFailed)
}

Окружение теста — движок на тестовой базе. Настройки ускоряют падения и повторы: две попытки на шаг вместо десяти, повторы через 50 мс вместо секунды.

// orders/internal/e2e/order_test.go
// testEnv - движок процессов на тестовой базе.
type testEnv struct {
	t      *testing.T
	ctx    context.Context
	engine *engine.Engine
	tasks  *engine.UserTaskService
	reader *engine.HistoryReader
	mailer *fakeMailer
}

func start(t *testing.T, mailer *fakeMailer) *testEnv {
	t.Helper()
	ctx := context.Background()
	pool := testPool(t)
	log := slog.New(slog.DiscardHandler)
	on := func() bool { return true }

	svc := &services.Services{Mailer: mailer}
	processes := bpmgen.Processes(svc)
	q := queue.New(pool, queue.Options{PollInterval: 100 * time.Millisecond, Logger: log})
	e, err := engine.New(q, engine.Config{
		SystemMaxAttempts: 2,                     // упавший метод - быстро в failed
		RetryDelay:        50 * time.Millisecond, // повторы без долгих пауз
		History:           engine.HistoryConfig{ToDB: on},
		Logger:            log,
	}, processes, bpmgen.Queues(svc))
	if err != nil {
		t.Fatal(err)
	}
	q.Start(ctx)
	t.Cleanup(q.Stop)
	return &testEnv{
		t:      t,
		ctx:    ctx,
		engine: e,
		tasks:  engine.NewUserTaskService(pool, e),
		reader: engine.NewHistoryReader(pool, processes, bpmgen.Decisions),
		mailer: mailer,
	}
}

// testPool подключается к GO_BPM_TEST_DSN, применяет миграции и очищает таблицы движка.
func testPool(t *testing.T) *pgxpool.Pool {
	t.Helper()
	dsn := os.Getenv("GO_BPM_TEST_DSN")
	if dsn == "" {
		t.Skip("GO_BPM_TEST_DSN не задан")
	}
	ctx := context.Background()
	pool, err := pgxpool.New(ctx, dsn)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(pool.Close)
	if err := migrations.Up(ctx, pool); err != nil {
		t.Fatal(err)
	}
	_, err = pool.Exec(ctx, `truncate bpm_queue, bpm_parallel_sync, bpm_wait, bpm_history, bpm_rules_history,
		bpm_process_instance, bpm_user_task`)
	if err != nil {
		t.Fatal(err)
	}
	return pool
}

Ожидание статуса и задачи — опрос с таймаутом. Заглушка почты здесь под мьютексом: обработчик вызывают воркеры очереди из своих горутин.

// orders/internal/e2e/order_test.go
// waitStatus ждёт, пока экземпляр придёт в статус want; процесс идёт асинхронно, через очередь.
func (env *testEnv) waitStatus(id uuid.UUID, want string) {
	env.t.Helper()
	deadline := time.Now().Add(10 * time.Second)
	for {
		instance, err := env.reader.GetInstance(env.ctx, id)
		if err != nil {
			env.t.Fatal(err)
		}
		if instance.Status == want {
			return
		}
		if instance.Status != engine.InstanceRunning || time.Now().After(deadline) {
			env.t.Fatalf("экземпляр %s: статус %s, ждали %s", id, instance.Status, want)
		}
		time.Sleep(50 * time.Millisecond)
	}
}

// waitTask ждёт открытую задачу пользователя на элементе elementId.
func (env *testEnv) waitTask(id uuid.UUID, elementId string) engine.UserTask {
	env.t.Helper()
	deadline := time.Now().Add(10 * time.Second)
	for {
		tasks, err := env.tasks.ListTasks(env.ctx, engine.TaskFilter{InstanceUid: id})
		if err != nil {
			env.t.Fatal(err)
		}
		for _, task := range tasks {
			if task.ElementId == elementId {
				return task
			}
		}
		if time.Now().After(deadline) {
			env.t.Fatalf("экземпляр %s: нет задачи %s, открыты %+v", id, elementId, tasks)
		}
		time.Sleep(50 * time.Millisecond)
	}
}

// fakeMailer - заглушка services.Mailer. Обработчик вызывают воркеры очереди, поэтому под мьютексом.
type fakeMailer struct {
	err  error
	mu   sync.Mutex
	sent []string
}

func (m *fakeMailer) Send(ctx context.Context, to, subject, body string) error {
	if m.err != nil {
		return m.err
	}
	m.mu.Lock()
	defer m.mu.Unlock()
	m.sent = append(m.sent, to+": "+subject)
	return nil
}

func (m *fakeMailer) Sent() []string {
	m.mu.Lock()
	defer m.mu.Unlock()
	return append([]string(nil), m.sent...)
}

Запуск#

Без GO_BPM_TEST_DSN e2e-тесты пропускаются, поэтому go test ./... работает и без базы. С базой:

docker run -d --name orders-test-pg -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=orders_test -p 5433:5432 postgres:18
GO_BPM_TEST_DSN='postgres://postgres:postgres@localhost:5433/orders_test?sslmode=disable' go test ./...

Четыре e2e-сценария примера проходят примерно за две секунды: таймаутов и долгих задержек в процессе нет, а повторы ускорены настройками теста.

База очищается. testPool применяет миграции и очищает таблицы bpm_* перед каждым тестом. Не направляйте GO_BPM_TEST_DSN на базу с данными. Тесты с базой делят таблицы, поэтому не запускайте их параллельно: без t.Parallel(), а если e2e-тесты лежат в нескольких пакетах — go test -p 1.

В CI#

PostgreSQL поднимается сервисом job. Например, в GitHub Actions:

test:
  runs-on: ubuntu-latest
  services:
    postgres:
      image: postgres:18
      env:
        POSTGRES_PASSWORD: postgres
        POSTGRES_DB: orders_test
      ports: ["5432:5432"]
      options: --health-cmd pg_isready --health-interval 2s --health-retries 15
  env:
    GO_BPM_TEST_DSN: postgres://postgres:postgres@localhost:5432/orders_test?sslmode=disable
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-go@v5
      with:
        go-version-file: go.mod
    - run: go generate ./... && git diff --exit-code   # сгенерированный код совпадает со схемами
    - run: go test ./...

git diff --exit-code после генерации ловит схему, которую поменяли в моделере, но забыли перегенерировать.

Тесты самого go-bpm#

В репозитории движка — образцы на все случаи. Они используют процессы демо и внутренний хелпер базы, но устроены так же, как тесты выше.

ФайлЧто проверяет
engine/e2e_test.goвсе процессы демо доходят до End event, история и экземпляры читаются обратно
engine/user_tasks_e2e_test.goзадачи пользователей: очередь группы, Claim, Unclaim, Complete, ошибки, возврат на исправление
engine/start_tx_e2e_test.goStartProcessTx: откат транзакции — нет экземпляра, коммит — экземпляр виден сразу
engine/timers_test.goграничные таймеры и ошибки: кто успел первым, отмена таймера, ошибки service task
engine/decision_test.goзадача бизнес-правила и история сработавших правил
engine/call_activity_test.goвызов процесса и возврат токена в родителя
internal/generator/codegen/validate_test.goчто генератор отвергает и что восстанавливает в BPMN

Как запускать тесты репозитория go-bpm — в Запуске и эксплуатации.

Предложить правку на GitHub