Converting Postman collections with env support to JMeter

 · 21 min read
 · emkeyen
Table of contents

If you've ever had to migrate Postman API tests into JMeter for load testing, you know the drill: either you manually recreate everything (pain), or you find some hacky tool that half-works. So I wrote a script in Python to make this a bit less annoying.

The source code is available on GitHub: https://github.com/emkeyen/postman-to-jmx

Functionality

  • Converts a Postman collection JSON into a JMeter jmx test plan.
  • Adds support for raw body, form-urlencoded, headers, query params.
  • Pulls in both collection-level vars and optional env files as JMeter User Defined Variables.
  • Handles folders, nested requests, and basic structure in a way JMeter can actually use.

Usage

Just save the script as postman2jmx.py, make it executable, and run it like this:

chmod +x postman2jmx.py
./postman2jmx.py my_collection.json output.jmx -e my_env.json

The -e flag is optional. If you pass in an environment JSON exported from Postman, it’ll include those values in the test plan.

Notes

  • A single-thread JMeter test plan (you can tweak that later).
  • Every request in Postman becomes a corresponding HTTPSamplerProxy.
  • Headers and body content (raw or form) are preserved.
  • Query params are parsed and added as proper JMeter args.
  • Vars show up in the Test Plan as global UDV blocks.
  • Variables from your Postman collection and environment will appear as "User Defined Variables" in JMeter. Remember that in JMeter, you reference these variables using ${variable_name}.
  • The script handles raw JSON and x-www-form-urlencoded bodies. Other complex body types (like formdata with file uploads) might need manual adjustment in JMeter after conversion.
  • Any JS code you have in Postman's pre-request or test scripts won't be converted. You'll need to re-implement that logic in JMeter using JSR223 samplers or other JMeter elements.

Mostly for folks who already use Postman heavily for API development but need to load test without rebuilding everything from scratch in JMeter. You still might need to tweak the resulting jmx for more advanced stuff (think auth flows, assertions, etc.), but it saves a bunch of boilerplate work.

#!/usr/bin/env python3
import json
import argparse
import xml.etree.ElementTree as ET
from xml.dom import minidom
from urllib.parse import urlparse, parse_qs

def convert_postman_to_jmx(postman_file, output_file, environment_file=None):
    """
    Converts a Postman collection JSON file to a JMeter JMX file.

    Args:
        postman_file (str): path to the Postman collection JSON file.
        output_file (str): path where the JMeter JMX file will be saved.
        environment_file (str, optional): path to the Postman env JSON file.
                                        if provided, env vars will be added.
    """
    # load Postman collection
    with open(postman_file, 'r') as f:
        collection = json.load(f)

    # load Postman env if provided
    environment_vars = []
    if environment_file:
        try:
            with open(environment_file, 'r') as f_env:
                environment_data = json.load(f_env)
                if 'values' in environment_data:
                    # filter for enabled vars from env
                    environment_vars = [v for v in environment_data['values'] if v.get('enabled', True)]
        except FileNotFoundError:
            print(f"Warning: Environment file '{environment_file}' not found. Skipping environment variables.")
        except json.JSONDecodeError:
            print(f"Warning: Could not parse environment file '{environment_file}'. Skipping environment variables.")


    # create JMX root structure
    jmeter_test_plan = ET.Element('jmeterTestPlan', version="1.2", properties="5.0", jmeter="5.2.1")
    hash_tree = ET.SubElement(jmeter_test_plan, 'hashTree')

    # create test plan
    test_plan = ET.SubElement(hash_tree, 'TestPlan', {
        'guiclass': 'TestPlanGui',
        'testclass': 'TestPlan',
        'testname': 'Postman Collection Import',
        'enabled': 'true'
    })
    ET.SubElement(test_plan, 'boolProp', name="TestPlan.functional_mode").text = 'false'
    ET.SubElement(test_plan, 'stringProp', name="TestPlan.comments")
    ET.SubElement(test_plan, 'boolProp', name="TestPlan.serialize_threadgroups").text = 'false'
    ET.SubElement(test_plan, 'stringProp', name="TestPlan.user_define_classpath")

    # add default user defined variables to the test plan (can be empty)
    element_prop = ET.SubElement(test_plan, 'elementProp', {
        'name': 'TestPlan.user_defined_variables',
        'elementType': 'Arguments'
    })
    ET.SubElement(element_prop, 'collectionProp', name="Arguments.arguments")

    hash_tree2 = ET.SubElement(hash_tree, 'hashTree')

    # create thread group
    thread_group = ET.SubElement(hash_tree2, 'ThreadGroup', {
        'guiclass': 'ThreadGroupGui',
        'testclass': 'ThreadGroup',
        'testname': collection.get('info', {}).get('name', 'Postman Requests'),
        'enabled': 'true'
    })

    element_prop = ET.SubElement(thread_group, 'elementProp', {
        'name': 'ThreadGroup.main_controller',
        'elementType': 'LoopController',
        'guiclass': 'LoopControlPanel',
        'testclass': 'LoopController',
        'enabled': 'true'
    })
    ET.SubElement(element_prop, 'boolProp', name="LoopController.continue_forever").text = 'false'
    ET.SubElement(element_prop, 'stringProp', name="LoopController.loops").text = '1'

    ET.SubElement(thread_group, 'stringProp', name="ThreadGroup.num_threads").text = '1'
    ET.SubElement(thread_group, 'stringProp', name="ThreadGroup.ramp_time").text = '1'
    ET.SubElement(thread_group, 'boolProp', name="ThreadGroup.scheduler").text = 'false'
    ET.SubElement(thread_group, 'stringProp', name="ThreadGroup.duration").text = '0'
    ET.SubElement(thread_group, 'stringProp', name="ThreadGroup.delay").text = '0'
    ET.SubElement(thread_group, 'stringProp', name="ThreadGroup.on_sample_error").text = 'continue'
    ET.SubElement(thread_group, 'boolProp', name="ThreadGroup.same_user_on_next_iteration").text = 'true'

    hash_tree3 = ET.SubElement(hash_tree2, 'hashTree')

    # process collection vars
    if 'variable' in collection:
        add_user_defined_variables(collection['variable'], hash_tree3, name="Collection Variables")

    # process env vars
    if environment_vars:
        add_user_defined_variables(environment_vars, hash_tree3, name="Environment Variables")

    # process items recursively (requests and folders)
    if 'item' in collection:
        process_items(collection['item'], hash_tree3)

    # convert to XML string with pretty formatting
    xml_str = ET.tostring(jmeter_test_plan, encoding='utf-8', method='xml')
    dom = minidom.parseString(xml_str)

    # empty elements are properly serialized
    for elem in dom.getElementsByTagName('stringProp'):
        if not elem.firstChild and elem.getAttribute('name') == 'HTTPSampler.port':
            elem.appendChild(dom.createTextNode(''))

    pretty_xml = dom.toprettyxml(indent="    ")

    # remove redundant newlines and spaces added by minidom
    pretty_xml = '\n'.join(line for line in pretty_xml.split('\n') if line.strip())

    with open(output_file, 'w') as f:
        f.write(pretty_xml)

def add_user_defined_variables(variables, parent_hash_tree, name="User Defined Variables"):
    """
    Adds User Defined Variables to the JMeter JMX structure.

    Args:
        variables (list): A list of dictionaries, where each dictionary represents a variable
                        with 'key' and 'value' (e.g., from Postman collection or environment).
        parent_hash_tree (ET.Element): The parent hashTree element to which the Arguments element will be added.
        name (str): The name to be displayed for this set of User Defined Variables in JMeter.
    """
    if not variables:
        return

    arguments = ET.SubElement(parent_hash_tree, 'Arguments', {
        'guiclass': 'ArgumentsPanel',
        'testclass': 'Arguments',
        'testname': name,
        'enabled': 'true'
    })
    collection_prop = ET.SubElement(arguments, 'collectionProp', name="Arguments.arguments")

    for var in variables:
        # ensure 'key' and 'value' exist before processing
        if 'key' in var and 'value' in var:
            element_prop = ET.SubElement(collection_prop, 'elementProp', {
                'name': var['key'],
                'elementType': 'Argument'
            })
            ET.SubElement(element_prop, 'stringProp', name="Argument.name").text = var['key']
            ET.SubElement(element_prop, 'stringProp', name="Argument.value").text = str(var['value'])
            ET.SubElement(element_prop, 'stringProp', name="Argument.metadata").text = '='

    ET.SubElement(parent_hash_tree, 'hashTree') # This hashTree closes the Arguments element

def process_items(items, parent_element):
    """
    Recursively processes Postman collection items (folders and requests).

    Args:
        items (list): A list of Postman collection items.
        parent_element (ET.Element): The parent XML element to which new elements will be added.
    """
    for item in items:
        if 'item' in item:
            # this is a dir, create a JMeter test fragment or just process its children directly
            process_items(item['item'], parent_element)
        else:
            # request
            process_request(item, parent_element)

def process_request(item, parent_element):
    """
    Processes a single Postman request and converts it into an HTTPSamplerProxy element.

    Args:
        item (dict): The Postman request item dictionary.
        parent_element (ET.Element): The parent XML element to which the sampler and its hashTree will be added.
    """
    if 'request' not in item:
        return

    request = item['request']

    # init URL and method components with defaults
    method = request.get('method', 'GET')
    domain = ''
    path = ''
    protocol = 'http'
    port = ''

    # create HTTPSamplerProxy
    sampler = ET.SubElement(parent_element, 'HTTPSamplerProxy', {
        'guiclass': 'HttpTestSampleGui',
        'testclass': 'HTTPSamplerProxy',
        'testname': item.get('name', 'Unnamed Request'),
        'enabled': 'true'
    })

    # process request body
    if 'body' in request:
        body = request['body']
        if body.get('mode') == 'raw' and 'raw' in body and body['raw']:
            ET.SubElement(sampler, 'boolProp', name="HTTPSampler.postBodyRaw").text = 'true'
            element_prop = ET.SubElement(sampler, 'elementProp', {
                'name': 'HTTPsampler.Arguments',
                'elementType': 'Arguments',
                'guiclass': 'HTTPArgumentsPanel',
                'testclass': 'Arguments',
                'enabled': 'true'
            })
            collection_prop = ET.SubElement(element_prop, 'collectionProp', name="Arguments.arguments")

            arg_element = ET.SubElement(collection_prop, 'elementProp', {
                'name': '', # name is empty for raw body
                'elementType': 'HTTPArgument'
            })
            ET.SubElement(arg_element, 'boolProp', name="HTTPArgument.always_encode").text = 'false'
            ET.SubElement(arg_element, 'stringProp', name="Argument.value").text = body['raw']
            ET.SubElement(arg_element, 'stringProp', name="Argument.metadata").text = '='
        elif body.get('mode') == 'urlencoded' and 'urlencoded' in body and body['urlencoded']:
            # for x-www-form-urlencoded, JMeter handles params as args
            element_prop = ET.SubElement(sampler, 'elementProp', {
                'name': 'HTTPsampler.Arguments',
                'elementType': 'Arguments',
                'guiclass': 'HTTPArgumentsPanel',
                'testclass': 'Arguments',
                'enabled': 'true'
            })
            collection_prop = ET.SubElement(element_prop, 'collectionProp', name="Arguments.arguments")

            for param in body['urlencoded']:
                arg_element = ET.SubElement(collection_prop, 'elementProp', {
                    'name': param.get('key', ''),
                    'elementType': 'HTTPArgument'
                })
                ET.SubElement(arg_element, 'boolProp', name="HTTPArgument.always_encode").text = 'false'
                ET.SubElement(arg_element, 'stringProp', name="Argument.value").text = param.get('value', '')
                ET.SubElement(arg_element, 'stringProp', name="Argument.metadata").text = '='
                ET.SubElement(arg_element, 'boolProp', name="HTTPArgument.use_equals").text = 'true'
                ET.SubElement(arg_element, 'stringProp', name="Argument.name").text = param.get('key', '')
        else:
            # handle other body types or empty body
            element_prop = ET.SubElement(sampler, 'elementProp', {
                'name': 'HTTPsampler.Arguments',
                'elementType': 'Arguments',
                'guiclass': 'HTTPArgumentsPanel',
                'testclass': 'Arguments',
                'enabled': 'true'
            })
            ET.SubElement(element_prop, 'collectionProp', name="Arguments.arguments")
    else:
        # no body in request
        element_prop = ET.SubElement(sampler, 'elementProp', {
            'name': 'HTTPsampler.Arguments',
            'elementType': 'Arguments',
            'guiclass': 'HTTPArgumentsPanel',
            'testclass': 'Arguments',
            'enabled': 'true'
        })
        ET.SubElement(element_prop, 'collectionProp', name="Arguments.arguments")


    # set common sampler props (bool props)
    ET.SubElement(sampler, 'boolProp', name="HTTPSampler.auto_redirects").text = 'false'
    ET.SubElement(sampler, 'boolProp', name="HTTPSampler.follow_redirects").text = 'true'
    ET.SubElement(sampler, 'boolProp', name="HTTPSampler.use_keepalive").text = 'true'
    ET.SubElement(sampler, 'boolProp', name="HTTPSampler.monitor").text = 'false'
    ET.SubElement(sampler, 'boolProp', name="HTTPSampler.DO_MULTIPART_POST").text = 'false' # may need adjustment for multipart/form-data
    ET.SubElement(sampler, 'stringProp', name="HTTPSampler.embedded_url_re")
    ET.SubElement(sampler, 'stringProp', name="HTTPSampler.contentEncoding")


    # process URL (logic to determine domain, path, protocol, port)
    if 'url' in request:
        url_data = request['url']
        if isinstance(url_data, str):
            parsed_url = urlparse(url_data)
            domain = parsed_url.hostname or ''
            path = parsed_url.path or ''
            protocol = parsed_url.scheme or 'http'
            port = str(parsed_url.port) if parsed_url.port else '' # Ensure port is string, even if None

            # handle query paramsfrom URL string
            if parsed_url.query:
                query_args_element_prop = sampler.find("./elementProp[@name='HTTPsampler.Arguments']")
                if query_args_element_prop is None: # Create if not already present from body
                    query_args_element_prop = ET.SubElement(sampler, 'elementProp', {
                        'name': 'HTTPsampler.Arguments',
                        'elementType': 'Arguments',
                        'guiclass': 'HTTPArgumentsPanel',
                        'testclass': 'Arguments',
                        'enabled': 'true'
                    })
                    ET.SubElement(query_args_element_prop, 'collectionProp', name="Arguments.arguments")

                query_collection_prop = query_args_element_prop.find("collectionProp[@name='Arguments.arguments']")

                query_params = parse_qs(parsed_url.query)
                for key, values in query_params.items():
                    for value in values:
                        arg_element = ET.SubElement(query_collection_prop, 'elementProp', {
                            'name': key,
                            'elementType': 'HTTPArgument'
                        })
                        ET.SubElement(arg_element, 'boolProp', name="HTTPArgument.always_encode").text = 'true'
                        ET.SubElement(arg_element, 'stringProp', name="Argument.value").text = value
                        ET.SubElement(arg_element, 'stringProp', name="Argument.metadata").text = '='
                        ET.SubElement(arg_element, 'boolProp', name="HTTPArgument.use_equals").text = 'true'
                        ET.SubElement(arg_element, 'stringProp', name="Argument.name").text = key

        elif isinstance(url_data, dict):
            # Postman URL object structure
            domain = '.'.join(url_data.get('host', ['localhost']))
            path_parts = url_data.get('path', [])
            if isinstance(path_parts, list):
                path = '/' + '/'.join(path_parts)
            else: # assume it's a string
                path = path_parts
            protocol = url_data.get('protocol', 'http')
            if protocol.endswith(':'): # Remove trailing colon if present
                protocol = protocol[:-1]
            port = str(url_data.get('port', '')) # Ensure port is string, even if empty

            # process query params if present in URL object
            if 'query' in url_data and url_data['query']:
                args_element = sampler.find("./elementProp[@name='HTTPsampler.Arguments']/collectionProp[@name='Arguments.arguments']")
                if args_element is None:
                    element_prop = ET.SubElement(sampler, 'elementProp', {
                        'name': 'HTTPsampler.Arguments',
                        'elementType': 'Arguments',
                        'guiclass': 'HTTPArgumentsPanel',
                        'testclass': 'Arguments',
                        'enabled': 'true'
                    })
                    args_element = ET.SubElement(element_prop, 'collectionProp', name="Arguments.arguments")

                for param in url_data['query']:
                    if 'key' in param and 'value' in param:
                        arg_element = ET.SubElement(args_element, 'elementProp', {
                            'name': param.get('key', ''),
                            'elementType': 'HTTPArgument'
                        })
                        ET.SubElement(arg_element, 'boolProp', name="HTTPArgument.always_encode").text = 'true'
                        ET.SubElement(arg_element, 'stringProp', name="Argument.value").text = param.get('value', '')
                        ET.SubElement(arg_element, 'stringProp', name="Argument.metadata").text = '='
                        ET.SubElement(arg_element, 'boolProp', name="HTTPArgument.use_equals").text = 'true'
                        ET.SubElement(arg_element, 'stringProp', name="Argument.name").text = param.get('key', '')

    # create all primary sampler props using the extracted values
    ET.SubElement(sampler, 'stringProp', name="HTTPSampler.method").text = method
    ET.SubElement(sampler, 'stringProp', name="HTTPSampler.domain").text = domain
    ET.SubElement(sampler, 'stringProp', name="HTTPSampler.path").text = path
    ET.SubElement(sampler, 'stringProp', name="HTTPSampler.protocol").text = protocol
    port_prop = ET.SubElement(sampler, 'stringProp', name="HTTPSampler.port")
    port_prop.text = port or ''  # This handles both None and empty string


    # create hashTree for sampler
    sampler_hash_tree = ET.SubElement(parent_element, 'hashTree')

    # process headers
    if 'header' in request and request['header']:
        header_manager = ET.SubElement(sampler_hash_tree, 'HeaderManager', {
            'guiclass': 'HeaderPanel',
            'testclass': 'HeaderManager',
            'testname': 'HTTP Header Manager',
            'enabled': 'true'
        })
        collection_prop = ET.SubElement(header_manager, 'collectionProp', name="HeaderManager.headers")

        for header in request['header']:
            element_prop = ET.SubElement(collection_prop, 'elementProp', {
                'name': '',
                'elementType': 'Header'
            })
            ET.SubElement(element_prop, 'stringProp', name="Header.name").text = header.get('key', '')
            ET.SubElement(element_prop, 'stringProp', name="Header.value").text = header.get('value', '')

        ET.SubElement(sampler_hash_tree, 'hashTree')

    # process URL vars (path vars in Postman terminology)
    if 'url' in request and isinstance(request['url'], dict) and 'variable' in request['url'] and request['url']['variable']:
        add_user_defined_variables(request['url']['variable'], sampler_hash_tree, name="URL Path Variables")


def main():
    """
    Main func to parse args and init the conversion
    """
    parser = argparse.ArgumentParser(description='Convert Postman collection to JMeter JMX')
    parser.add_argument('input', help='Postman collection JSON file')
    parser.add_argument('output', help='Output JMX file')
    parser.add_argument('-e', '--environment', help='Postman environment JSON file (optional)', default=None)
    args = parser.parse_args()

    convert_postman_to_jmx(args.input, args.output, args.environment)
    print(f"Successfully converted {args.input} to {args.output}")

if __name__ == '__main__':
    main()