DEV Community

Mikhail Golikov
Mikhail Golikov

Posted on

Postman and pytest Are Living in Parallel Universes. Here's a Bridge.

You have a Postman collection with 40 requests. Organized into folders. With test scripts that check status codes. You spent time on this. It's good.

You also have a CI pipeline that has never heard of Postman and doesn't plan to.

These two things have coexisted peacefully for months because nobody wants to be the person who manually rewrites 40 requests as pytest functions. There's also Newman — but Newman runs tests, it doesn't generate code you can read, modify, or version properly.

So the collection documents the API. The CI tests the API. They describe the same system and have never met.

I built postman2pytest to introduce them.

One Command

pip install postman2pytest

postman2pytest \
  --collection my_api.postman_collection.json \
  --out tests/test_api.py

BASE_URL=https://staging.example.com pytest tests/test_api.py -v
Enter fullscreen mode Exit fullscreen mode

The output is plain Python. Readable, editable, committable. No framework lock-in, no runtime wrapper, no magic.

What the Output Looks Like

Given a Postman collection with a Users folder containing POST /api/v1/users with a test script asserting status 201:

def test_users_post_create_user():
    """POST ENV_base_url/api/v1/users (users)"""
    url = f"{BASE_URL}/api/v1/users"
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {os.environ.get('token', '')}",
    }
    body = json.loads('{"name": "John Doe", "email": "john@example.com"}')
    response = requests.post(url, headers=headers, json=body)
    assert response.status_code == 201, (
        f"Expected 201, got {response.status_code}: {response.text[:200]}"
    )
Enter fullscreen mode Exit fullscreen mode

A few things worth noticing:

Folder names end up in the function name. Create user inside Userstest_users_post_create_user. If you have 40 requests and three folders called List, you'll thank this later.

Postman variables become environment variables. {{base_url}}BASE_URL env var. {{token}} in an Authorization header → os.environ.get('token', '') in an f-string. The generated tests are environment-aware out of the box.

Status codes come from your existing test scripts. If you wrote pm.response.to.have.status(201) in Postman, the generated test asserts exactly 201. No test script → defaults to 200.

Disabled headers stay disabled. You toggled them off in Postman for a reason.

The Architecture

Two stages, cleanly separated.

Parse (core/parser.py) — reads the Postman JSON and produces a flat list of ParsedRequest objects, validated with Pydantic v2. Nested folders are flattened recursively. Malformed items are skipped with a warning; the rest of the collection still generates.

class ParsedRequest(BaseModel):
    name: str
    method: str
    url: str
    headers: dict[str, str]
    body: str | None
    expected_status: int
    folder: str | None
Enter fullscreen mode Exit fullscreen mode

Generate (core/generator.py) — takes the flat list and renders a Jinja2 template. The tricky part is variable substitution: {{base_url}}/api/v1/users needs to become f"{BASE_URL}/api/v1/users" in Python, and Bearer {{token}} in a header needs to become f"Bearer {os.environ.get('token', '')}". Two custom Jinja2 filters handle this: strip_base_url for URLs, render_header_value for header values.

The split is deliberate — you can use the parser independently to generate a different output format. The template is the only thing that knows what pytest looks like.

What It Doesn't Do (Yet)

  • Postman environments (the .postman_environment.json file)
  • OAuth 2.0 flows
  • Pre-request scripts
  • Response body assertions

These are all solvable. v1.0 is small enough to be trustworthy. I'd rather you use it and tell me what's missing than promise features I haven't built.

36 Tests, Because Eating Your Own Dogfood Matters

pip install postman2pytest pytest
pytest tests/ -v  # 36 passed
Enter fullscreen mode Exit fullscreen mode

CI runs on Python 3.10, 3.11, and 3.12 via GitHub Actions.

Why Not Just Use Newman?

Newman runs your Postman tests. That's useful. But it doesn't generate code — it generates a report. When the test fails in CI, Newman tells you it failed. pytest tells you it failed, shows you the diff, lets you add fixtures, parametrize the case, integrate with your existing test infrastructure.

If your team runs pytest for unit tests, integration tests, and contract tests, having your API smoke tests in the same runner means one command, one report, one CI step.


GitHub: github.com/golikovichev/postman2pytest
PyPI: pypi.org/project/postman2pytest

If you hit a collection format this doesn't handle, open an issue.

Top comments (0)