Что, если бы автотесты читались как сценарий?
Что, если бы каждый шаг был понятен, каждая метка — на месте, а отчёт — пригоден не только для QA, но и для бизнеса?
Так появился Scenax — DSL-фреймворк поверх Vitest и Allure, превращающий тесты в читаемые сценарии.

? Структура статьи

  1. Итерация 1: DSL-обёртка testCase, step, attach

  2. Scenax — не просто DSL: это архитектура

  3. Итерация 2: Классы, декораторы и runTest()

  4. Итерация 3: параметризация тестов с @TestCase.each

  5. Итерация 4: описание тестов через @Description, @Tag, @Owner, @Severity

    ? Ссылка на Часть 2

  6. Итерация 5: @Suite, @ParentSuite, @SubSuite, @Layer — иерархия тестов в Allure

  7. Итерация 6: @Setup, @Teardown, @Context, @Inject — жизненный цикл и shared state

  8. Итерация 7: @BeforeAll, @AfterAll, @Setup(params) — масштабирование сценариев

  9. Итерация 8: @Step, @Scenario() и автономные классы шагов

  10. Как scenax вписывается в стек технологий

  11. Заключение: почему 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) с автосериализацией

? Почему это важно

  1. Тест становится читаем как сценарий

  2. Удаляет boilerplate (label, step, attachment), cокращается дублирование, уменьшается вероятность ошибок (label('AS_ID', ...) → типизировано)

  3. Повышает читаемость, особенно в отчётах

  4. Стандартизирует стиль написания тестов

  5. Закладывает фундамент для архитектуры: классы, декораторы, слои. Готова почва для 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

  • и автоматизируем запуск целых слоёв

Комментарии (0)