<?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.us-east-2.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>Green unit tests are a comfort blanket</title>
      <dc:creator>Mikhail Golikov</dc:creator>
      <pubDate>Sat, 20 Jun 2026 11:31:59 +0000</pubDate>
      <link>https://dev.to/golikovichev/green-unit-tests-are-a-comfort-blanket-40ag</link>
      <guid>https://dev.to/golikovichev/green-unit-tests-are-a-comfort-blanket-40ag</guid>
      <description>&lt;p&gt;My converter has a test suite. It was green. Every example I could think of went in, the right pytest file came out, and I shipped it to PyPI as 1.0. Weeks later it sits there marked Production/Stable.&lt;/p&gt;

&lt;p&gt;Then I pointed a fuzzer at it, and it stopped being so quiet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The blind spot in example tests
&lt;/h2&gt;

&lt;p&gt;Unit tests check the inputs you thought of. That is their whole nature: you sit down, you imagine how the code gets used, you write an example for each case. The cases you imagine tend to be the cases the code already handles, because you wrote the code and the test in the same afternoon, with the same blind spots.&lt;/p&gt;

&lt;p&gt;A converter makes that worse. postman2pytest takes a Postman collection, a JSON file, and turns it into a runnable pytest suite. The input is not something I control. It is a file a stranger exports from a tool, edits by hand, truncates over a flaky download, or generates from a script that had a bug. The real test suite for a parser is the set of files you did not write.&lt;/p&gt;

&lt;p&gt;So I stopped writing examples and let a machine write them for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Letting Hypothesis do the imagining
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://hypothesis.readthedocs.io/" rel="noopener noreferrer"&gt;Hypothesis&lt;/a&gt; is property-based testing for Python. Instead of one example, you describe the shape of the input and assert a property that must hold for all of them. It then generates hundreds of cases, including the mean ones you would never type out.&lt;/p&gt;

&lt;p&gt;For a parser the property is simple to state. Feed it any well-formed JSON, whatever its shape. It should do exactly one of two things: return a list of parsed tests, or raise a clear error that tells the user what is wrong. It must never fall over with a raw stack trace.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;hypothesis&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;given&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strategies&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;

&lt;span class="n"&gt;json_values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recursive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;none&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;booleans&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;integers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;children&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dictionaries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;children&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@given&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json_values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_parser_is_graceful_on_any_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tmp_path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tmp_path&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write_text&lt;/span&gt;&lt;span class="p"&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;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;  &lt;span class="c1"&gt;# the one allowed failure: a clear, typed error
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I ran it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it found
&lt;/h2&gt;

&lt;p&gt;The parser handled every real Postman collection the same as before. The fuzzer never touched valid input. It broke my handling of garbage, in five distinct shapes.&lt;/p&gt;

&lt;p&gt;A collection whose top level was a list instead of an object. A collection whose &lt;code&gt;info&lt;/code&gt; block was a string. An item that was a bare number where the code expected a dict. A folder nested deep enough that the recursion bottomed out. And a payload so deeply nested that &lt;code&gt;json.loads&lt;/code&gt; itself blew the stack before my code ran at all.&lt;/p&gt;

&lt;p&gt;In each case the user would have seen an &lt;code&gt;AttributeError&lt;/code&gt;, a &lt;code&gt;TypeError&lt;/code&gt;, or a &lt;code&gt;RecursionError&lt;/code&gt;, with an internal stack trace and no idea what to fix. None of those is a useful message. The tool knew the input was wrong. It just had no manners about saying so.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is a contract, not a patch
&lt;/h2&gt;

&lt;p&gt;Plugging the five holes was the boring part. The contract underneath them was the real work: valid collection in, pytest suite out; anything else, one clear &lt;code&gt;ValueError&lt;/code&gt; that names the problem. Degrade, do not crash.&lt;/p&gt;

&lt;p&gt;That turned into a few &lt;code&gt;isinstance&lt;/code&gt; checks at the boundaries, a depth limit on folder recursion, and wrapping the top-level &lt;code&gt;json.loads&lt;/code&gt; so a stack-busting document becomes a readable "this file is nested too deeply to parse" instead of a traceback. The same review on my other parser, secure-log2test, which reads Kibana log exports, turned up the same family of gaps in the same afternoon. Same shape of bug, same shape of fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually learned
&lt;/h2&gt;

&lt;p&gt;"Production/Stable" is a statement about the API, not about the input. It never promised to survive a corrupt file, and I had let people assume it did.&lt;/p&gt;

&lt;p&gt;And finding bugs with a fuzzer is the fuzzer doing its job. Point Hypothesis at a parser and it finds nothing? You probably did not let it generate anything nasty enough. The bugs are the evidence the method works.&lt;/p&gt;

&lt;p&gt;If your tool reads a file someone else made, your unit tests are a comfort blanket. Spend twenty lines on a property test before you trust them. The input you did not write is the one that finds you in production.&lt;/p&gt;




&lt;p&gt;postman2pytest and secure-log2test are MIT-licensed on PyPI. The fuzz tests are in each repo if you want a template to copy.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>pytes</category>
      <category>hypothesis</category>
      <category>python</category>
    </item>
    <item>
      <title>Keep the LLM out of your chatbot tests</title>
      <dc:creator>Mikhail Golikov</dc:creator>
      <pubDate>Thu, 04 Jun 2026 22:10:03 +0000</pubDate>
      <link>https://dev.to/golikovichev/keep-the-llm-out-of-your-chatbot-tests-236a</link>
      <guid>https://dev.to/golikovichev/keep-the-llm-out-of-your-chatbot-tests-236a</guid>
      <description>&lt;p&gt;It is a warm evening. You are home from work, you crack the window open, and the room fills with that low gold light you get for about twenty minutes a day. You have changed into the soft clothes. There is a cold drink sweating in your hand. You drop onto the sofa, pick up the remote, and hit play on the show you have been saving all week.&lt;/p&gt;

&lt;p&gt;It does not play. Your account "needs attention." And the only thing now standing between you and that perfect evening is a long, heartfelt conversation with a support bot.&lt;/p&gt;

&lt;p&gt;So that bot had better not lose the plot on turn three. Somebody has to build it, and somebody has to test it, and the moment you test a multi-turn conversation you hit a fork in the road.&lt;/p&gt;

&lt;h2&gt;
  
  
  TLDR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An LLM that grades your chatbot's answers is a second non-deterministic system. Now two of them have to agree before CI goes green, and they will not always agree.&lt;/li&gt;
&lt;li&gt;Most of what you actually want to assert about a conversation is boring and checkable: did the bot ask for the city after it got the name, did it keep the slot, did it return the right reply on turn three.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pytest-conversational&lt;/code&gt; gives you a small &lt;code&gt;Conversation&lt;/code&gt; object, a bot adapter you write, and pytest fixtures that keep turn order and per-turn state. No model on the test side.&lt;/li&gt;
&lt;li&gt;It is alpha. Single-turn and multi-turn assertions work today; a YAML scenario format and async adapters are on the roadmap, not in the box yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two ways people test chatbots, and why both hurt
&lt;/h2&gt;

&lt;p&gt;When you sit down to test a conversational interface, you usually land in one of two camps.&lt;/p&gt;

&lt;p&gt;The first camp is a pile of &lt;code&gt;requests.post&lt;/code&gt; calls. You fire a message at the bot, read the JSON, and write an assertion against it. This works for one turn. By turn four you are hand-rolling the history payload, threading state through local variables, and copy-pasting the same setup into every test. When something breaks, the failure message tells you a dict did not equal another dict, and you go spelunking to find out which turn went wrong.&lt;/p&gt;

&lt;p&gt;The second camp is a full conversational-testing framework that owns your whole setup. It handles multi-turn flows, but it pins you to one platform and one way of describing a bot. The day your bot moves behind a different transport, or you want to test a plain Python function instead of an HTTP service, you are fighting the framework.&lt;/p&gt;

&lt;p&gt;There is a third option that keeps coming up, and it is the one I want to push back on: have a language model read the bot's reply and decide whether it was good. It looks appealing because it handles paraphrase. The bot said "sure thing, what city?" instead of "got it, what city?" and the grader shrugs and passes it.&lt;/p&gt;

&lt;p&gt;The problem is what you just built. Your test suite now contains two non-deterministic systems, and CI only goes green when both of them agree. The model under test drifts when you change a prompt. The grader model drifts when the provider ships a new version. A test that passed on Monday fails on Thursday and nothing in your code changed. You also pay latency and tokens on every run, and you have given up the one thing a test should give you: a clear, repeatable yes or no.&lt;/p&gt;

&lt;p&gt;A colleague who leads engineering on a chatbot platform put it well when we were going back and forth on this online. Keep the test side deterministic. Let the bot be as clever as it likes; the thing checking the bot should be dumb and predictable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pivot: make the test side boring on purpose
&lt;/h2&gt;

&lt;p&gt;Here is the shift. Most of what you care about in a conversation is not "was the answer phrased nicely." It is structural:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;After the user gives a name, does the bot ask for the next slot?&lt;/li&gt;
&lt;li&gt;Does the bot remember the name three turns later?&lt;/li&gt;
&lt;li&gt;On an out-of-scope message, does it fall back instead of inventing an answer?&lt;/li&gt;
&lt;li&gt;Does the reply contain the order number, match a known pattern, or land in a known set of options?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of that is checkable without a model. You need something that keeps turn order, holds per-conversation state, and prints a readable transcript when an assertion fails. That is the whole job &lt;code&gt;pytest-conversational&lt;/code&gt; is trying to do, and nothing more.&lt;/p&gt;

&lt;p&gt;You bring the bot. The plugin wires it into pytest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&gt;

&lt;p&gt;Install it:&lt;br&gt;
&lt;/p&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;pytest-conversational
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python 3.10 and up.&lt;/p&gt;

&lt;p&gt;A bot is any callable that takes the user text and the conversation, and returns a string. The simplest test:&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;my_bot&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="n"&gt;convo&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hello&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sorry, did not get that&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_greeting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conversation_factory&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;convo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;conversation_factory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;my_bot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;convo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;say&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hello there&lt;/span&gt;&lt;span class="sh"&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;convo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;conversation_factory&lt;/code&gt; is a fixture. You hand it a bot, you get a fresh &lt;code&gt;Conversation&lt;/code&gt; back, isolated from every other test.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-turn state, without globals
&lt;/h3&gt;

&lt;p&gt;This is where the single-turn camp suffers. A bot adapter can read &lt;code&gt;convo.state&lt;/code&gt; and &lt;code&gt;convo.turns&lt;/code&gt;, so slot filling stays inside the conversation instead of leaking into your test body:&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;slot_filling_bot&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="n"&gt;convo&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;slots&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;convo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slots&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="k"&gt;if&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="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;slots&lt;/span&gt;&lt;span class="p"&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="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;got it, what city?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;city&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;city&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
        &lt;span class="k"&gt;return&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;hello &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slots&lt;/span&gt;&lt;span class="p"&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="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; from &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;city&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="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_two_slot_flow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conversation_factory&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;convo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;conversation_factory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;slot_filling_bot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;convo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;say&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mikhail&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;convo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;say&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hove&lt;/span&gt;&lt;span class="sh"&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;convo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slots&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&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;name&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;Mikhail&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;city&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;Hove&lt;/span&gt;&lt;span class="sh"&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;convo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hello Mikhail from Hove&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test reads top to bottom like the conversation it describes. When it fails, you call &lt;code&gt;convo.transcript()&lt;/code&gt; and see every turn, not a diff of two dictionaries.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzlw5b7rktx71k9f64pe4.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzlw5b7rktx71k9f64pe4.gif" alt="multi-turn failure transcript" width="800" height="517"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A multi-turn test catching a bot that drops the name on the final turn. The failure message shows exactly what the bot said versus what the test expected.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Matchers for the fuzzy bits
&lt;/h3&gt;

&lt;p&gt;Exact string equality is fine until it is not. Some replies legitimately vary, and you want to assert a shape rather than a literal. The &lt;code&gt;expect&lt;/code&gt; module covers the common cases and puts the actual reply in the failure message, so pytest output shows what the bot said versus what you wanted:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pytest_conversational&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;expect&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_replies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conversation_factory&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;convo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;conversation_factory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;my_bot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;convo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;say&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;convo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hello&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;convo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^hello\s+\w+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;one_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;convo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&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;hello there&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;hi there&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;hey&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;&lt;code&gt;contains&lt;/code&gt; is a case-insensitive substring check by default. &lt;code&gt;regex&lt;/code&gt; runs &lt;code&gt;re.search&lt;/code&gt; and hands back the match object so you can inspect captured groups. &lt;code&gt;one_of&lt;/code&gt; checks the reply against a list of alternatives, with an exact mode and a substring mode. None of this needs a model to decide anything. The rules are yours and they do not drift.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing a bot that lives behind HTTP
&lt;/h3&gt;

&lt;p&gt;If your bot is a deployed service, you do not have to write a transport by hand. There is a bundled webhook adapter:&lt;br&gt;
&lt;/p&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;pytest-conversational[http]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pytest_conversational&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Conversation&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pytest_conversational.adapters&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;http_webhook&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_remote_bot&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;bot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;http_webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://my-bot.example.com/webhook&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="mf"&gt;3.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;convo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Conversation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;convo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;say&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hello&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;convo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default contract is a POST of &lt;code&gt;{"user": text, "history": [[u, b], ...]}&lt;/code&gt; and a &lt;code&gt;200&lt;/code&gt; with &lt;code&gt;{"reply": "..."}&lt;/code&gt;. If your endpoint speaks a different shape, pass &lt;code&gt;request_builder&lt;/code&gt; and &lt;code&gt;response_parser&lt;/code&gt; callbacks.&lt;/p&gt;

&lt;p&gt;One thing worth slowing down on. The webhook URL goes straight to &lt;code&gt;httpx&lt;/code&gt; as written. If a test ever feeds it a URL that came from fixture data, a config file, or anything you did not type yourself, the adapter will dutifully hit it. That includes &lt;code&gt;127.0.0.1&lt;/code&gt;, the cloud metadata address &lt;code&gt;169.254.169.254&lt;/code&gt;, and anything on &lt;code&gt;10.x.x.x&lt;/code&gt; inside your network. A test reaching the metadata endpoint is not a hypothetical; it is how a fair number of SSRF incidents start. Pin the URL to a literal in the test, or run it through your own allowlist before it gets near the adapter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits, and where it does not
&lt;/h2&gt;

&lt;p&gt;Determinism on the test side is the right call for structure, state, routing, and fallbacks. That is the bulk of what breaks in a conversational product, and it is exactly the part a model-as-judge handles worst.&lt;/p&gt;

&lt;p&gt;It is not the right tool for judging open-ended generation quality. If your actual question is "is this a good, helpful, on-brand paragraph," a rule cannot answer that, and you should not pretend it can. Use human review or an evaluation harness for that, and keep it out of the gate that decides whether your build is broken. The two jobs are different. Conflating them is how you end up with a flaky CI that nobody trusts.&lt;/p&gt;

&lt;p&gt;Honest status: the plugin is alpha, with a v1.0 release targeted for June 2026. Single-turn and multi-turn assertions, the matchers, and the HTTP adapter all work now. A scenario format you can load from YAML or plain-text fixtures is on the roadmap, and so is async adapter support for coroutine-based bots. Those are not shipped, and I would rather say so than have you &lt;code&gt;pip install&lt;/code&gt; and go looking for them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try 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;pytest-conversational
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point it at a bot you already have, write one multi-turn test, and see whether the failure transcript tells you more than your current setup does. The code, issues, and roadmap are on GitHub: &lt;a href="https://github.com/golikovichev/pytest-conversational" rel="noopener noreferrer"&gt;https://github.com/golikovichev/pytest-conversational&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you do try it, the assertions you wish existed are the most useful issues to open. That is what shapes the scenario format before v1.0.&lt;/p&gt;




</description>
    </item>
    <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>
