Тема
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:
- browser нажимает submit;
- backend фиксирует порядок endpoints;
- UI показывает progress;
- искусственная ошибка запускает compensation endpoint;
- reload восстанавливает checkpoint, если это product requirement.
Не пытайтесь E2E-тестами покрыть все варианты DAG. Граф, conditions и errors быстрее и точнее проверяются unit tests.