<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Mikhail Golikov</title>
    <description>The latest articles on Forem by Mikhail Golikov (@golikovichev).</description>
    <link>https://forem.com/golikovichev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3896140%2Fe7fa3715-e9c3-49e7-89c6-db9da942a6a5.jpg</url>
      <title>Forem: Mikhail Golikov</title>
      <link>https://forem.com/golikovichev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/golikovichev"/>
    <language>en</language>
    <item>
      <title>Postman and pytest Are Living in Parallel Universes. Here's a Bridge.</title>
      <dc:creator>Mikhail Golikov</dc:creator>
      <pubDate>Fri, 24 Apr 2026 14:13:35 +0000</pubDate>
      <link>https://forem.com/golikovichev/postman-and-pytest-are-living-in-parallel-universes-heres-a-bridge-5bgn</link>
      <guid>https://forem.com/golikovichev/postman-and-pytest-are-living-in-parallel-universes-heres-a-bridge-5bgn</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;You also have a CI pipeline that has never heard of Postman and doesn't plan to.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So the collection documents the API. The CI tests the API. They describe the same system and have never met.&lt;/p&gt;

&lt;p&gt;I built &lt;strong&gt;postman2pytest&lt;/strong&gt; to introduce them.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Command
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;postman2pytest

postman2pytest &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--collection&lt;/span&gt; my_api.postman_collection.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--out&lt;/span&gt; tests/test_api.py

&lt;span class="nv"&gt;BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://staging.example.com pytest tests/test_api.py &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output is plain Python. Readable, editable, committable. No framework lock-in, no runtime wrapper, no magic.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Output Looks Like
&lt;/h2&gt;

&lt;p&gt;Given a Postman collection with a &lt;code&gt;Users&lt;/code&gt; folder containing &lt;code&gt;POST /api/v1/users&lt;/code&gt; with a test script asserting status 201:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_users_post_create_user&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;POST ENV_base_url/api/v1/users (users)&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/api/v1/users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;John Doe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;john@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Expected 201, got &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noticing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Folder names end up in the function name.&lt;/strong&gt; &lt;code&gt;Create user&lt;/code&gt; inside &lt;code&gt;Users&lt;/code&gt; → &lt;code&gt;test_users_post_create_user&lt;/code&gt;. If you have 40 requests and three folders called &lt;code&gt;List&lt;/code&gt;, you'll thank this later.&lt;/p&gt;

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

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

&lt;p&gt;&lt;strong&gt;Disabled headers stay disabled.&lt;/strong&gt; You toggled them off in Postman for a reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;Two stages, cleanly separated.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ParsedRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;expected_status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;folder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Doesn't Do (Yet)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Postman environments (the &lt;code&gt;.postman_environment.json&lt;/code&gt; file)&lt;/li&gt;
&lt;li&gt;OAuth 2.0 flows&lt;/li&gt;
&lt;li&gt;Pre-request scripts&lt;/li&gt;
&lt;li&gt;Response body assertions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  36 Tests, Because Eating Your Own Dogfood Matters
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;postman2pytest pytest
pytest tests/ &lt;span class="nt"&gt;-v&lt;/span&gt;  &lt;span class="c"&gt;# 36 passed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CI runs on Python 3.10, 3.11, and 3.12 via GitHub Actions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Just Use Newman?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/golikovichev/postman2pytest" rel="noopener noreferrer"&gt;github.com/golikovichev/postman2pytest&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;PyPI:&lt;/strong&gt; &lt;a href="https://pypi.org/project/postman2pytest/" rel="noopener noreferrer"&gt;pypi.org/project/postman2pytest&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you hit a collection format this doesn't handle, open an issue.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>pytest</category>
      <category>testing</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
