Работая в Сбере, я столкнулся с тем, что общепринятым инструментом для функционального тестирования в моем трайбе был JMeter. Нравится ли мне это? Вопрос второстепенный. Приходилось работать с тем, что есть. По мере того как разрастались наши компоненты и их функциональность - разрастались и JMeter-тесты. Если кто не сталкивался - вся логика JMeter-тестов описана в файле с расширением .jmx. По сути это XML-файл, содержащий в себе всю логику тестов: вызовы endpoints, проверки JSON и прочая логика. И если подойти к этому вопросу без знания, то можно столкнуться с такой же проблемой, как у нас: файл разросся до 50 000+ строк и вносить/ревьюить/поддерживать его стало крайне сложно. Я нашёл способ уменьшить размер файла в 10 раз. Давайте же вместе распилим этот монолит)

Как выглядел процесс код-ревью
Как выглядел процесс код-ревью

Давайте для начала немного раскрою проблему. Что же плохого в файле на 50 000 строк?

  • Он не помещался в оперативную память моего ноутбука: поиск и редактирование по файлу работали медленно

  • При одновременной работе с JMeter-тестами двумя разными разработчиками случались противные merge-конфликты

  • PR-ревью превратилось в формальность - никто не способен адекватно проверить diff на 3 000 строк XML

  • JSR223 Groovy и SQL скрипты в JMeter не имеют подсказок и подсветки синтаксиса - хотелось бы работать с ними напрямую в IDE

Результат в числах

  • Размер JMX-файла уменьшился до 5 000 строк

  • Вся логика тестов выделена в отдельные файлы

  • Тестирование одного эндпоинта требует в 3 раза меньше строк: с 900 до 300

А теперь давайте поговорим о способах правильно структурировать JMeter-тесты, чтобы добиться такого же результата.


Что делать с SQL-скриптами?

Иногда для тестирования функциональности требуется подготовить таблицы в БД: добавить сущности, которые необходимы для тестов. Для этого в JMeter есть специальная нода - JDBC Request. И у неё есть некоторые минусы:

  • SQL-код этой ноды хранится в JMX-файле

  • У SQL нет подсказок и подсветки синтаксиса в интерфейсе JMeter

В идеале нам хотелось бы вынести эти SQL-скрипты в отдельные .sql файлы, которые можно было бы редактировать в IDE, пользуясь всеми её благами. Небольшой и быстрый гуглинг подсказывает нам, что в JMeter есть отличная встроенная функция __FileToString:

Использование функции FileToString
Использование функции FileToString

Однако у этой функции есть одна проблема - она не раскрывает автоматически переменные JMeter из vars. И если раньше мы могли написать вот так:

SQL запрос с переменной из vars
SQL запрос с переменной из vars

То теперь не можем, и переменная vGeneratedId автоматически не подставится в запрос.

Было решено написать собственный скрипт, который автоматически раскрывал бы переменные, и нам не пришлось бы переписывать все SQL-запросы. Вот сам скрипт:

import groovy.sql.Sql
import java.util.regex.Pattern

// ============================================================
// JMeter JSR223 Sampler - Universal SQL Executor
// ============================================================
// Parameters (через пробел):
//   args[0] = JDBC URL      (jdbc:postgresql://host:5432/db)
//   args[1] = DB username
//   args[2] = DB password
//   args[3] = путь до .sql файла (можно с пробелами)
//
// Результат пишется в JMeter vars:
//   sql_result_count    - кол-во строк
//   sql_col_count       - кол-во колонок
//   sql_col_<N>         - имя колонки N (1-based)
//   sql_<row>_<col>     - значение (1-based, например sql_1_1)
//   sql_result          - первое значение (scalar shortcut)
// ============================================================

def jdbcUrl  = args[0]
def dbUser   = args[1]
def dbPass   = args[2]
def sqlFile  = args[3..args.length - 1].join(" ")

// --- Читаем SQL из файла ---
def rawSql = new File(sqlFile).getText("UTF-8")

// --- Подставляем ${varName} -> vars.get("varName") ---
def resolved = rawSql.replaceAll(/\$\{(\w+)\}/) { fullMatch, varName ->
    def value = vars.get(varName)
    if (value == null) {
        log.warn("Variable '${varName}' not found in JMeter vars, leaving as-is")
        return fullMatch
    }
    return value
}

log.info("Executing SQL:\n${resolved}")

// --- Подключаемся и выполняем ---
def sql = Sql.newInstance(jdbcUrl, dbUser, dbPass, "org.postgresql.Driver")

try {
    def trimmed = resolved.stripIndent().trim()
    def isSelect = trimmed.toUpperCase() =~ /^\s*(SELECT|WITH|VALUES)\b/

    if (isSelect) {
        // --- SELECT / WITH / VALUES - ожидаем ResultSet ---
        def rows = sql.rows(resolved)

        if (rows == null || rows.isEmpty()) {
            vars.put("sql_result_count", "0")
            vars.put("sql_col_count", "0")
            vars.put("sql_result", "")
            log.info("SQL returned 0 rows")
            return
        }

        def colNames = rows[0].keySet().toList()
        vars.put("sql_result_count", String.valueOf(rows.size()))
        vars.put("sql_col_count", String.valueOf(colNames.size()))

        colNames.eachWithIndex { name, idx ->
            vars.put("sql_col_${idx + 1}", name)
        }

        rows.eachWithIndex { row, rowIdx ->
            colNames.eachWithIndex { col, colIdx ->
                def val = row[col]
                vars.put("sql_${rowIdx + 1}_${colIdx + 1}", val != null ? val.toString() : "")
            }
        }

        def firstVal = rows[0][colNames[0]]
        vars.put("sql_result", firstVal != null ? firstVal.toString() : "")
        log.info("SQL returned ${rows.size()} row(s), ${colNames.size()} col(s)")

    } else {
        // --- DML (INSERT/UPDATE/DELETE/MERGE и т.д.) ---
        def affected = sql.executeUpdate(resolved)
        vars.put("sql_result_count", "0")
        vars.put("sql_col_count", "0")
        vars.put("sql_result", "")
        vars.put("sql_affected_rows", String.valueOf(affected))
        log.info("DML executed, affected rows: ${affected}")
    }

} catch (Exception e) {
    log.error("SQL execution failed: ${e.message}", e)
    throw e
} finally {
    sql.close()
}

А использовать его можно вот таким образом: нам понадобится нода JSR223 Sampler, в параметры которой мы передаём URL подключения к БД, логин, пароль и относительный путь до .sql файла со скриптом.

Использование скрипта выполнения sql с подстановкой переменных
Использование скрипта выполнения sql с подстановкой переменных

На этом вынос SQL в отдельные файлы завершён.


Вынос Groovy-скриптов из JSR223-элементов

Данный пункт достаточно очевиден, но хотелось бы его подсветить. Все ноды, которые начинаются с JSR223..., мы выносим в отдельные .groovy файлы. Все скрипты теперь не inline, а лежат в отдельных файлах. Работаем мы с ними напрямую в IDE, а не в интерфейсе JMeter.

Важный момент: при использовании внешних скриптов обязательно ставьте галочку «Cache compiled script if available» в настройках JSR223-элемента. Без неё JMeter будет перекомпилировать Groovy-скрипт при каждом вызове, что заметно просадит производительность.

Также, если JMeter-тестов несколько, то общие утилиты на Groovy можно вынести в отдельные файлы. Например, скрипт по запуску SQL-скриптов, указанный выше, лежит в папке common и доступен всем JMeter-тестам.


Разделение Thread Group по разным JMX-файлам

Оказывается, кусочки JMX-файла можно вынести в разные файлы, указав на них ссылки в главном JMX. Как это делается?

Конкретно в моём проекте тестирование каждого домена было вынесено в отдельный Thread Group. Достаточно было выделить всё, что вложено в каждый Thread Group, и нажать ПКМ → Save as Test Fragment, выбрав путь, где будет лежать логика этой Thread Group.

Внедрение внешнего Test Fragment из JMX-файла делается с помощью ноды Include Controller.

Подводные камни Include Controller:

Будьте внимательны с путями. В GUI всё работает отлично, но при запуске из командной строки (jmeter -n -t test_plan.jmx) пути до фрагментов резолвятся относительно рабочей директории, а не относительно основного JMX-файла. Если у вас тесты запускаются из CI/CD - убедитесь, что рабочая директория при запуске совпадает с той, из которой вы работаете в GUI. Иначе получите FileNotFoundException и будете долго искать причину.


Итоговая структура проекта

Было:

project/
└── src/test/jmeter/
    └── test_plan.jmx            # 50 000+ строк, вся логика внутри

Стало:

project/
└── src/test/jmeter/
    ├── test_plan.jmx                        # ~5 000 строк, только порядок выполнения
    ├── common/
    │   ├── sql_executor.groovy              # общий скрипт запуска SQL
    │   └── json_utils.groovy                # общие утилиты
    ├── domain_a/
    │   ├── domain_a_fragment.jmx            # Test Fragment для домена A
    │   ├── create_entity.sql
    │   ├── cleanup.sql
    │   ├── pre_processing.groovy
    │   └── response_validation.groovy
    ├── domain_b/
    │   ├── domain_b_fragment.jmx
    │   ├── prepare_data.sql
    │   ├── check_result.groovy
    │   └── post_processing.groovy
    └── domain_c/
        ├── domain_c_fragment.jmx
        ├── init.sql
        └── assertions.groovy
...

Каждая папка домена - самодостаточная единица. Открыл папку - сразу видишь и фрагмент теста, и все скрипты, которые к нему относятся.


Что в итоге?

Получилась архитектура, которая интуитивно понятна разработчикам, привыкшим к обычным проектам. Её легко ревьюить и поддерживать. А размер основного JMX-файла уменьшился в 10 раз.

Основная идея моей концепции: в JMX-файлах хранится только порядок выполнения тестов. Вся остальная логика и проверки выносятся в отдельные файлы с необходимыми расширениями, и работа с ними ведётся из IDE со всеми её плюсами.