Skip to content

React и тестирование workflow

React работает с domain wrapper над WorkflowStore, как с любым MobX-store.

Компонент прогресса

ts
import { useEffect } from "react"
import { observer } from "mobx-react-lite"

import { DashboardBootstrapStore } from "../../../data/dashboard/model"
import { useStoreInstance } from "../../../shared/hooks"

interface DashboardBootstrapProps {
    createStore(): DashboardBootstrapStore
}

export const DashboardBootstrap = observer((props: DashboardBootstrapProps) => {
    // useStoreInstance — project hook, а не экспорт Query Layer.
    const store = useStoreInstance(props.createStore)

    useEffect(() => {
        // Запускаем один составной bootstrap после mount.
        void store.initialize().catch(() => {
            // Ошибка уже находится в observable store.error.
            // Здесь можно отправить unexpected error в monitoring.
        })

        return () => store.dispose()
    }, [store])

    if (store.running) {
        return (
            <section aria-live="polite">
                <p>
                    Выполнено {store.completedSteps} из {store.totalSteps}
                </p>

                <button onClick={store.cancel} type="button">
                    Отменить
                </button>
            </section>
        )
    }

    if (store.error) {
        return (
            <section>
                <p role="alert">Не удалось подготовить кабинет</p>

                <button onClick={() => void store.initialize()} type="button">
                    Повторить
                </button>
            </section>
        )
    }

    if (store.status === "success") {
        return <Dashboard />
    }

    return <button onClick={() => void store.initialize()}>Открыть</button>
})

observer перерисует компонент при изменении status, progress или error.

Почему execute() нужно обрабатывать

Workflow записывает error в MobX-state и одновременно отклоняет Promise. Это нужно для composition кода и tests.

ts
void store.initialize().catch(reportWorkflowError)

Не оставляйте rejected Promise необработанным. Не скрывайте ошибку пустым catch, если monitoring требуется.

Unit test порядка зависимостей

ts
import { describe, expect, it, vi } from "vitest"
import { WorkflowStore } from "@dubium/query-layer/orchestration"

describe("dashboard workflow", () => {
    it("запускает settings после permissions", async () => {
        const events: string[] = []

        const workflow = new WorkflowStore({
            nodes: [
                {
                    id: "permissions",
                    execute: async () => {
                        events.push("permissions")
                        return ["admin"]
                    },
                },
                {
                    id: "settings",
                    dependencies: ["permissions"],
                    execute: async ({ results }) => {
                        events.push("settings")
                        expect(results.get("permissions")).toEqual(["admin"])
                        return { theme: "dark" }
                    },
                },
            ],
        })

        const results = await workflow.execute()

        expect(events).toEqual(["permissions", "settings"])
        expect(results.get("settings")).toEqual({ theme: "dark" })
        expect(workflow.status).toBe("success")
    })
})

Test параллельного уровня

Не проверяйте точный порядок независимых Promise. Проверяйте, что второй node стартовал до завершения первого:

ts
it("выполняет независимые nodes параллельно", async () => {
    const started: string[] = []
    let releaseFirst!: () => void

    const firstBarrier = new Promise<void>((resolve) => {
        releaseFirst = resolve
    })

    const workflow = new WorkflowStore({
        concurrency: 2,
        nodes: [
            {
                id: "a",
                execute: async () => {
                    started.push("a")
                    await firstBarrier
                    return "A"
                },
            },
            {
                id: "b",
                execute: async () => {
                    started.push("b")
                    return "B"
                },
            },
        ],
    })

    const execution = workflow.execute()

    await Promise.resolve()
    expect(started).toEqual(["a", "b"])

    releaseFirst()
    await execution
})

Test compensation

ts
it("компенсирует completed nodes в обратном порядке", async () => {
    const compensated: string[] = []

    const workflow = new WorkflowStore({
        nodes: [
            {
                id: "reserve",
                execute: async () => "reservation-1",
                compensate: async () => {
                    compensated.push("reserve")
                },
            },
            {
                id: "payment",
                dependencies: ["reserve"],
                execute: async () => {
                    throw new Error("payment failed")
                },
            },
        ],
    })

    await expect(workflow.execute()).rejects.toMatchObject({
        failedNodeId: "payment",
    })

    expect(compensated).toEqual(["reserve"])
    expect(workflow.status).toBe("error")
})

Test cancellation

Node должен слушать context.signal или связывать его с store.cancel().

ts
it("помечает workflow отменённым", async () => {
    const workflow = new WorkflowStore({
        nodes: [
            {
                id: "slow",
                execute: ({ signal }) => {
                    return new Promise((resolve, reject) => {
                        signal.addEventListener("abort", () => reject(signal.reason ?? new Error("aborted")), {
                            once: true,
                        })
                    })
                },
            },
        ],
    })

    const execution = workflow.execute()
    workflow.cancel()

    await expect(execution).rejects.toBeDefined()
    expect(workflow.status).toBe("cancelled")
})

Test checkpoint restore

Используйте fake IWorkflowPersister, а не реальный localStorage:

ts
const snapshots = new Map<string, IWorkflowSnapshot>()

const persister: IWorkflowPersister = {
    save: async (snapshot) => {
        snapshots.set(snapshot.workflowId, snapshot)
    },
    restore: async (workflowId) => {
        return snapshots.get(workflowId) ?? null
    },
    remove: async (workflowId) => {
        snapshots.delete(workflowId)
    },
}

Проверьте:

  • completed node не выполняется второй раз;
  • его result доступен зависимому node;
  • несовместимый snapshot удаляется;
  • после полного success snapshot удалён;
  • после compensation snapshot удалён.

Component test

В component test подставьте createStore с mocked domain dependencies. Не mock-айте private поля WorkflowStore. Проверяйте observable public contract:

  • показан progress;
  • кнопка cancel вызывает cancel();
  • error показывает retry;
  • success показывает основной экран;
  • unmount вызывает dispose().

Playwright

E2E нужен для одного-двух критичных бизнес-flow:

  1. browser нажимает submit;
  2. backend фиксирует порядок endpoints;
  3. UI показывает progress;
  4. искусственная ошибка запускает compensation endpoint;
  5. reload восстанавливает checkpoint, если это product requirement.

Не пытайтесь E2E-тестами покрыть все варианты DAG. Граф, conditions и errors быстрее и точнее проверяются unit tests.