postman2pytest
August 6, 2026 ยท View on GitHub
Convert a Postman Collection v2.1 JSON file into a ready-to-run pytest test suite. One command.

๐ Read the article on Dev.to
postman2pytest --collection my_api.json --out tests/test_api.py
BASE_URL=https://api.example.com pytest tests/test_api.py -v
Why
Postman collections document your API. postman2pytest turns that documentation into executable regression tests that run in CI. No manual rewriting, no drift.
Install
pip install postman2pytest
Or from source:
git clone https://github.com/golikovichev/postman2pytest
cd postman2pytest
pip install -e .
Usage
postman2pytest \
--collection data/my_api.postman_collection.json \
--out generated_tests/test_api.py
Then run the generated tests:
BASE_URL=https://staging.example.com pytest generated_tests/test_api.py -v
Options
| Flag | Required | Description |
|---|---|---|
--collection | โ | Path to the input file: a Postman Collection v2.1 JSON, or an OpenAPI 3.x spec with --input-format openapi |
--out | โ | Output path for generated pytest file |
--input-format | โ | postman (default) or openapi (OpenAPI 3.x JSON or YAML) |
--base-url | โ | Tip printed after generation (does not override env var) |
--filter-folder | โ | Generate tests only for the named Postman folder |
--env | โ | Postman environment JSON export to resolve {{variables}} |
--max-input-mb | โ | Refuse to load collections larger than this many MB (default: 100) |
OpenAPI 3.x input
If your API is documented as an OpenAPI 3.x spec instead of a Postman
collection, pass --input-format openapi. JSON and YAML specs are both
accepted:
postman2pytest \
--collection openapi/my_api.yaml \
--input-format openapi \
--out generated_tests/test_api.py
The generated suite has the same shape as the Postman path. Path parameters
(/users/{id}), query parameters, and header parameters map to os.environ
lookups, and a JSON request body is generated from the operation's example or
schema. Operations are grouped by their first tag (used as the folder name), so
--filter-folder works the same way.
Notes for this first version: the base URL always comes from the BASE_URL
environment variable, so any path in the spec's servers list is ignored (put
the version prefix in BASE_URL). $ref references are not resolved, so a
request body defined purely by a $ref is generated empty.
To regenerate tests for one folder, pass its Postman folder name:
postman2pytest \
--collection data/my_api.postman_collection.json \
--out generated_tests/test_users.py \
--filter-folder Users
Resolving environment variables
Postman collections reference variables such as {{base_url}} and
{{auth_token}}. These come from two places, and both are read.
A collection carries its own variable block, which is where Postman puts a
base URL. Those values are used automatically, with no extra flag: the leading
URL variable becomes the default for BASE_URL, and the rest are inlined
where they are used. So a collection exported straight out of Postman usually
generates a suite that already points at the right host.
For anything the collection does not carry, or to point the same collection at
another environment, pass an environment export with --env:
postman2pytest \
--collection data/my_api.postman_collection.json \
--out generated_tests/test_api.py \
--env data/prod.postman_environment.json
- The environment file wins over the collection's own
variableblock on a name collision. The collection is the general case, the file is the specific one. - Non-secret variables are inlined as literal values in the generated tests.
- Variables marked
secret, in either source, and any variable neither source declares, become named pytest fixtures instead. The secret value never lands in the generated source; the fixture reads it from the environment at run time (and can be overridden in your ownconftest.py). - The base URL is never inlined into the request lines. It sets the
BASE_URLdefault, soBASE_URL=https://staging.example.com pyteststill redirects the whole suite.
Resolution covers variables in request URLs and headers. Variables inside request bodies and form fields are not resolved yet and are left as-is.
When neither source declares a variable, it stays an os.environ.get("name", "") lookup, exactly as before.
One caveat worth stating plainly: a credential kept as a plain collection
variable will be inlined, because nothing marks it as sensitive. Postman's own
secret type is honoured, so set it there, or keep credentials in an
environment file instead of the collection.
Examples
Generate tests for a single folder
The bundled data/sample_collection.json file includes a Users folder and one top-level Health check request. Generating from the whole collection creates three tests:
postman2pytest \
--collection data/sample_collection.json \
--out /tmp/test_all.py
Generated 3 test(s) -> /tmp/test_all.py
The generated file contains tests with folder-prefixed names:
def test_users_get_get_all_users():
def test_users_post_create_user():
def test_get_health_check():
To generate only the requests from the Users folder, pass --filter-folder. Folder matching is case-insensitive, so Users, users, and USERS all match the same folder:
postman2pytest \
--collection data/sample_collection.json \
--out /tmp/test_users.py \
--filter-folder Users
Generated 2 test(s) -> /tmp/test_users.py
The filtered output contains only the tests from that folder:
def test_users_get_get_all_users():
def test_users_post_create_user():
How It Works
- Parse: reads the Postman Collection JSON, flattens nested folders into a flat request list
- Extract: captures method, URL, headers, body, and expected status from
pm.response.to.have.status()test scripts - Generate: renders a Jinja2 template into a
.pyfile with onedef test_*()per request
Variable substitution
Postman variables {{base_url}} become ENV_base_url in the URL, resolved at runtime via the BASE_URL environment variable.
Generated output example
Given a Postman request GET {{base_url}}/api/v1/users with a test asserting status 200, the output is:
def test_get_users():
"""GET ENV_base_url/api/v1/users"""
url = f"{BASE_URL}/api/v1/users"
headers = {}
response = requests.get(url, headers=headers)
assert response.status_code == 200, (
f"Expected 200, got {response.status_code}: {response.text[:200]}"
)
Supported features
- โ Postman Collection v2.1 (v2.0 accepted with a warning)
- โ Nested folders โ flattened with folder prefix in test name
- โ GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS
- โ Request headers (disabled headers excluded)
- โ
Auth headers (Authorization Bearer/Basic, API-key headers) pulled into a
shared
auth_headersfixture in a generatedconftest.py; the secret is replaced with an environment-variable placeholder (AUTH_TOKEN,X_API_KEY, ...) - โ Raw JSON body
- โ
Expected status from
pm.response.to.have.status(N)test scripts - โ Falls back to 200 when no status assertion found
- โ
Test-script assertions translated to pytest
assert: response time (responseTime ... to.be.below(N)), header presence (to.have.header("X")), and top-level JSON field equality (pm.expect(jsonData.field).to.eql(value), string / number / boolean) - โ Malformed items skipped with a warning. Rest of collection still generated
Limitations
Honest scope so you know what to expect before pointing the tool at a real collection.
- โ Variables a collection does not declare still need
--env. The collection's ownvariableblock is read automatically, but a value that lives only in a Postman environment (a staging host, a tenant id) passes through as anos.environlookup until you pass--env path/to/env.json. Non-secret values are then inlined as literals; secret and unknown variables stay as lookups. - โ Pre-request scripts are skipped. Auth that depends on
pm.sendRequestto grab a token before each call (e.g. OAuth client-credentials flows refreshing per request) needs manual translation into a pytest fixture. - โ Only a subset of test-script assertions is translated. Status,
response time, header presence, and top-level JSON field equality survive the
conversion (see Supported features). Anything outside that subset (arbitrary
JS, nested-field or array-length checks, JSON schema validation, and
pm.variables.set(...)calls) is skipped rather than mistranslated, so a generated test never carries a broken assert. - โ Multipart file uploads render as
files=, but the file must exist at test time. Aformdatafile field becomesfiles={"document": open(os.environ.get("DOCUMENT_FILE", "report.pdf"), "rb")}. Only the basename from the Postmansrcis kept (the author's local path never lands in the generated code); it is the default for a<KEY>_FILEenv var you point at a real file. Text and file fields in the same body send bothdata=andfiles=. The opened handle is not explicitly closed, which is fine for a short-lived smoke test. - โ Form bodies render as
data=(urlencoded). Repeated form keys are now preserved: the field renders as a list of(key, value)pairs so requests sends every value. A hand-setmultipart/form-dataContent-Type header still will not match the urlencoded body, so adjust by hand if your endpoint needs true multipart. - โ
auth_headersis a union across the collection. Every detected auth header goes into one shared fixture, so a request that used a single scheme still receives all of them. Split the fixture by hand if your endpoints use conflicting auth. The generatedconftest.pyis overwritten on each run and is not merged with an existing one. - โ Cookies, certificates, and per-request proxy settings are ignored.
- โ Variable substitution is shallow. Path variables (
/users/:id) become{id}placeholders. A variable whose value is itself written in terms of other variables is inlined as written, not resolved recursively. - โ
BASE_URLfalls back tohttp://localhost:8080. When the collection names no base URL of its own, bare path items hit localhost until you set the env var. Items carrying a full URL resolve either way.
If a missing feature is blocking you, please open an issue with a redacted slice of the collection that demonstrates it.
Roadmap
Short list of what is next, roughly in priority order. Tracked in detail on the issues board.
- Multipart file upload support: done.
formdatatext fields render asdata={...}(OAuth-token-endpoint cases work) and file fields render asfiles={...}with a<KEY>_FILEenv placeholder (see Limitations). - Auth-header fixtures
(#2): done. Auth
headers now extract into a shared
auth_headersfixture (see Supported features). - Two-way sync: generate an updated Postman collection back from a pytest suite, so the collection and the tests can both stay current instead of the conversion being a one-time export. This is the gap a one-directional converter leaves open.
- Drop-in CI action: a GitHub Action that runs the conversion and then the
generated suite from one workflow file, so a
postman_collection.jsonin a repo becomes a running pytest job without local setup. - Pre-request script translation, scoped scope: surface the script,
even as a
pytest.fixturestub, so the operator does not lose the auth context silently. --ai-edgesmode: opt-in pass that asks an LLM to fill in edge cases (boundary numbers, missing required fields, type-confusion payloads) on top of the deterministic happy-path tests.- Allure step annotations toggle:
--allureflag that wraps each generated test inallure.step(...)blocks so the report shows the Postman folder structure.
Contributions to any of the above are welcome. See CONTRIBUTING.md for the workflow.
Running tests
pip install pytest
pytest tests/ -v
Related projects and patterns
Once postman2pytest has generated your suite, the next questions are
usually "how do I structure fixtures across all these requests" and
"how do I run them under async with shared auth state". The
tessl-labs/pytest-api-testing
skill on the Tessl Registry collects the conventions that worked for
that follow-on layer: httpx AsyncClient setup, conftest.py fixture
shape, database isolation, parametrize patterns for edge cases, and
auth-flow handling. Useful reference if your generated tests grow
beyond the request-by-request shape this tool emits.
Sister projects in the same workspace:
- secure-log2test: same idea but the input is Kibana / Elasticsearch JSON logs instead of Postman collections.
- pytest-conversational: pytest plugin for multi-turn dialogue testing.
- phoenix2pytest: same idea but the input is labeled LLM failure traces from Arize Phoenix instead of Postman collections.
Contributing
Contributions are welcome. If you are new to the project, the issues labelled good first issue and help wanted are a good place to start. See CONTRIBUTING.md for setup and the workflow.
Changelog
See CHANGELOG.md for release notes.
License
MIT. See LICENSE.