Работая в Сбере, я столкнулся с тем, что общепринятым инструментом для функционального тестирования в моем трайбе был 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:

Однако у этой функции есть одна проблема - она не раскрывает автоматически переменные JMeter из 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 в отдельные файлы завершён.
Вынос 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 со всеми её плюсами.
