Decomposing JMeter Tests: From 50,000 to 5,000 Lines in JMX
Monolithic JMX files in JMeter with 50,000+ lines make code reviews, editing, and maintenance a nightmare. The file won't fit in memory, merge conflicts during parallel work are inevitable, and a 3,000-line XML diff in a PR is unreadable. JSR223 Groovy and SQL scripts embedded in JMX lack IDE syntax highlighting.
Decomposition reduced the JMX to 5,000 lines, moving test logic into separate files. Testing an endpoint shrank from 900 to 300 lines. The project structure became modular, just like regular code.
Extracting SQL Scripts into Separate Files
JDBC Request stores SQL inside JMX without highlighting. The __FileToString function reads .sql files but doesn't expand JMeter variables ${varName} from vars.
The solution is a universal JSR223 Sampler in Groovy. The script substitutes variables, executes SELECT/DML, and saves results to vars:
sql_result_countβ number of rowssql_col_<N>β column namessql_<row>_<col>β valuessql_resultβ first valuesql_affected_rowsβ rows affected for DML
import groovy.sql.Sql
import java.util.regex.Pattern
// ============================================================
// JMeter JSR223 Sampler - Universal SQL Executor
// ============================================================
// Parameters (space-separated):
// args[0] = JDBC URL (jdbc:postgresql://host:5432/db)
// args[1] = DB username
// args[2] = DB password
// args[3] = path to .sql file (can include spaces)
//
// Results are written to JMeter vars:
// sql_result_count - row count
// sql_col_count - column count
// sql_col_<N> - column name N (1-based)
// sql_<row>_<col> - value (1-based, e.g., sql_1_1)
// sql_result - first value (scalar shortcut)
// ============================================================
def jdbcUrl = args[0]
def dbUser = args[1]
def dbPass = args[2]
def sqlFile = args[3..args.length - 1].join(" ")
// --- Read SQL from file ---
def rawSql = new File(sqlFile).getText("UTF-8")
// --- Substitute ${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}")
// --- Connect and execute ---
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 - expect 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, etc.) ---
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()
}
Parameters in JSR223 Sampler: JDBC URL, username, password, path to .sql. Relative paths work in GUI and CLI if working directories match.
Groovy Scripts Outside JMX
JSR223 elements are moved to .groovy files. In settings, enable Cache compiled script if available β without this, compilation on every call reduces performance.
Common utilities (sql_executor.groovy, json_utils.groovy) go in a common folder, accessible to all tests.
Modularity via Test Fragment and Include Controller
Thread Groups by domain are saved as Test Fragments (right-click β Save as Test Fragment). Add them to the main JMX via Include Controller.
Pitfalls:
- In CLI, paths resolve from the working directory, not the JMX
- In CI/CD, set
cdto the project directory
Project Structure After Refactoring
Before:
project/
βββ src/test/jmeter/
βββ test_plan.jmx # 50,000+ lines
After:
project/
βββ src/test/jmeter/
βββ test_plan.jmx # ~5,000 lines
βββ common/
β βββ sql_executor.groovy
β βββ json_utils.groovy
βββ domain_a/
β βββ domain_a_fragment.jmx
β βββ create_entity.sql
β βββ cleanup.sql
β βββ pre_processing.groovy
β βββ response_validation.groovy
βββ domain_b/
β βββ domain_b_fragment.jmx
β βββ prepare_data.sql
β βββ check_result.groovy
βββ domain_c/
βββ domain_c_fragment.jmx
βββ init.sql
βββ assertions.groovy
JMX contains only the execution sequence. Logic is in specialized files with IDE support.
Key Takeaways
- JMX size reduced 10x: 50,000 β 5,000 lines
- SQL and Groovy outside JMX with highlighting and autocomplete
- Test Fragment + Include Controller for modularity
- Universal SQL script with
${varName}substitution - Cache compiled script is essential for Groovy
β Editorial Team
No comments yet.