<?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>DEV Community: Mikhail Golikov</title>
    <description>The latest articles on DEV Community by Mikhail Golikov (@golikovichev).</description>
    <link>https://dev.to/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%2F8cfce11c-18fa-487a-a071-399c4ed2dadf.jpg</url>
      <title>DEV Community: Mikhail Golikov</title>
      <link>https://dev.to/golikovichev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/golikovichev"/>
    <language>en</language>
    <item>
      <title>Your Kibana logs are full of test cases. Here is a CLI that extracts them, with auth scrubbed by default.</title>
      <dc:creator>Mikhail Golikov</dc:creator>
      <pubDate>Fri, 15 May 2026 09:45:15 +0000</pubDate>
      <link>https://dev.to/golikovichev/your-kibana-logs-are-full-of-test-cases-here-is-a-cli-that-extracts-them-with-auth-scrubbed-by-4433</link>
      <guid>https://dev.to/golikovichev/your-kibana-logs-are-full-of-test-cases-here-is-a-cli-that-extracts-them-with-auth-scrubbed-by-4433</guid>
      <description>&lt;p&gt;Every sprint we export a JSON dump from Kibana, scroll through hundreds of log entries, and tell ourselves we will turn them into test cases later.&lt;/p&gt;

&lt;p&gt;Later never comes.&lt;/p&gt;

&lt;p&gt;The logs contain real API calls. Real endpoints, real payloads, real status codes from production. It is the closest thing to a specification of how the system actually behaves. And almost none of it ever becomes an automated test, because converting it manually takes longer than the sprint.&lt;/p&gt;

&lt;p&gt;I got tired of later. I wrote &lt;strong&gt;secure-log2test&lt;/strong&gt;, a CLI that reads a Kibana JSON export and generates a ready-to-run pytest file. One command. Working tests.&lt;/p&gt;

&lt;p&gt;There is one constraint that shaped the whole design: no data leaves your machine. No LLM API calls. No cloud. Everything runs locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the privacy constraint matters
&lt;/h2&gt;

&lt;p&gt;The obvious alternative to building a tool is asking an LLM to write the tests from your logs. It would probably work. Right up until someone on the security team noticed that production logs full of PII and internal API structures were being sent to an external service.&lt;/p&gt;

&lt;p&gt;In enterprise environments that conversation ends badly. So I made it impossible: the core has no network calls at all.&lt;/p&gt;

&lt;p&gt;But the bigger privacy story is what happens to secrets inside the log entries themselves. Production logs leak &lt;code&gt;Authorization: Bearer ...&lt;/code&gt; headers all the time. They leak &lt;code&gt;Cookie&lt;/code&gt; values, &lt;code&gt;X-API-Key&lt;/code&gt; values, and increasingly they leak request bodies that contain &lt;code&gt;password&lt;/code&gt;, &lt;code&gt;refresh_token&lt;/code&gt;, &lt;code&gt;client_secret&lt;/code&gt;, or whatever the team called their auth field this week. If the generated tests carry those values, the regression suite becomes a credential dump on disk, ready to be accidentally committed.&lt;/p&gt;

&lt;p&gt;secure-log2test scrubs three layers before the test file is written:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A static list of well-known auth headers: &lt;code&gt;authorization&lt;/code&gt;, &lt;code&gt;proxy-authorization&lt;/code&gt;, &lt;code&gt;proxy-authenticate&lt;/code&gt;, &lt;code&gt;cookie&lt;/code&gt;, &lt;code&gt;set-cookie&lt;/code&gt;, &lt;code&gt;x-api-key&lt;/code&gt;, &lt;code&gt;x-auth-token&lt;/code&gt;, &lt;code&gt;x-csrf-token&lt;/code&gt;, &lt;code&gt;x-access-token&lt;/code&gt;, &lt;code&gt;refresh-token&lt;/code&gt;, &lt;code&gt;id-token&lt;/code&gt;, &lt;code&gt;x-amz-security-token&lt;/code&gt;, &lt;code&gt;authentication&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A regex pattern (&lt;code&gt;auth|token|secret|key|session|cookie|credential|bearer|password|passwd&lt;/code&gt;) that catches custom header names project teams invent.&lt;/li&gt;
&lt;li&gt;The same logic walks JSON request bodies recursively. So &lt;code&gt;{"password": "..."}&lt;/code&gt;, &lt;code&gt;{"client_secret": "..."}&lt;/code&gt;, OAuth &lt;code&gt;{"refresh_token": "..."}&lt;/code&gt; all get replaced with &lt;code&gt;***REDACTED***&lt;/code&gt; at parse time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The marker is a placeholder. You inject real credentials at run time through environment variables or fixtures.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&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;secure-log2test

secure-log2test data/sample_kibana_export.json &lt;span class="nt"&gt;--output&lt;/span&gt; tests_generated.py
pytest tests_generated.py &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A sample export ships with the repo (&lt;code&gt;data/sample_kibana_export.json&lt;/code&gt;), so you can see real output without setting up a Kibana instance first.&lt;/p&gt;

&lt;p&gt;Input is the Kibana JSON &lt;code&gt;hits.hits[*]._source&lt;/code&gt; format you get from any saved search:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"hits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"_source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/v1/orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer abc.xyz"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Content-Type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"item_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"password"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hunter2"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output is a pytest file with one test function per log entry:&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_post_api_v1_orders_1&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;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;BASE_URL&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/v1/orders&lt;/span&gt;&lt;span class="sh"&gt;"&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="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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;***REDACTED***&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;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="n"&gt;json&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;item_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;password&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;***REDACTED***&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both the &lt;code&gt;Authorization&lt;/code&gt; header value and the &lt;code&gt;password&lt;/code&gt; field inside the body are redacted. The original log dict is not mutated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture: two stages
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Parse&lt;/strong&gt; (&lt;code&gt;secure_log2test/core/parser.py&lt;/code&gt;). Pydantic v2 validates and normalises each entry. Records with missing fields fall back to safe defaults rather than crashing. Malformed entries are dropped with a warning, not silently swallowed. Redaction runs as a Pydantic &lt;code&gt;field_validator&lt;/code&gt; so it cannot be skipped accidentally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generate&lt;/strong&gt; (&lt;code&gt;secure_log2test/core/generator.py&lt;/code&gt;). The validated list goes through a Jinja2 template (&lt;code&gt;templates/test_module.py.j2&lt;/code&gt;) and lands as a &lt;code&gt;.py&lt;/code&gt; file. The template is the only place that knows what pytest looks like. Want a different output (httpx instead of requests, unittest instead of pytest, k6 scenarios)? You replace the template. The parser stays untouched.&lt;/p&gt;

&lt;p&gt;This split is the part I am most happy with. The redaction logic is unit-testable in isolation. The output format is a config file. Anyone can fork the template and emit something else from the same parsed entries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The user-feedback loop that improved v1.0.1
&lt;/h2&gt;

&lt;p&gt;The first PyPI release shipped at v1.0.0 on May 11th. Within hours an external user fed it a Grafana Loki export with Cyrillic content from a Russian backend. The parser opened the file without an explicit encoding argument. On Linux this works (utf-8 by default). On Windows the same call raises &lt;code&gt;UnicodeDecodeError&lt;/code&gt; because Windows defaults to cp1252.&lt;/p&gt;

&lt;p&gt;Bug filed, reproduced, fixed within a day. v1.0.1 went out on the 13th with explicit &lt;code&gt;encoding="utf-8-sig"&lt;/code&gt; on the file open. I added a regression test that simulates the cp1252 environment so the same bug cannot come back.&lt;/p&gt;

&lt;p&gt;What I learned: every framework that handles user-provided input needs an adversarial encoding test. The happy path is easy. The bug lived in the gap between "what my dev machine does by default" and "what a Windows shell does by default."&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tests cover
&lt;/h2&gt;

&lt;p&gt;59 tests as of v1.1.0, across parser and generator:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Valid input, malformed records, missing fields, empty exports.&lt;/li&gt;
&lt;li&gt;Header redaction with the static list. Header redaction by regex pattern. Custom team-specific headers like &lt;code&gt;X-Custom-Token&lt;/code&gt; caught by pattern.&lt;/li&gt;
&lt;li&gt;Body redaction walker: password fields, OAuth refresh tokens, nested dicts, lists of dicts, non-dict pass-through.&lt;/li&gt;
&lt;li&gt;Float duration coercion (Kibana sometimes outputs &lt;code&gt;134.0&lt;/code&gt; instead of &lt;code&gt;134&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Template rendering, payload serialisation, test naming.&lt;/li&gt;
&lt;li&gt;CLI argument handling, file output path creation.&lt;/li&gt;
&lt;li&gt;A CI smoke test that runs the CLI end-to-end on the sample export and parses the generated Python with &lt;code&gt;ast.parse&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Kibana / Elasticsearch JSON export shape only. Grafana Loki Explore exports are tracked in issue #4.&lt;/li&gt;
&lt;li&gt;Single-file input. Multi-file batch mode is on the roadmap.&lt;/li&gt;
&lt;li&gt;Output format: pytest only. JSON / CSV for downstream pipelines are tracked in issue #5.&lt;/li&gt;
&lt;li&gt;Loads the full file into memory. Not designed for multi-GB exports.&lt;/li&gt;
&lt;li&gt;Does not infer sequences or dependencies between requests.&lt;/li&gt;
&lt;li&gt;Does not replace manual test design. It accelerates the first pass.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The generated tests are a starting point. You review them, set the base URL via environment variable, add setup or teardown where needed. But you start from working, runnable code rather than a blank file and a pile of log entries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it goes next
&lt;/h2&gt;

&lt;p&gt;v1.1 will add response body assertions and optional schema match (issue #1). v1.2 will allow custom redaction rules via config file (issue #2). Two &lt;code&gt;good first issue&lt;/code&gt; slots are open right now if you want to grab one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get it
&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;secure-log2test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repo (MIT licence): &lt;strong&gt;&lt;a href="https://github.com/golikovichev/secure-log2test" rel="noopener noreferrer"&gt;github.com/golikovichev/secure-log2test&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your Kibana export shape is different from what the parser expects, open an issue with a redacted sample. The parser is the easy part to extend.&lt;/p&gt;

</description>
      <category>python</category>
      <category>testing</category>
      <category>qa</category>
      <category>opensource</category>
    </item>
    <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://dev.to/golikovichev/postman-and-pytest-are-living-in-parallel-universes-heres-a-bridge-5bgn</link>
      <guid>https://dev.to/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 you can read and edit and commit to version control. No framework lock-in. No runtime wrapper. Just generated Python code.&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; becomes &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; becomes the &lt;code&gt;BASE_URL&lt;/code&gt; env var. &lt;code&gt;{{token}}&lt;/code&gt; in an Authorization header becomes &lt;code&gt;os.environ.get('token', '')&lt;/code&gt; in an f-string. The generated tests are environment-aware by default.&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 a single command, one report, integrated into the existing 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>
