Модульные тесты
Обработчик триггера — почти чистая функция от payload события, снимка условий и набора обработчиков действий. Именно поэтому Triggery существует — и именно поэтому тесты на триггеры короткие, быстрые и фреймворк-агностические. Ты ничего не рендеришь. Не ждёшь коммита эффекта. Не подделываешь <Provider>. Ты собираешь крошечный рантайм, подменяешь порты, отправляешь событие, проверяешь, что было вызвано.
Эта страница покрывает поверхность модульного тестирования в @triggery/testing: createTestRuntime, mockCondition, mockAction, fireSync против fire + flushMicrotasks и инспектор как цель проверок.
Установка
Заголовок раздела «Установка»У пакета нет runtime-зависимостей кроме @triggery/core (он у тебя уже есть). Работает одинаково в Vitest, Jest с ESM, node:test и bun:test — никакие глобалы тест-раннеров не трогаются.
Форма теста триггера
Заголовок раздела «Форма теста триггера»Четыре строки настройки, одна строка действия, две строки проверок. Это вся форма. Всё ниже — вариации.
createTestRuntime — изолированный рантайм на тест
Заголовок раздела «createTestRuntime — изолированный рантайм на тест»createTestRuntime(options?) оборачивает createRuntime из @triggery/core и добавляет три тестовых метода: mockCondition, mockAction, flushMicrotasks. Всё остальное из публичного API Runtime доступно — fire, fireSync, subscribe, getInspectorBuffer, graph, dispose.
Ключевое здесь — изоляция. Два тестовых рантайма не делят ничего: ни триггеров, ни индекса событий, ни буфера инспектора. Объяви триггер против rtA, отправь событие в rtB — триггер не запустится:
Поэтому в тестах всегда передавай рантайм вторым аргументом в createTrigger. Вызов createTrigger(config) без него уходит в getDefaultRuntime(), который является глобальным синглтоном — и течёт между тестами.
createTestRuntime принимает те же RuntimeOptions, что и createRuntime:
Для большинства тестов дефолтов хватит. Увеличь inspectorBufferSize, если проверяешь длинные последовательности. Принудительно поставь inspector: true, если твой тест-раннер по какой-то причине ставит NODE_ENV=production — без этого getInspectorBuffer() вернёт пустой массив.
mockCondition — подложи снимок
Заголовок раздела «mockCondition — подложи снимок»Используй там, где обычно работал бы useCondition. Две формы:
Правило обёртки: если передаёшь функцию с arity === 0, она трактуется как геттер напрямую. Если передаёшь что-то ещё — включая функцию с параметрами — она трактуется как значение и оборачивается в () => valueOrGetter. Запасной выход для случая «значение условия само — функция без аргументов»: передай явный геттер () => myFn.
mockCondition возвращает RegistrationToken с методом unregister(), ровно как registerCondition. Используй его, чтобы тестировать замену в середине запуска:
mockAction — зарегистрируй обработчик побочного эффекта
Заголовок раздела «mockAction — зарегистрируй обработчик побочного эффекта»handler — обычно vi.fn() (или jest.fn() / голое замыкание с побочными эффектами, которые потом проверишь). Вызовы actions.name?.(payload) триггера попадают прямо в твой обработчик.
Как и mockCondition, возвращает токен. Рантайм держит стек регистраций по имени, побеждает самая свежая — поэтому регистрация второй подмены для того же действия прозрачно заменяет первую (а её снятие возвращает к предыдущей).
Отправка событий: fireSync против fire
Заголовок раздела «Отправка событий: fireSync против fire»У рантайма два режима срабатывания, и твой тест выбирает тот, что соответствует продакшен-режиму триггера.
fireSync — в обход планировщика
Заголовок раздела «fireSync — в обход планировщика»fireSync диспатчит обработчики в том же кадре вызова, до возврата fireSync. Никакой микротаски, никаких await. Используй для:
- Синхронных обработчиков (по умолчанию).
- Тестов горячего пути, где нужен детерминизм стека вызовов.
- Воспроизведения «срабатывания изнутри действия» — каскада в одном тике.
fire + flushMicrotasks — через планировщик
Заголовок раздела «fire + flushMicrotasks — через планировщик»fire ставит диспатч в планировщик микротасок — тот же путь, что и в продакшен-коде. Два вызова fire в одном тике батчатся в один сток микротасок. Используй для тестов:
- Батчинга микротасок (два быстрых
fireдолжны оба отработать до следующей макротаски). - Чего-то, что реально зависит от планировщика — порядок, батчинг, опция расписания
'sync'.
flushMicrotasks — это await Promise.resolve() дважды; даёт запланированным обработчикам отработать, включая последующие микротаски. После его резолва проверки безопасны.
Async-проверки
Заголовок раздела «Async-проверки»Для async-обработчика — возвращающего Promise — нужно дать ему отстояться. Тот же паттерн flushMicrotasks работает, с дополнительными раундами, если обработчик сам что-то awaits:
Для детерминированного тайминга подменяй fetch (MSW, vi.fn(), замыкание), чтобы резолв пришёлся на известную микротаску. Тогда await rt.flushMicrotasks() обычно достаточно.
Проверка причин skip через инспектор
Заголовок раздела «Проверка причин skip через инспектор»Когда триггер не запускается, инспектор записывает почему. Это самая чистая цель проверки «пропустил из-за отсутствия required-условия» — гораздо лучше, чем проверять, что действие не было вызвано: это ничего не доказывает о пройденном пути.
Другие причины skip, на которые можно матчиться:
| Фрагмент reason | Значение |
|---|---|
'missing-required: <name>' | required-условие не было зарегистрировано. |
'disabled' | Был вызван trigger.disable(). |
'cancelled-by-middleware: <name>' | onFire middleware вернул { cancel: true }. |
Инспектор также записывает executedActions: readonly string[] пер-запуск — удобно для проверки, какие действия реально сработали (привет, debounce/throttle):
См. Инспектор — там полная форма снимка.
Паттерны инспекции снимков
Заголовок раздела «Паттерны инспекции снимков»Три полезных паттерна, когда отлаживаешь флейк-триггер или строишь contract-тест.
Последний запуск
Заголовок раздела «Последний запуск»trigger.inspect() возвращает самый свежий снимок триггера или undefined, если он не запускался. Эквивалентно runtime.getInspectorBuffer().find(s => s.triggerId === trigger.id), но O(1).
Полная последовательность (newest first)
Заголовок раздела «Полная последовательность (newest first)»Буфер — кольцо; размер по умолчанию 50. Если проверяешь длинные последовательности, увеличь inspectorBufferSize.
Подписанный слушатель
Заголовок раздела «Подписанный слушатель»Полезно, когда нужно проверить порядок между несколькими триггерами в одном рантайме.
Конфигурация Vitest
Заголовок раздела «Конфигурация Vitest»Минимальный конфиг Vitest, подбирающий тесты триггеров где угодно в src/:
environment: 'node' намеренный — модульные тесты не рендерят React, поэтому jsdom ничего не даёт и тормозит сьют. Используй environment: 'jsdom' (или happy-dom) только для интеграционных тестов.
Небольшой глобальный хелпер, чтобы тесты были опрятными:
Тогда каждый тест начинает со свежим рантаймом, а dispose() чистит выполняющиеся таймеры от отменённых async-обработчиков — никаких утечек между тестами.
Заметки по interop с Jest
Заголовок раздела «Заметки по interop с Jest»Пакеты Triggery — ESM-only. Дефолтный конфиг Jest всё ещё трактует файлы как CommonJS, из-за чего import { createTrigger } from '@triggery/core' падает с SyntaxError: Cannot use import statement outside a module. Два пути:
Запусти Jest с ESM VM-флагом (рекомендуется)
Заголовок раздела «Запусти Jest с ESM VM-флагом (рекомендуется)»Работает для Jest 28+, и это официальный ESM-путь. Если ты на более старом Jest — обновись; ESM-история до 28 слишком хрупка для повседневной работы.
Перейди на Vitest
Заголовок раздела «Перейди на Vitest»Если можешь выбирать: Vitest запускает Jest-формы тестов без transform-конфига, нативный ESM и ~10× быстрее холодный старт. Миграция — это import { test, expect, vi } from 'vitest' и однопаговый vitest.config.ts. Тесты триггеров — чистая логика; нет Jest-API, которого тебе будет не хватать.
bun:test и node:test
Заголовок раздела «bun:test и node:test»Оба работают из коробки. Кит не зависит от Vitest-специфичных глобалов; единственная адаптация на тест-раннер — какая библиотека для подмен.