Минулого місяця я розбирав створення workflow для виконання замовлень за допомогою workflow engine: сигнали від webhooks, обмеження очікування через timeouts, retries із затримкою, branching та saga-компенсація на випадок збоїв. Стаття вийшла розлогою, але одне питання залишилося відкритим.
Як протестувати той етап, де система «засинає» на три дні?
Це не риторичне питання. Візьмемо нагадування про покинутий кошик: користувач додає товар і йде з сайту. Ви надсилаєте легке нагадування, чекаєте три дні, надсилаєте фінальне і на цьому все. Більшу частину часу цей workflow просто чекає. Якщо ваші тести не покривають очікування, ви перевіряєте лише нудну чверть процесу, залишаючи найцікавіші три чверті на відкуп production.
Гарна новина: вам ніколи не доведеться чекати насправді. Жодної секунди. Екземпляр, що «спить» — це просто рядок у базі даних із часом пробудження. Рядкам байдуже до того, що показує годинник. Запустити семиденний процес за десять мілісекунд — це лише питання контролю над часом, і цей контроль має належати не самому workflow.
Тест, який вам бреше
Ось наш workflow. Два кроки, один із яких передбачає очікування.
final class AbandonedCartReminder implements WorkflowDefinitionContract
{
public static function name(): string
{
return 'abandoned_cart_reminder';
}
public function steps(): array
{
return [
SendFirstReminder::class,
SendFinalReminder::class,
];
}
}
final class SendFirstReminder implements WorkflowStepContract
{
public function __construct(
private readonly CartRepository $carts,
) {}
public function execute(WorkflowContext $context): StepResult
{
$cart = $this->carts->find($context->get('cart_id'));
Mail::to($cart->email)->queue(new FirstReminder($cart));
return StepResult::sleep(259_200, ['first_reminder_sent_at' => now()]);
}
public function timeoutSeconds(): ?int
{
return null;
}
public function maxAttempts(): int
{
return 3;
}
}
А ось тест, який зазвичай пишуть першим, бо до нього підштовхує сам інструментарій.
it('sleeps for three days', function (): void {
$result = app(SendFirstReminder::class)->execute(
context: WorkflowContext::make(
workflowInstanceId: 'wf_1',
aggregateId: (string) $this->cart->id,
aggregateType: 'cart',
initialData: ['cart_id' => $this->cart->id],
),
);
expect($result->sleepSeconds)->toBe(259_200);
});
Цей тест пройде. Він проходитиме завжди. Але він нічого не вартий з тієї самої причини, про яку я писав у статті Testing Actions, Not Mocks: він перевіряє структуру повернутого значення, а не те, що система з ним робить.
Запитайте себе, що такий тест здатен виявити? Він не помітить відсутності workflow:tick у scheduler, через що workflow ніколи не прокинеться. Він не зафіксує втрату відкладеної задачі при перезапуску черги (queue). Він не виявить помилку, якщо другий крок спробує звернутися до first_reminder_sent_at і нічого не знайде. І він точно не допоможе з клієнтом, який оформив замовлення на другий день, але все одно отримав нагадування — а саме такі баги зазвичай заповнюють вашу скриньку підтримки.
Усі ці проблеми виникають «по той бік» сну. Unit test туди ніколи не заглядає.
Керуйте годинником, а не кодом
Щоб зробити процес керованим, потрібно змінити парадигму. Перестаньте думати про workflow, що спить, як про заблокований процес. Сприймайте його як «припаркований». Жодних активних worker-ів чи відкритих з’єднань. Є лише рядок у workflow_instances зі статусом sleeping і часом wake_at у майбутньому, а також відкладена задача, яка постукає у двері, коли прийде час.
Тому тест не має нічого чекати. Він сам переводить годинник і стукає у двері.
use JustSteveKing\WorkflowEngine\Contracts\WorkflowRepositoryContract;
use JustSteveKing\WorkflowEngine\Domain\WorkflowEngine;
use JustSteveKing\WorkflowEngine\Domain\WorkflowStatus;
beforeEach(function (): void {
$this->freezeTime();
Queue::fake();
Mail::fake();
$this->engine = app(WorkflowEngine::class);
$this->repository = app(WorkflowRepositoryContract::class);
$this->cart = Cart::factory()->abandoned()->create();
});
it('parks on a wake time once the first reminder goes out', function (): void {
$instance = $this->engine->start(
workflowName: 'abandoned_cart_reminder',
aggregateId: (string) $this->cart->id,
aggregateType: 'cart',
initialContext: ['cart_id' => $this->cart->id],
);
$this->engine->advance($instance->id);
expect($this->repository->findById($instance->id))
->status->toBe(WorkflowStatus::Sleeping);
Mail::assertQueued(FirstReminder::class);
});
Тут працюють три речі. freezeTime() фіксує час, щоб розрахунки базувалися на конкретному моменті, а не на часі запуску CI. Queue::fake() не дає задачі AdvanceWorkflow виконати решту кроків без вашого відома — це робить тестування покроковим. Прямий виклик advance() синхронно запускає state machine. У production черга викликає той самий метод; у тесті роль черги виконуєте ви.
Важливий нюанс: advance() виконує рівно один крок. Він запускає поточний крок, зберігає результат і ставить у чергу задачу для наступного. Він не працює в циклі. Щоб провести екземпляр до кінця вручну, вам знадобиться один advance() на кожен крок і ще один фінальний, щоб закрити workflow.
Перевірка відсутності дії
Саме тут тестування сну починає відрізнятися від звичних підходів.
Зазвичай ми перевіряємо, що щось сталося: рядок з’явився в базі, лист надіслано. Але коли екземпляр «припаркований», найважливішими стають негативні твердження (negative assertions). Наступний крок не виконався. Лист не в черзі. У context не з’явився ключ, який додає лише фінальний етап. Екземпляр залишився рівно там, де ви його залишили.
Довести, що дія ще не відбулася — це половина роботи.
Найкритичніший сценарій — передчасний запуск. В умовах черги з гарантією доставки «хоча б один раз» (at-least-once), ви можете отримати дублікати або ранні виклики advance. Engine має ігнорувати їх, якщо час пробудження ще не настав. Це реальна поведінка, яка заслуговує на тест.
it('ignores an advance that lands before the wake time', function (): void {
$instance = $this->engine->start(
workflowName: 'abandoned_cart_reminder',
aggregateId: (string) $this->cart->id,
aggregateType: 'cart',
initialContext: ['cart_id' => $this->cart->id],
);
$this->engine->advance($instance->id);
$this->travel(2)->days();
$this->engine->advance($instance->id);
expect($this->repository->findById($instance->id))
->status->toBe(WorkflowStatus::Sleeping);
Mail::assertNotQueued(FinalReminder::class);
});
Цей тест каже нам: через два дні триденного сну додатковий виклик advance нічого не змінює. Якщо ви випадково зламаєте перевірку часу в коді, цей тест стане «червоним», тоді як звичайний unit test залишиться «зеленим».
І тільки після цього ми дозволяємо часу сплинути.
it('sends the final reminder once the sleep has elapsed', function (): void {
$instance = $this->engine->start(
workflowName: 'abandoned_cart_reminder',
aggregateId: (string) $this->cart->id,
aggregateType: 'cart',
initialContext: ['cart_id' => $this->cart->id],
);
$this->engine->advance($instance->id); // SendFirstReminder, потім сон
$this->travel(3)->days()->addMinute();
$this->engine->advance($instance->id); // прокидається, запускає SendFinalReminder
$this->engine->advance($instance->id); // курсор за межами, завершення
expect($this->repository->findById($instance->id))
->status->toBe(WorkflowStatus::Completed);
Mail::assertQueued(FinalReminder::class);
});
Три дні та одна хвилина пролітають за кілька рядків коду без жодних реальних затримок. Це і є ті самі десять мілісекунд.
Той, хто спить, не слухає
У цьому workflow є пастка, і знайти її можна саме через тести.
Коли екземпляр перебуває у статусі sleeping, він не «чекає» (не awaiting). Він не припаркований на сигналі, тому він його не слухає. Якщо клієнт завершить покупку на другий день, сигнал checkout_completed нічого не скасує. Якщо увімкнено буферизацію (що є стандартом), сигнал збережеться для кроку, який його ніколи не запитає. У результаті через три дні людина, яка вже купила товар, отримає нагадування про покинутий кошик.
Сон — це затримка, а не підписка. Якщо кампанія має бути керованою, крок, що прокидається, повинен заново перевірити стан світу перед дією.
public function execute(WorkflowContext $context): StepResult
{
$cart = $this->carts->find($context->get('cart_id'));
if ($cart->isCheckedOut()) {
return StepResult::complete(['skipped_reason' => 'checked_out']);
}
Mail::to($cart->email)->queue(new FinalReminder($cart));
return StepResult::complete();
}
Тепер ми можемо написати тест, який реально захищає клієнта.
it('does not send the final reminder to a cart that has since checked out', function (): void {
$instance = $this->engine->start(
workflowName: 'abandoned_cart_reminder',
aggregateId: (string) $this->cart->id,
aggregateType: 'cart',
initialContext: ['cart_id' => $this->cart->id],
);
$this->engine->advance($instance->id);
$this->travel(2)->days();
$this->cart->markCheckedOut();
$this->travel(1)->day()->addMinute();
$this->engine->advance($instance->id);
$this->engine->advance($instance->id);
expect($this->repository->findById($instance->id))
->status->toBe(WorkflowStatus::Completed)
->context->get('skipped_reason')->toBe('checked_out');
Mail::assertNotQueued(FinalReminder::class);
});
Стан змінився, поки workflow спав. Це визначальна риса тривалих процесів, і вона невидима для будь-якого тесту, який не маніпулює часом.
Стан гонитви: шанс на помилку лише один
Сон — це лише частина справи. Справді складні шляхи виникають, коли сигнал і таймер спрацьовують одночасно. Такі race conditions неможливо відтворити вручну.
Візьмемо workflow реєстрації учасника. Крок ChargeMemberStep очікує сигнал payment_succeeded із таймаутом в одну годину. Webhook приходить на 59-й хвилині. Крок виконується. Але десь у системі все ще запланована задача таймауту. Коли вона спрацює, вона не має робити нічого.
Саме тому таймаут містить індекс кроку, для якого він був створений. Engine ігнорує таймаут, якщо екземпляр уже не перебуває на тому самому кроці. Гарантія без тесту — це просто коментар, тому перевірте це.
it('ignores a timeout scheduled for a step that has already advanced', function (): void {
$instance = $this->engine->start(
workflowName: 'member_registration',
aggregateId: (string) $this->member->id,
aggregateType: 'member',
initialContext: ['member_id' => $this->member->id, 'plan' => 'annual'],
);
$this->engine->advance($instance->id); // ChargeMemberStep чекає на сигнал
$this->engine->signal(
instanceId: $instance->id,
signal: 'payment_succeeded',
signalData: ['payment_id' => 'pay_123'],
deliveredBy: 'stripe_webhook',
);
$this->travel(1)->hour()->addMinute();
$this->engine->timeout(
instanceId: $instance->id,
stepIndex: 0, // крок, для якого був таймаут, уже пройдено
);
expect($this->repository->findById($instance->id))
->status->not->toBe(WorkflowStatus::Failed);
});
Інший важливий тест — ранній сигнал. Швидкий провайдер може надіслати payment_succeeded ще до того, як крок встигне «припаркуватися». Завдяки буферизації сигнал має зберегтися і застосуватися в момент запиту. Перевірте це через події (events): переконайтеся, що SignalReceived прийшов із buffered: true, а WorkflowAwaitingSignal взагалі не спрацював.
Я б написав ці тести ще до того, як перевірятиму основний сценарій (happy path). Основний сценарій легко перевірити вручну за 30 секунд. А от race conditions трапляються раз на тисячу випадків о третій ночі, коли клієнт уже висить на лінії підтримки.
Час — це вхідні дані, а не зовнішня обставина
І останнє: те, що гарантує правдивість ваших тестів.
У чергах з доставкою at-least-once крок може запуститися двічі. Якщо крок вираховує дедлайн на основі now() у момент виконання, два запуски дадуть два різні дедлайни. Ваш тест збігався з реальністю лише тому, що ви «заморозили» час. У production годинник не зупиняється.
Розраховуйте дедлайни один раз при виклику start() і передавайте їх через context як значення.
$instance = $this->engine->start(
workflowName: 'abandoned_cart_reminder',
aggregateId: (string) $cart->id,
aggregateType: 'cart',
initialContext: [
'cart_id' => $cart->id,
'final_reminder_at' => now()->addDays(3)->toIso8601String(),
],
);
Тепер крок читає стале значення замість того, щоб питати світ «котра година». Повторний запуск видасть той самий результат, що й перший. Це той самий принцип, що й зберігання ID замість цілих моделей у context. Все, що крок бере із зовнішнього світу під час виконання — це потенційне джерело помилок у тестах.
Коли час під контролем, триденна кампанія стає звичайним Pest-тестом, що виконується миттєво. Це дозволяє перейти до значно складнішого питання: що робити, коли крок падає на півдорозі й потрібно скасувати все, що вже було успішно зроблено.
Compensation — це saga, що працює у зворотному напрямку, і тестування відкату (rollback) — це зовсім інша історія. Про це — наступного разу.