Что, если бы автотесты читались как сценарий?
Что, если бы каждый шаг был понятен, каждая метка — на месте, а отчёт — пригоден не только для QA, но и для бизнеса?
Так появился Scenax — DSL-фреймворк поверх Vitest и Allure, превращающий тесты в читаемые сценарии.
? Структура статьи
Итерация 1: DSL-обёртка testCase, step, attach
Scenax — не просто DSL: это архитектура
Итерация 2: Классы, декораторы и
runTest()
Итерация 3: параметризация тестов с
@TestCase.each
-
Итерация 4: описание тестов через
@Description
,@Tag
,@Owner
,@Severity
Итерация 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
и автоматизируем запуск целых слоёв