Back to Home

JMX JMeter Decomposition: from 50k to 5k lines

The article describes refactoring a monolithic JMX file in JMeter with 50 000 lines. Extract SQL to .sql with variable substitution, Groovy to files, modularity via Test Fragment. Result: size 10 times smaller, convenient code review.

JMeter JMX monolith to modules: refactoring 10x
Advertisement 728x90

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.

Google AdInline article slot

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 rows
  • sql_col_<N> β€” column names
  • sql_<row>_<col> β€” values
  • sql_result β€” first value
  • sql_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.

Google AdInline article slot

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:

Google AdInline article slot
  • In CLI, paths resolve from the working directory, not the JMX
  • In CI/CD, set cd to 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

Advertisement 728x90

Read Next