Модульные тесты покрывают логику обработчика — при таком payload и таком снимке сработали ли нужные действия. Они ничего не говорят о слое, который этот снимок и подкладывает: useCondition в каком-то провайдере, useAction в каком-то реакторе, useEvent в каком-то продьюсере. Если баг в подключении — модульный тест его не поймает.
Интеграционные тесты рендерят настоящее дерево компонентов под TriggerRuntimeProvider, отправляют события так, как это делает UI, и проверяют наблюдаемые побочные эффекты в DOM. Они медленнее модульных (jsdom + render + commit), поэтому их меньше — но они покрывают слой, где живёт большинство багов вида «забыл смонтировать реактор».
Баг в регистрации — провайдер не монтируется на каком-то маршруте, реактор размонтируется во время перехода, скоуп переименован.
Эффект триггера виден в DOM — появляется тост, переключается класс, перемещается фокус. Проверить, что побочный эффект произошёл, проще, чем что действие было вызвано, если вся цель действия — позвать сторонний UI-либ.
Тестируешь сценарий через два скоупа / рантайма / микрофронтенда.
Тестируешь интеграцию с network-моком (MSW) — данные, скачанные на событие, должны пройти через рантайм в реактор.
На всё остальное предпочитай подход из Модульных тестов — та же логика, ~10× быстрее.
Главное правило: никогда не дели рантайм между тестами. Без изоляции триггеры, зарегистрированные в одном тесте, протекают в следующий, провайдеры от вчерашнего рендера всё ещё держат регистрации, и ты проводишь полдня, разглядывая, «почему этот тест падает только когда перед ним прошёл предыдущий».
Стандартный паттерн — свежий createRuntime, обёрнутый вокруг тестируемого компонента:
src/test-utils/render-with-runtime.tsx (React)
import { createRuntime, type Runtime } from '@triggery/core';import { TriggerRuntimeProvider } from '@triggery/react';import { render, type RenderOptions } from '@testing-library/react';import type { ReactElement } from 'react';export function renderWithRuntime( ui: ReactElement, options: RenderOptions & { runtime?: Runtime } = {},) { const runtime = options.runtime ?? createRuntime(); const result = render(ui, { ...options, wrapper: ({ children }) => ( <TriggerRuntimeProvider runtime={runtime}>{children}</TriggerRuntimeProvider> ), }); return { ...result, runtime };}
Тогда каждый тест получает свой рантайм бесплатно — а ты при этом можешь передать его явно, когда тесту нужно проверить инспектор:
Триггер объявлен на module scope, ровно как в продакшене. Рантайм — пер-тест; модуль триггера общий. Это работает, потому что createTrigger(config) без второго аргумента регистрирует против getDefaultRuntime(), а <TriggerRuntimeProvider runtime={fresh}> переопределяет, к какому рантайму обращаются хуки.
Для полной изоляции (без общего дефолтного рантайма) передавай рантайм явно: createTrigger(config, runtime) внутри тестовой фабрики. Цена — больше настройки, меньше похоже на продакшен.
await waitFor(...) покрывает разрыв микротаски между fire и запуском обработчика. await new Promise(r => setTimeout(r, 0)) тоже работает — выбирай, что роднее твоему сьюту.
Тестовая библиотека Solid экспортирует ту же поверхность render / screen / fireEvent, что и React-овская, но render(() => <App />) принимает функцию вместо JSX-элемента (рендеры Solid — реактивные функции, не снимки). Два await Promise.resolve() сливают планировщик микротасок — тот же приём, что использует flushMicrotasks внутри @triggery/testing.
Vue-овский mount даёт стандартный wrapper-API. Самый чистый паттерн — небольшой компонент Root пер-тест (или пер-файл), фиксирующий рантайм. Экспортируемый TriggerRuntimeProvider — тот же компонент, что и в продакшене — никакого отдельного тестового провайдера учить не нужно.
Главный сдвиг от модульных к интеграционным тестам — что ты проверяешь.
Модульные тесты проверяют, что действие было вызвано, — они владеют подменой действия, поэтому могут. Интеграционные тесты не подменяют действие; они проверяют, что реальный побочный эффект произошёл где-то в DOM:
DOM-проверка ловит баги, которые инспектор пропускает: реактор, вызывающий setToast, но ничего не рендерящий, портал, который не монтируется, CSS-правило, прячущее тост. Используй инспектор как вторичную проверку, когда DOM-чек уже прошёл (или как основную, когда побочный эффект невизуальный, например, запись в localStorage).
render(...) из React Testing Library авто-размонтирует между тестами в Vitest с @testing-library/jest-dom/vitest. Solid и Vue делают то же со своими конфигами. Подтверди в настройке:
Авто-размонтирование удаляет React-дерево, что снимает каждое условие и действие через cleanup-пути. Сам триггер остаётся зарегистрированным на рантайме — вызови dispose у рантайма в afterEach для надёжности:
afterEach(() => { // If you stash the runtime somewhere global per-test: currentRuntime?.dispose(); currentRuntime = undefined;});
С пер-тестовым wrapper выше это происходит автоматически — каждый тест получает свежий createRuntime(), старый становится недостижим, и сборщик мусора его уберёт.
Теперь async-обработчик работает против контролируемого fetch, а DOM-проверки ждут действия, потребляющего ответ.
Test
test('fetched message renders in toast', async () => { renderWithRuntime(<App />); fireEvent.click(screen.getByRole('button', { name: 'load' })); // Two waits: one for the microtask scheduler, one for the fetch resolution. // waitFor handles both. await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('mocked'));});
Если твой триггер использует debounce / throttle / defer, бери createFakeScheduler из @triggery/testing. Он фреймворк-агностичен и не дерётся с vi. Смесь приводит к загадкам вида «тест зависает навсегда».
Гоняй интеграционный сьют с <StrictMode>, обёрнутым вокруг провайдера рантайма. Хуки Triggery спроектированы strict-mode-safe (cycle mount→cleanup→mount проходит чисто — cleanup первого монта очищает слот до того, как второй монт запишет; last-write-wins для condition / action и channel-подписок) — обёртка делает безопасными и твои компоненты. Подробности жизненного цикла см. в StrictMode (React).
Если зовёшь createTrigger(config) без аргумента рантайма, регистрация идёт против глобального дефолтного рантайма. Два теста, делящие его — даже через wrapper, переопределяющий React-контекст — всё равно могут видеть регистрации триггеров друг друга (wrapper переопределяет React-lookup, не таргет регистрации). Два безопасных варианта:
Передай рантайм в createTrigger внутри фабрики, которую вызывает тест:
Вызывай rt.dispose() в afterEach и прими, что глобальный дефолт всё равно будет держать триггер — нормально для проверок read-after-render, проблемно для тестов «триггер ещё не существует».
В 90% интеграционных тестов глобальный дефолт нормально. Тянись за фабрикой только когда изоляция тестов реально ломается.
fire ставит в очередь. fireSync запускает сразу. fireEvent из React Testing Library синхронный, поэтому клик по кнопке, вызывающей fire, возвращается до запуска обработчика:
fireEvent.click(screen.getByRole('button')); // returns immediatelyexpect(screen.queryByRole('status')).toBeNull(); // not yet renderedawait waitFor(() => /* now it's there */ );
await waitFor(...) из React Testing Library поллит, пока проверка не пройдёт, — это закрывает и слив микротасок, и фазу коммита React. Используй его. Не тянись к act(() => …), если не замерил конкретную необходимость.