Что, если бы автотесты читались как сценарий?
Что, если бы каждый шаг был понятен, каждая метка — на месте, а отчёт — пригоден не только для QA, но и для бизнеса?
Так появился Scenax — DSL-фреймворк поверх Vitest и Allure, превращающий тесты в читаемые сценарии.
📑 Структура статьи
-
Итерация 1: DSL-обёртка testCase, step, attach
-
Scenax — не просто DSL: это архитектура
-
Итерация 2: Классы, декораторы и
runTest() -
Итерация 3: параметризация тестов с
@TestCase.each -
Итерация 4: описание тестов через
@Description,@Tag,@Owner,@SeverityЧасть 2
-
Итерация 5:
@Suite,@ParentSuite,@SubSuite,@Layer— иерархия тестов в Allure -
Итерация 6:
@Setup,@Teardown,@Context,@Inject— жизненный цикл и shared state -
Итерация 7:
@BeforeAll,@AfterAll,@Setup(params)— масштабирование сценариев -
Итерация 8:
@Step,@Scenario()и автономные классы шагов -
Как
scenaxвписывается в стек технологий -
Заключение: почему
scenax— это новый стандарт для API-тестов на Vitest
🤯 Проблема
Современные инструменты автотестирования мощны — но требуют дисциплины и ручной работы, за основу возьмем один из самых популярных фреймворков тестирования vitest:
-
vitest.test()хорош для unit-проверок, но не подходит для сценариев с шагами и контекстом -
Метки (
feature,severity,tag,owner) задаются вручную, легко ошибиться -
Шаги через
allure.step(...)— не типизированы, не читаемы, не переиспользуемы -
Нет архитектуры: шаги дублируются, контекст передаётся вручную
В итоге тесты теряют читаемость и перестают быть документацией. Вместо сценария — каша из кода.
💡 Идея
Мы хотим, чтобы тест выглядел как намерение, а не как реализация.
testCase( 'Создание пользователя', { id: 'API-001', feature: 'Users', severity: 'critical' }, async () => { const response = await step('POST /users', () => axios.post('/users', { name: 'Иван' }) ) attach('Ответ', response.data, 'application/json') expect(response.status).toBe(201) } )
Это должен быть не просто автотест. Сценарий, документ, понятный и разработчику, и аналитику, и бизнесу. Вариант, предложенный выше, выглядит уже намного приятнее глазу, чем подобный тест на vitest + allure.
Что такое Scenax? О чём эта статья.
Мы определились, что хотим видеть намерение и составили список, определяющий библиотеку исходя из наших «хотелок».
Scenax — это:
-
DSL (язык сценариев) поверх Vitest + Allure (опираемся на базовые библиотеки)
-
Class-based архитектура: тесты = сценарии, методы = шаги
-
Декораторы:
@TestCase,@Feature,@Step,@Contextи др. -
Переиспользуемые шаги и сценарные классы
-
Поддержка параметризации, lifecycle, иерархии
-
Чистые отчёты Allure — без ручного label/step/attach
Мы покажем путь — от простого DSL до полноценного архитектурного подхода, для удобства разделив все на итерации, где каждая будет:
-
Давать реальный value (шаг за шагом)
-
Содержать читаемый код
-
Подкрепляться живыми примерами
В финале получим:
-
📦 Переиспользуемую библиотеку
scenax -
💻 Открытый репозиторий на GitHub
-
✍️ Вводную статью для команды
-
И, возможно, новый взгляд на тестирование
Начнём.
Итерация 1: DSL-обёртка testCase, step, attach
🎯 Цель
Сделать API-тест читаемым, логически структурированным и готовым к загрузке в Allure TestOps — без необходимости вручную писать allure.label(...), allure.step(...) и другие низкоуровневые вызовы.
🤯 Проблема
allure-vitest предоставляет доступ к фасаду Allure (метки, шаги, вложения), но при этом API остаётся низкоуровневым и дублируемым:
import * as allure from 'allure-js-commons' test('profile', async () => { await allure.label('AS_ID', 'API-123') await allure.feature('Profile') await allure.step('GET /profile', async () => { const res = await axios.get('/profile') await allure.attachment('response', JSON.stringify(res.data), 'application/json') expect(res.status).toBe(200) }) })
Повторяются одни и те же вызовы. Лёгко ошибиться в строковых ключах (AS_ID, severity). Снижается читаемость и темп разработки.
Решение — обёртка
Создаём минимальный DSL: testCase, step, attach → единый стиль, чистая структура, меньше ошибок.
testCase( 'Получение профиля', { id: 'API-101', feature: 'Профиль', severity: 'critical' }, async () => { const response = await step('GET /profile', () => axios.get('/profile')) attach('Ответ', response.data, 'application/json') expect(response.status).toBe(200) } )
Коротко, более декларативно, красиво. Всё нужное — на виду.
🔧 Что мы сделали
✍️ Написали testCase(name, meta, fn) с поддержкой метаданных
📚 Обернули шаги в step(name, fn)
📎 Добавили attach(name, content) с автосериализацией
💡 Почему это важно
-
Тест становится читаем как сценарий
-
Удаляет boilerplate (
label,step,attachment), cокращается дублирование, уменьшается вероятность ошибок (label('AS_ID', ...)→ типизировано) -
Повышает читаемость, особенно в отчётах
-
Стандартизирует стиль написания тестов
-
Закладывает фундамент для архитектуры: классы, декораторы, слои. Готова почва для class-based архитектуры
⚙️ Под капотом
import * as allure from 'allure-js-commons' import { test } from 'vitest' export function testCase(name, meta, fn) { test(name, async () => { if (meta.id) await allure.label('AS_ID', meta.id) if (meta.feature) await allure.feature(meta.feature) if (meta.severity) await allure.severity(meta.severity) await fn() }) } export async function step(name, fn) { return await allure.step(name, fn) } export function attach(name, data, type = 'text/plain') { allure.attachment(name, typeof data === 'string' ? data : JSON.stringify(data), type) }
Итоги Итерации 1: читаемые тесты, декларативность и язык намерений
Первая итерация дала нам минимально жизнеспособный DSL (testCase, step, attach) для написания API-тестов. И вроде бы — всего три обёртки. Но они изменили всё.
Как это назвать?
Мы больше не пишем “тест-функцию”. Мы описываем тест-кейс.
Это не unit test, это use-case
Это не test(name, () => {}), это testCase(meta, steps)
Это не “проверка функции”, это “проверка бизнес-сценария”
Это направление ближе к:
Scenario-based testing — есть шаги, сценарии, feature
Use-case testing — каждый testCase фактически use-case
Structured test DSL — у нас свой язык описания
🔜 Что дальше
В следующей итерации мы превратим тесты в полноценные классы с @TestCase, @Feature, @Severity и единым runTest().Так мы сделаем ещё один шаг от технической реализации — к тесту как сценарию.
📎 После первой итерации легко подумать:
“Это просто обёртка над vitest и allure, да?” Но нет. Мы закладываем архитектуру. И это важно.
Итерация 2: Классы, декораторы и runTest()
🎯 Цель
Перевести тесты из функций в структурированные классы, чтобы:
— сократить дублирование шагов
— повысить читаемость
— обеспечить масштабируемую архитектуру
🤔 Проблема
В прошлой итерации мы писали тесты в функциональном стиле:
testCase('Получение профиля', async () => { const res = await step('GET /profile', () => api.getProfile()) attach('Ответ', res.data) expect(res.status).toBe(200) })
Это уже хорошо и читаемо, но может приводить к дублированию логики — особенно если в сценарии много шагов или тестов несколько.
Нам нужно:
— возможность переиспользовать this.ctx
— структурировать шаги как методы
— сгруппировать тесты по feature
Решение — перейти к классам
С помощью декораторов @Feature, @TestCase, @Severity и runTest() мы превращаем каждый тест в метод, а весь сценарий — в класс.
@Feature('Профиль') class ProfileTest { @TestCase('Получение профиля', { id: 'API-102', severity: 'critical' }) async testProfile() { const res = await step('GET /profile', () => api.getProfile()) attach('Ответ', res.data) expect(res.status).toBe(200) } } runTest(ProfileTest)
🔧 Что сделали
— Создали декоратор @TestCase(name, meta) для метода
— Добавили @Feature и @Severity
— Написали runTest() — адаптер, который превращает все методы в test()
export function runTest(clazz) { const instance = new clazz() const proto = Object.getPrototypeOf(instance) for (const key of Object.getOwnPropertyNames(proto)) { const method = proto[key] const meta = Reflect.getMetadata('testcase', instance, key) if (typeof method === 'function' && meta) { test(meta.name, async () => { if (meta.id) await label('AS_ID', meta.id) if (meta.severity) await severity(meta.severity) await method.call(instance) }) } } }
Пример в стиле Scenax
@Feature('Профиль') class ProfileTest { @TestCase('Проверка имени пользователя', { id: 'API-103', severity: 'normal' }) async checkName() { const res = await step('GET /profile', () => api.getProfile()) expect(res.data.name).toBe('Иван') } @TestCase('Проверка email') async checkEmail() { const res = await step('GET /profile', () => api.getProfile()) expect(res.data.email).toMatch(/@example\.com/) } } runTest(ProfileTest)
Что такое класс в Scenax?
Класс в Scenax — это архитектурная единица, описывающая тестируемый сценарий на уровне бизнес-фичи или контекста.
В терминах проектирования:
— Это не просто набор методов — это контейнер намерения
— Он объединяет тест-кейсы по логике, а не по типу
— Он становится единицей в Allure-отчёте, документации и архитектуре
Фича или сущность?
Чаще всего — сущность, например: ProfileTest, AuthFlow, PaymentChecks.
Но может быть и логическая группа тестов: RegressionSuite, MobileAPITests, UnauthorizedFlows.
Что можно «повесить» на класс?
@Feature('Profile')— на класс, «Название бизнес фичи»@Suite('API') — на класс, «Группировка в Allure»@ParentSuite('E2E') — на класс, к примеру «Категория (UI, e2e, regression и т.п.)»@Layer('e2e') — на класс, «Архитектурный слой»@Context() — на поле, «Передаёт shared state для всех методов»@Inject() — на поле, «Внедряет вспомогательные step-классы»
Класс — это:
-
Контейнер тестов с единым контекстом
-
Неймспейс, где можно централизованно задать
Feature,Suite,Layer -
Платформа для Lifecycle-хуков (
@BeforeAll,@Setup,@Teardown) -
Единица документации, которая отображается в Allure как модуль
💡 Класс делает архитектуру тестов явной, предсказуемой и расширяемой
Почему классы?
Вот несколько причин, которые подошли для нашей команды:
-
Возможность шарить
this.ctx,this.client,this.steps -
Легко группировать тесты по сущности (
@Feature('Profile')) -
Можно подключить lifecycle (
@BeforeAll,@Setup,@Teardown) -
Привычно для backend-разработчиков и архитекторов
Мы не заменяем Vitest. Мы описываем намерения в архитектурной форме.
Что это дает на практике?
Было/Стало:
-
testCase(name, fn)/@TestCase()над методом -
Метки внутри тела теста / Декораторы над методом/классом
-
Один тест = одна функция / Один сценарий = один класс
-
Нет общего контекста /
this.ctx,this.steps, и др.
Что дальше?
В следующей итерации — сделаем параметризацию тестов через @TestCase.each() и создадим первую полноценную data-driven структуру.
➡️ К одному сценарию — много входов. Много данных. Один стиль.
Итерация 3: параметризация тестов с @TestCase.each
Один из самых частых паттернов в автотестах — проверка одного сценария с разными данными. Vitest умеет test.each(...), но наш DSL — тоже.
Цель
— Добавить @TestCase.each([...]) — для генерации множественных тестов
— Автоматически передавать параметры в метод
— Фиксировать значения в отчёте через allure.parameter
Как выглядит
@Feature('Авторизация') class AuthTests { @TestCase.each([ ['admin@example.com', 'admin123', 200], ['user@example.com', 'user123', 200], ['hacker@example.com', 'wrongpass', 401], ])('Логин для %s', async (email, password, expectedStatus) => { const res = await axios.post('/login', { email, password }) expect(res.status).toBe(expectedStatus) }) }
Что происходит:
-
Генерируются 3 отдельных теста с названиями:
Логин дляadmin@example.com,Логин дляuser@example.com и т.д. -
Аргументы
email,password,expectedStatusпередаются в метод -
Allure фиксирует параметры:
param1 =admin@example.com,param2 = admin123,param3 = 200
Любое количество аргументов
Метод получает столько аргументов, сколько указано в .each():
@TestCase.each([ ['admin', '123', 'desktop', true], ['guest', 'qwerty', 'mobile', false] ])('Попытка входа: %s', async (login, pass, platform, expected) => { // все аргументы приходят как есть })
DSL не ограничивает количество параметров — работает как
(...args) => {}
📈 Выгода
— Читаемость и лаконичность
— Единая точка теста и данных
— Allure отображает параметры и шаги для каждого случая
— Работает с step() и attach()
🎯 Результат
Теперь наш DSL поддерживает один из самых частых паттернов в тестировании. В следующей итерации — @BeforeEachCase, хуки и re-use шагов.
💭 «Постойте… А чем это лучше обычного test.each?»
Отличный вопрос.
Вы, скорее всего, сейчас думаете:
«Окей, я вижу
@TestCase.each, красиво, декларативно…
Но ведь у Vitest уже естьtest.each(...)— разве не то же самое?»
Разберём по полочкам.
🤜 test.each — это удобно. Но…
Когда вы пишете так:
test.each([ ['email1', 'pass1', 200], ['email2', 'pass2', 401], ])('Login for %s', ...)
— вы действительно получаете быстрый, минималистичный тест.
Но что если вы хотите:
— Подсветить фичу (@Feature('Авторизация')) — Повесить ID на тест (AS_ID, TMS, Issue) — Проставить severity или owner — Сделать структурированные шаги внутри отчёта — Приложить response JSON в Allure
Всё это вам придётся делать вручную, с кучей allure.label(...), allure.step(...) и allure.attachment(...).
🎯 А вот @TestCase.each — уже про сценарии
@TestCase.each([ ['admin@example.com', 'admin123', 200], ['user@example.com', 'user123', 200], ])('Логин для %s', { severity: 'critical' }) // Добавляем severity,feature и т.д. async login(email, password, expectedStatus) { ... }
И получаем:
— Один метод → несколько кейсов
— Метки (severity, feature) — встроены
— Название кейса — шаблонное
— Параметры отображаются в Allure
— Отчёт структурирован: шаги, вложения, параметры
💡 Вывод
Если вы просто хотите повторить тест 3 раза — test.each вас спасёт. Но если вы описываете бизнес-сценарии, хотите качественные отчёты и масштабируемый DSL, то @TestCase.each — это уже язык, а не просто удобная функция. По сути, мы создаём структурированный тестовый фреймворк на базе Vitest,не конкурируя с ним, а надстраивая декларативный слой.
Итерация 4: описание тестов через @Description, @Tag, @Owner, @Severity
🎯 Цель
Дать тестам больше смысла — прямо из кода.
Добавим поддержку мета-декораторов: описаний, тегов, владельцев, уровней важности.
Почему это важно?
Открываете Allure и видите: «Логин для admin@example.com» — пройден ✅» Но больше — ничего. Ни кто владелец, ни зачем тест, ни приоритет.Всё это важно, особенно когда:
Всё это важно, особенно когда:
— 👥 тестов много
— 📊 нужна аналитика в TestOps
— 🤝 команда хочет понимать, что тест проверяет
Что мы сделали
Добавили поддержку следующих декораторов:
@Description('Проверяет, что пользователь с валидными данными может авторизоваться') @Tag('auth') @Owner('dmitry.nkt') @Severity('critical')
Теперь они работают как на класс, так и на метод.
Как это работает?
Каждый из этих декораторов:
-
сохраняет значение в metadata через
reflect-metadata -
при запуске в
runTest— применяется к Allure через facade (allure.description,allure.tag, …)
Пример использования
@Feature('Авторизация') @Tag('api') @Owner('backend-team') class AuthTests { @TestCase.each([ ['admin@example.com', 'admin123', 200], ['hacker@example.com', 'wrongpass', 401] ])('Логин для %s') @Description('Проверяет сценарий логина с учётными данными') @Tag('login') @Severity('critical') @Owner('dmitry.nkt') async login(email, password, expectedStatus) { const res = await step(`POST /login`, () => axios.post('https://httpbin.org/status/' + expectedStatus, { email, password }) ) expect(res.status).toBe(expectedStatus) } }
Что это даёт
— 📎 Видно, зачем тест (описание)
— 🧩 Кто его владелец (@Owner)
— 🚦 Насколько он важен (@Severity)
— 🏷️ Какой группе принадлежит (@Tag)
— 📊 Allure и TestOps могут группировать, фильтровать, считать покрытие по owner/feature
Итог
Мы добавили семантический слой над тестами. Теперь каждый тест-кейс несёт не только шаги, но и контекст — и для людей, и для систем.
🔁 Что дальше?
В первой части мы заложили фундамент:
-
построили минимальный DSL (
testCase) -
добавили параметризацию (
@TestCase.each) -
научились задавать структуру для Allure (
@Feature, @Suite,@Layer) -
объединили сценарии в классы
Это уже делает Scenax мощным инструментом — особенно для описания API и E2E-процессов как человеческих сценариев, а не голого кода.
Но дальше — ещё интереснее.
Во второй части статьи мы переходим от сценариев к архитектуре целых тестовых библиотек:
подключаем lifecycle-хуки
вводим @Context и @Inject
создаём Step Library
и автоматизируем запуск целых слоёв
ссылка на оригинал статьи https://habr.com/ru/articles/914450/
Добавить комментарий