<?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: Kabir Hossain</title>
    <description>The latest articles on DEV Community by Kabir Hossain (@praman).</description>
    <link>https://dev.to/praman</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%2F1391841%2F20a0eebf-df9a-4369-ba79-a21f87d70b31.png</url>
      <title>DEV Community: Kabir Hossain</title>
      <link>https://dev.to/praman</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/praman"/>
    <language>en</language>
    <item>
      <title>How to validate JSON responses without forcing heavy dependencies on your users</title>
      <dc:creator>Kabir Hossain</dc:creator>
      <pubDate>Sat, 31 Oct 2026 08:10:37 +0000</pubDate>
      <link>https://dev.to/praman/how-to-validate-json-responses-without-forcing-heavy-dependencies-on-your-users-1ie2</link>
      <guid>https://dev.to/praman/how-to-validate-json-responses-without-forcing-heavy-dependencies-on-your-users-1ie2</guid>
      <description>&lt;p&gt;When you build an API testing framework, checking status codes is the easy part. &lt;/p&gt;

&lt;p&gt;assert response.status_code == 200&lt;/p&gt;

&lt;p&gt;The hard part is validating the JSON payload. You need to check that the fields are correct, that nested data exists, and eventually, that the schema matches what the API documentation promised. &lt;/p&gt;

&lt;p&gt;I've been building Praman, an open-source QA engine in Python. I just finished the API automation layer, and the test suite is now at 663 passing tests. Here is the architectural decision I had to make when adding JSON Schema validation.&lt;/p&gt;

&lt;p&gt;If you want to validate a JSON payload against a schema in Python, the standard tool is the jsonschema library. It is excellent, it is powerful, and it supports Draft 2020-12.&lt;/p&gt;

&lt;p&gt;But here is the problem: I don't want to force every user of Praman to install it. &lt;/p&gt;

&lt;p&gt;Some teams just want to make a GET request and check a few fields. They don't need a full schema validator. If I make jsonschema a required dependency in my pyproject.toml, I am bloating their environment for a feature they might never use.&lt;/p&gt;

&lt;p&gt;So I used an optional dependency architecture.&lt;/p&gt;

&lt;p&gt;I put the schema validation logic in a dedicated module, but I didn't add jsonschema to the required dependencies. Instead, it lives in an optional extra. If a user tries to call the schema validation method without having the library installed, Praman raises a clear error message:&lt;/p&gt;

&lt;p&gt;"To use JSON Schema validation, please install the optional dependencies: pip install praman[schema]"&lt;/p&gt;

&lt;p&gt;This is how professional libraries are built. The core package stays lean, and the advanced features are opt-in. It respects the client's environment.&lt;/p&gt;

&lt;p&gt;I separated the API assertions into three levels of complexity.&lt;/p&gt;

&lt;p&gt;First, top-level field validation. If I just want to check that the response contains a user_id and a status field, I can do that without any external libraries. It's just a dictionary lookup.&lt;/p&gt;

&lt;p&gt;Second, nested path validation. If I need to check response["data"]["attributes"]["created_at"], I wrote a simple path resolver. Again, zero dependencies.&lt;/p&gt;

&lt;p&gt;Third, full JSON Schema validation. This is where the optional dependency kicks in. If the client actually needs to enforce a Draft 2020-12 schema, they install the extra.&lt;/p&gt;

&lt;p&gt;This layered approach means the framework is useful immediately for simple tests, but it scales up when the project gets serious.&lt;/p&gt;

&lt;p&gt;When you test APIs, you need to know if the request actually succeeded. &lt;/p&gt;

&lt;p&gt;The second half of the API layer was about handling authentication and headers cleanly. I added a Praman-owned ApiClient. It handles Bearer authentication, configurable headers, and the standard HTTP methods: GET, POST, PUT, PATCH, and DELETE. &lt;/p&gt;

&lt;p&gt;More importantly, it validates HTTP status codes automatically. If an API returns a 500 when you expected a 200, the framework fails the test with a clear message, rather than forcing the test author to write assert response.status_code == 200 on every single test.&lt;/p&gt;

&lt;p&gt;The test suite is now at 663 passing tests. The full build passed on a clean wheel installation, meaning a client can install Praman, run an external API test, and everything works.&lt;/p&gt;

&lt;p&gt;If you want to look at the code, the repository is public on GitHub: &lt;a href="https://github.com/pranromumu/praman" rel="noopener noreferrer"&gt;https://github.com/pranromumu/praman&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Next up is the remaining Phase 2 work: UI automation and Page Objects. The API foundation is done. The framework is growing.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>testing</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Making a Python QA framework installable: what Phase 1 actually requires</title>
      <dc:creator>Kabir Hossain</dc:creator>
      <pubDate>Sun, 25 Oct 2026 06:55:27 +0000</pubDate>
      <link>https://dev.to/praman/making-a-python-qa-framework-installable-what-phase-1-actually-requires-5gm6</link>
      <guid>https://dev.to/praman/making-a-python-qa-framework-installable-what-phase-1-actually-requires-5gm6</guid>
      <description>&lt;p&gt;When you build a testing framework, the actual test execution logic is only half the work. The other half is making it installable, configurable, and ready for someone else's repository.&lt;/p&gt;

&lt;p&gt;I just published the Phase 1 milestone for Praman, an open-source QA engine for Python. The repo is live on GitHub. Here is what it took to get the core framework packaged and running.&lt;/p&gt;

&lt;p&gt;The first challenge wasn't writing more browser-automation code. It was building the wheel.&lt;/p&gt;

&lt;p&gt;Building a source distribution and a wheel forces you to look at your code from the perspective of a client. I had to verify the package metadata, ensure that Pytest was declared correctly, and make sure the plugin auto-registration worked. Praman uses a pytest11 entry point, which means when a client installs the wheel, the praman_page fixture is available immediately. They don't have to copy any conftest.py files.&lt;/p&gt;

&lt;p&gt;The only way to trust this is to test it in isolation. I installed the wheel in a fresh Python environment and ran a smoke test against it. It passed. That single external test proves more than a hundred local tests, because it confirms the packaging actually works.&lt;/p&gt;

&lt;p&gt;The test suite is currently at 476 tests passing. One of the tests covers the configuration precedence we talked about in the last article. Another covers the pytest plugin discovery. They run in seconds, which means they can go into a CI pipeline on every commit without slowing things down. The GitHub Actions workflow is already set up and passing.&lt;/p&gt;

&lt;p&gt;A QA framework is only as good as its documentation.&lt;/p&gt;

&lt;p&gt;I spent time writing a CLIENT_SETUP.md guide and a full README. The README needed to explain the exact difference between the standard configuration pipeline (YAML first, then environment variables, then CLI arguments) and the pytest fixture configuration (which uses two specific environment variables to load the YAML file). Those are two separate concerns, and if the documentation blurs them, the client will guess wrong.&lt;/p&gt;

&lt;p&gt;I also added the MIT license and cleaned up the repository structure. The framework folder holds the core code, the tests folder holds the verification suite, and the pyproject.toml declares the build dependencies.&lt;/p&gt;

&lt;p&gt;This is not a finished platform. It is a foundation. The roadmap includes API and UI automation, CI/CD integrations, and AI-assisted test analysis. But none of those matter if the base package doesn't install cleanly. &lt;/p&gt;

&lt;p&gt;If you want to look at the code or try the wheel, the repository is public. The feedback loop on packaging is what makes the next phase possible.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>testing</category>
      <category>python</category>
      <category>programming</category>
    </item>
    <item>
      <title>Config precedence in Python: why your resolver shouldn't know what "CLI" means</title>
      <dc:creator>Kabir Hossain</dc:creator>
      <pubDate>Wed, 14 Oct 2026 03:43:48 +0000</pubDate>
      <link>https://dev.to/praman/config-precedence-in-python-why-your-resolver-shouldnt-know-what-cli-means-1aci</link>
      <guid>https://dev.to/praman/config-precedence-in-python-why-your-resolver-shouldnt-know-what-cli-means-1aci</guid>
      <description>&lt;p&gt;Most config systems I've written have the same shape. A function that looks at the environment, looks at the command line, looks at a YAML file, and decides which one wins. Something like this:&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;load_config&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cli_args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cli_args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TIMEOUT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TIMEOUT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;yaml_config&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;timeout&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multiply that by every setting and you get a function that's 200 lines long, impossible to test in isolation, and impossible to change precedence on without editing every branch.&lt;/p&gt;

&lt;p&gt;The version I've been working on separates two things the if/else version fuses together. What order sources apply in. And how they merge. Once those are separate, precedence stops being code and starts being structure.&lt;/p&gt;

&lt;p&gt;The resolver doesn't know what "CLI" means. Here's the merge function in full:&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;resolve_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Iterable&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;SourceSpec&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;merged_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sources&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;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_config&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;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;failure_policy&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;FailurePolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REQUIRED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt;
            &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Optional configuration source %s failed: %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;__class__&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="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="n"&gt;merged_config&lt;/span&gt; &lt;span class="o"&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;merged_config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;merged_config&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that and notice what it doesn't do. It doesn't check whether a source is a CLI source, an env source, or a YAML source. It doesn't sort anything. It doesn't decide that one kind of source should win over another kind. It takes a list of sources, merges them left to right, and returns the result.&lt;/p&gt;

&lt;p&gt;The last source in the list wins. That's the entire precedence rule.&lt;/p&gt;

&lt;p&gt;That's not a limitation. It's the design. The moment the resolver knows what "CLI" means, it can start making decisions it shouldn't. It can start reordering. It can start special-casing. And once it does that, you can't test precedence without also testing the resolver's opinions, and you can't change precedence without reading the resolver's code.&lt;/p&gt;

&lt;p&gt;Precedence is a list. That list comes from a builder:&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;build_standard_pipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&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;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cli_args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;SourceSpec&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;yaml_source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;YAMLSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;env_source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;EnvSource&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;cli_source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CLISource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cli_args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;SourceSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;yaml_source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failure_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FailurePolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REQUIRED&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;SourceSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;env_source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failure_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FailurePolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OPTIONAL&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;SourceSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cli_source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failure_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FailurePolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OPTIONAL&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;YAML first. Env second. CLI last. Since the resolver merges left-to-right and later sources override earlier ones, CLI wins over env wins over YAML. That's the whole precedence system, and it's expressed as three lines of a list.&lt;/p&gt;

&lt;p&gt;If you want to change precedence, say you decide env should beat CLI, you change the order of that list. Nothing else changes. No if branches to hunt down. No settings that read precedence from a config file. Just the order of the list.&lt;/p&gt;

&lt;p&gt;The tests are where the pattern earns its keep, because they lock in behaviors, not implementation details.&lt;/p&gt;

&lt;p&gt;First test. Later source wins.&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_1_later_source_wins&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;source_a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FakeConfigSource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;browser&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;chromium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;source_b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FakeConfigSource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;browser&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;firefox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;source_c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FakeConfigSource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;browser&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;webkit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;specs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;SourceSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;source_a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failure_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FailurePolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REQUIRED&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;SourceSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;source_b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failure_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FailurePolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REQUIRED&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;SourceSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;source_c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failure_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FailurePolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REQUIRED&lt;/span&gt;&lt;span class="p"&gt;)&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;resolve_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;specs&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;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;browser&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;webkit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the base contract. Merge is left-to-right, last write wins. It's obvious in isolation and it's the thing every other behavior depends on.&lt;/p&gt;

&lt;p&gt;Second test. Reordering changes result.&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_2_reordering_changes_result&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;source_a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FakeConfigSource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;browser&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;chromium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;source_b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FakeConfigSource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;browser&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;firefox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;source_c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FakeConfigSource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;browser&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;webkit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;specs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;SourceSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;source_c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failure_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FailurePolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REQUIRED&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;SourceSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;source_a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failure_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FailurePolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REQUIRED&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;SourceSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;source_b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failure_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FailurePolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REQUIRED&lt;/span&gt;&lt;span class="p"&gt;)&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;resolve_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;specs&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;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;browser&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;firefox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one looks trivial and isn't. It proves the resolver has no hidden opinion about which source should win. If someone ever helpfully adds logic to the resolver that reorders sources, say putting CLI sources last regardless of where they appear, this test fails. That's the category of mistake it prevents. Someone trying to make precedence smarter inside the merge.&lt;/p&gt;

&lt;p&gt;Third test. The resolver doesn't sort.&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_3_resolver_does_not_sort&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="c1"&gt;# Simulate a logically reversed order: CLI-like -&amp;gt; YAML-like -&amp;gt; ENV-like
&lt;/span&gt;    &lt;span class="c1"&gt;# The resolver should NOT know these names mean anything.
&lt;/span&gt;    &lt;span class="n"&gt;cli_like&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FakeConfigSource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;browser&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;webkit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;yaml_like&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FakeConfigSource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;browser&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;chromium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;env_like&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FakeConfigSource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;browser&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;firefox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;specs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;SourceSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cli_like&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failure_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FailurePolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REQUIRED&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;SourceSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;yaml_like&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failure_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FailurePolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REQUIRED&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;SourceSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;env_like&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failure_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FailurePolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REQUIRED&lt;/span&gt;&lt;span class="p"&gt;)&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;resolve_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;specs&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;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;browser&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;firefox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the one I'd defend hardest. It uses three sources named after the standard precedence chain, CLI, YAML, ENV, but passes them in a deliberately wrong order. The resolver's job is to merge them in the order given, not to fix it.&lt;/p&gt;

&lt;p&gt;If you ever find yourself wanting to add precedence knowledge to the resolver, this test is the one that will stop you. That's the point. Tests aren't just assertions about current behavior. They're arguments against future versions of yourself who will have worse ideas.&lt;/p&gt;

&lt;p&gt;There's a subtle thing in the resolver that's easy to miss.&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;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_config&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;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;failure_policy&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;FailurePolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REQUIRED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each source carries its own failure policy. YAML is required. If the file is missing or malformed, the pipeline fails. Env and CLI are optional. If they fail to load, the pipeline continues with what it has.&lt;/p&gt;

&lt;p&gt;This is deliberately a separate concern from precedence. A source can be last in precedence, highest priority, and optional, failing silently. A source can be first in precedence, lowest priority, and required. The two axes are independent.&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_5_failure_policy_remains_independent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;caplog&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;valid_yaml&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FakeConfigSource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;browser&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;chromium&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;timeout&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;failing_env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FailingSource&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;valid_cli&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FakeConfigSource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;browser&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;webkit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;specs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;SourceSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;valid_yaml&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failure_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FailurePolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REQUIRED&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;SourceSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;failing_env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failure_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FailurePolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OPTIONAL&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;SourceSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;valid_cli&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failure_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FailurePolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REQUIRED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;caplog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at_level&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WARNING&lt;/span&gt;&lt;span class="sh"&gt;"&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;resolve_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;specs&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;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;browser&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;webkit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timeout&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="mi"&gt;30000&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;Optional configuration source&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;caplog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Env fails. The pipeline continues. CLI still overrides YAML. Timeout from YAML still applies because nothing else set it. Precedence and failure handling do their jobs without interacting.&lt;/p&gt;

&lt;p&gt;Fusing these two concerns is the most common mistake I see in config systems. It looks natural. If the CLI arg is missing, fall back to env, which falls back to YAML. But it collapses two orthogonal decisions into one tangled function. Keeping them separate makes each one testable in isolation and changeable without touching the other.&lt;/p&gt;

&lt;p&gt;One more test, because it's the one that guards the whole design:&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_7_resolver_remains_unchanged&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inspect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolve_config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sources&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This asserts that resolve_config takes exactly one parameter. Nothing else. No pipeline name, no environment, no flags. Just the list of sources.&lt;/p&gt;

&lt;p&gt;It sounds pedantic, and it is, deliberately. Six months from now, someone (probably me) will want to pass an Environment into the resolver so it can adjust precedence based on the environment. This test makes that change fail loudly, which forces a conversation about whether the resolver should know anything about environments. The answer is almost always no. But without the test, the answer quietly becomes yes, one small change at a time.&lt;/p&gt;

&lt;p&gt;What this pattern buys you.&lt;/p&gt;

&lt;p&gt;Precedence is a list. Changing it means reordering a list, not editing logic.&lt;/p&gt;

&lt;p&gt;The resolver does one thing. It merges dictionaries left to right. That's it. It's 20 lines and it doesn't know what a CLI is.&lt;/p&gt;

&lt;p&gt;Failure handling is orthogonal. Each source declares whether it's required or optional. The resolver respects that declaration without conflating it with priority.&lt;/p&gt;

&lt;p&gt;Tests lock behaviors, not implementations. Each test asserts something category-level about what the system should never do, not just what it currently does.&lt;/p&gt;

&lt;p&gt;None of this is novel. It's the same pattern behind a dozen config libraries. But the version where precedence is structure, rather than a decision buried in a conditional, is the version I've found easiest to live with. Because the day you need to change precedence is the day you least want to be reading the resolver's code.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>programming</category>
      <category>python</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How I scrape any website in under 5 minutes (Python + Playwright)</title>
      <dc:creator>Kabir Hossain</dc:creator>
      <pubDate>Wed, 07 Oct 2026 04:48:09 +0000</pubDate>
      <link>https://dev.to/praman/how-i-scrape-any-website-in-under-5-minutes-python-playwright-25d3</link>
      <guid>https://dev.to/praman/how-i-scrape-any-website-in-under-5-minutes-python-playwright-25d3</guid>
      <description>&lt;p&gt;python, webdev, tutorial, automation&lt;br&gt;
Every scraping tutorial I've read online ends the same way.&lt;/p&gt;

&lt;p&gt;"Here's how to grab a title with BeautifulSoup." Twenty lines of Python. Works on the demo page. Fails on a real website.&lt;/p&gt;

&lt;p&gt;That bothered me for a long time. Because real websites are messy. They use JavaScript. They block bots. They hide data behind logins. They paginate. They rename their HTML classes every few months and break every script you've written.&lt;/p&gt;

&lt;p&gt;So instead of writing another "20-line scraper" tutorial, I want to show you what actual production scraping looks like. Not the theory — the workflow I use for real client jobs.&lt;/p&gt;

&lt;p&gt;What most tutorials skip&lt;br&gt;
When someone asks me to scrape a site, I don't open a fresh Python file and start writing selectors. I follow a process.&lt;/p&gt;

&lt;p&gt;Step one is an audit. I check the site first. Is it static HTML or JavaScript-rendered? Does it have clear repeating elements like product cards? Does it paginate cleanly? Does robots.txt allow scraping?&lt;/p&gt;

&lt;p&gt;If I skip this step, I waste hours trying to scrape a site that was never going to work. Five minutes of auditing saves five hours of frustration.&lt;/p&gt;

&lt;p&gt;Building the audit as a tool&lt;br&gt;
Since I audit every site the same way, I automated the audit itself.&lt;/p&gt;

&lt;p&gt;Here's what my tool does when I run it:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
tendem-audit&lt;br&gt;
It asks for a URL. Then it:&lt;/p&gt;

&lt;p&gt;Fetches the page using the same fetch strategy I use for scraping (static HTTP first, Playwright second)&lt;/p&gt;

&lt;p&gt;Checks robots.txt&lt;/p&gt;

&lt;p&gt;Auto-detects the repeating card block using heuristics&lt;/p&gt;

&lt;p&gt;Fingerprints the CMS (Shopify, WooCommerce, WordPress, Magento)&lt;/p&gt;

&lt;p&gt;Prints a verdict: green, yellow, orange, or red&lt;/p&gt;

&lt;p&gt;Green means I can quote the job today. Red means I politely decline.&lt;/p&gt;

&lt;p&gt;That one step — automating the audit — saved me more time than any scraper I've written.&lt;/p&gt;

&lt;p&gt;The actual scraping&lt;br&gt;
Once I know the site is scrapeable, the scraping itself is boring. That's how it should be.&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
tendem-scrape &lt;a href="https://shop.example.com/products" rel="noopener noreferrer"&gt;https://shop.example.com/products&lt;/a&gt; --preset shopify --max-pages 50 --format csv,json,sqlite&lt;br&gt;
That one command:&lt;/p&gt;

&lt;p&gt;Fetches all 50 pages automatically by following the "Next" link&lt;/p&gt;

&lt;p&gt;Uses the WooCommerce preset to find the right fields (title, price, image, link)&lt;/p&gt;

&lt;p&gt;Handles the pagination URL changes&lt;/p&gt;

&lt;p&gt;Merges items into one dataset&lt;/p&gt;

&lt;p&gt;Writes CSV, JSON, and SQLite files&lt;/p&gt;

&lt;p&gt;Total time: two minutes. For a 1,000-product catalog.&lt;/p&gt;

&lt;p&gt;What production scraping actually means&lt;br&gt;
The scraping part is 20% of the job. The other 80% is what makes a client pay.&lt;/p&gt;

&lt;p&gt;Here's what I deliver for every scraping job — no exceptions:&lt;/p&gt;

&lt;p&gt;Clean CSV, ready to open in Excel&lt;/p&gt;

&lt;p&gt;Not a raw dump. Columns with the right names. No hidden characters. UTF-8 BOM so Excel doesn't mangle it.&lt;/p&gt;

&lt;p&gt;Interactive HTML dashboard&lt;/p&gt;

&lt;p&gt;A single file the client opens in their browser. Sortable columns. Filterable search box. Works offline. No dependency on my server.&lt;/p&gt;

&lt;p&gt;Data quality report&lt;/p&gt;

&lt;p&gt;A file that lists exactly what problems I found: missing titles, empty prices, duplicate rows, broken image links. Clients trust the data because they can see what was checked.&lt;/p&gt;

&lt;p&gt;Audit trail&lt;/p&gt;

&lt;p&gt;What was scraped, when, from what URL, by what method. If the client asks "how did you get this?", the answer is one JSON file away.&lt;/p&gt;

&lt;p&gt;The technical stack&lt;/p&gt;

&lt;p&gt;For those who want to know how it works under the hood:&lt;/p&gt;

&lt;p&gt;Python 3.10 or newer&lt;/p&gt;

&lt;p&gt;Playwright for anything JavaScript-rendered&lt;/p&gt;

&lt;p&gt;BeautifulSoup with lxml for parsing&lt;/p&gt;

&lt;p&gt;Pydantic v2 for validated data models&lt;/p&gt;

&lt;p&gt;SQLite or PostgreSQL for storage&lt;/p&gt;

&lt;p&gt;GitHub Actions for the CI pipeline&lt;/p&gt;

&lt;p&gt;Pytest and Allure for tests and reports&lt;/p&gt;

&lt;p&gt;The framework has over 70 automated tests. There's a public test dashboard on GitHub Pages that shows them passing.&lt;/p&gt;

&lt;p&gt;What I learned doing this for real clients&lt;br&gt;
Three things stand out.&lt;/p&gt;

&lt;p&gt;First, sessions and logins matter more than anything. Half the sites I scrape require a login. If you don't learn how to save cookies and reuse them, half the job disappears.&lt;/p&gt;

&lt;p&gt;Second, quality control is what clients actually pay for. A raw CSV is worth ten dollars. A CSV plus a quality report plus a dashboard is worth a hundred. Same data, different packaging.&lt;/p&gt;

&lt;p&gt;Third, when a site changes, your tests catch it before the client does. That's not a nice-to-have — it's the difference between a one-off job and a monthly subscription.&lt;/p&gt;

&lt;p&gt;Try it yourself&lt;br&gt;
bash&lt;br&gt;
git clone &lt;a href="https://github.com/pranromumu/tendem-scraper" rel="noopener noreferrer"&gt;https://github.com/pranromumu/tendem-scraper&lt;/a&gt;&lt;br&gt;
cd tendem-scraper&lt;br&gt;
python -m venv .venv&lt;br&gt;
source .venv/bin/activate&lt;br&gt;
pip install -e ".[dev]"&lt;br&gt;
playwright install chromium&lt;/p&gt;

&lt;p&gt;tendem-scrape &lt;a href="https://books.toscrape.com/" rel="noopener noreferrer"&gt;https://books.toscrape.com/&lt;/a&gt; --max-pages 3 --open&lt;br&gt;
The last command scrapes three pages and opens the dashboard in your browser.&lt;/p&gt;

&lt;p&gt;What's next&lt;br&gt;
I'm building this out as a service for e-commerce sellers and marketing agencies who need competitor pricing, product catalogs, and lead lists on a schedule. If you have a scraping project in mind, or you just want to talk about Python automation, feel free to reach out.&lt;/p&gt;

&lt;p&gt;The GitHub repo is open source. Fork it, break it, submit a pull request.&lt;/p&gt;

&lt;p&gt;About me: I'm a web scraping and automation engineer based in Malaysia. I build Python tools that extract data from websites and deliver clean, structured files with quality guarantees.&lt;/p&gt;

&lt;p&gt;Live demo: &lt;a href="https://pranromumu.github.io/tendem-scraper/" rel="noopener noreferrer"&gt;https://pranromumu.github.io/tendem-scraper/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GitHub repo: &lt;a href="https://github.com/pranromumu/tendem-scraper" rel="noopener noreferrer"&gt;https://github.com/pranromumu/tendem-scraper&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Fiverr gig: &lt;a href="https://www.fiverr.com/s/3AAA3yL" rel="noopener noreferrer"&gt;https://www.fiverr.com/s/3AAA3yL&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you found this post useful, connect with me. I write about Python, scraping, and automation for real client work.&lt;/p&gt;

</description>
      <category>python</category>
      <category>tutorial</category>
      <category>webdev</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>From Script to Secure Pipeline: How I Created a "Trust Boundary" in CI/CD</title>
      <dc:creator>Kabir Hossain</dc:creator>
      <pubDate>Fri, 25 Sep 2026 06:33:41 +0000</pubDate>
      <link>https://dev.to/praman/from-script-to-secure-pipeline-how-i-created-a-trust-boundary-in-cicd-11em</link>
      <guid>https://dev.to/praman/from-script-to-secure-pipeline-how-i-created-a-trust-boundary-in-cicd-11em</guid>
      <description>&lt;p&gt;A few months ago, I thought CI/CD simply meant writing a GitHub Actions YAML file that runs &lt;code&gt;pytest&lt;/code&gt;.&lt;br&gt;
If tests pass, the result is 'green'; if they fail, it's 'red'pretty straightforward, right?&lt;br&gt;
However, while progressing through my QA automation roadmap (projects P141–P170), my mentor introduced me to a concept that completely shifted my perspective on testing: the 'Trust Boundary'.&lt;br&gt;
When a test fails in CI, developers need evidence (such as screenshots, traces, or logs) to debug the issue. But how can they be certain that the evidence stored hasn't been corrupted? How do they know the pipeline is making the correct decision?&lt;br&gt;
Here is how I built a secure CI pipeline to address this challenge.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Preserving Evidence of Failure (The &lt;code&gt;if: always()&lt;/code&gt;Strategy)
Typically, if a GitHub Actions job fails, subsequent steps do not execute. This means that if your Playwright test crashes, the step to upload artifacts won't run-causing the failure screenshot to be lost forever as soon as the virtual machine shuts down.
To solve this, I used the &lt;code&gt;if: always()&lt;/code&gt; condition for the upload step:
-name: Upload Failure Evidence
uses: actions/upload-artifact@v4
if: always()
with:
name: test-evidence
path: artifacts/
This ensures that even if the test crashes, the evidence is safely uploaded to GitHub's cloud storage.&lt;/li&gt;
&lt;li&gt;Independent Auditor/Verifier (Decoupled from the Test Job)
If the test job itself generates the evidence and also verifies it, the situation becomes akin to grading one's own homework. Even if a bug corrupts the file, the test job might still report a successful verification (&lt;code&gt;verification = true&lt;/code&gt;) based on incorrect data.
To resolve this, I split my pipeline into separate jobs:
Test Job: Runs Pytest, generates evidence, calculates the SHA-256 hash, and uploads it to the cloud.
Verify Job: Downloads the artifact, recalculates the hash from the raw bytes, and compares the two.
This establishes a 'trust boundary.' Here, the Verify job acts as an independent auditor. If anyone (or a system bug) alters or tampers with the evidence while it is stored in the cloud, the hashes will no longer match, and the auditor will mark the pipeline as 'failed.'&lt;/li&gt;
&lt;li&gt;The Final Quality Gate
Finally, I added a third job: 'The Gate.'
This job depends on the 'Verify' job (though the &lt;code&gt;if: always()&lt;/code&gt; condition was not used). Its sole purpose is to display the final decision:
=================================
FINAL EVIDENCE GATE
=================================
Test execution:      PASS
Artifact upload:     PASS
SHA-256 verification: PASS
FINAL GATE: PASS 
If verification fails, the 'Gate' step is skipped, and the overall workflow is marked as 'Red' (failed).
What's next?"Praman"(Proof) 
By completing this stage of CI/CD, I realized that QA isn't just about finding bugs; it is about building an engineering framework based on trust and evidence.
Stemming from this insight, I’ve named my future architecture'Praman'-a word that translates to 'Proof' or 'Evidence'.
The next steps in my roadmap involve transforming this pipeline into a modular, "drop-in" Python QA engine that freelance clients can easily integrate into their own repositories. It will be a tool designed to execute tests, gather irrefutable evidence, and (eventually) leverage AI to analyze the root causes of failures.
If you are building CI/CD pipelines, I highly recommend exploring independent artifact verification. It elevates your pipeline from a mere "script runner" into a professional-grade engineering system.
What aspects of CI/CD are you currently learning? Let’s connect! &lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>security</category>
      <category>testing</category>
    </item>
    <item>
      <title>My QA Automation Journey: From Zero to CI/CD 🚀</title>
      <dc:creator>Kabir Hossain</dc:creator>
      <pubDate>Mon, 21 Sep 2026 22:43:34 +0000</pubDate>
      <link>https://dev.to/praman/my-qa-automation-journey-from-zero-to-cicd-3fdl</link>
      <guid>https://dev.to/praman/my-qa-automation-journey-from-zero-to-cicd-3fdl</guid>
      <description>&lt;p&gt;Hello, &lt;br&gt;
I am embarking on a long journey into QA automation—and documenting my experiences along the way.&lt;br&gt;
Just a few months ago, while I could run &lt;code&gt;pytest&lt;/code&gt;, I lacked a clear understanding of what a professional automation framework actually looks like.&lt;br&gt;
I was familiar with some basic concepts of Python and automation. However, there was a significant gap between knowing isolated tools and understanding how everything integrates within a real-world QA engineering workflow.&lt;br&gt;
So, I decided to bridge that gap through hands-on practice.&lt;br&gt;
I am following a roadmap comprising over 200 practical, hands-on projects. It begins with Python and Playwright, progressing through CI/CD, observability, and AI-assisted testing, ultimately leading to the creation of an AI-driven QA platform.&lt;br&gt;
Here is what I have worked on so far:&lt;br&gt;
✅ Python Fundamentals&lt;br&gt;
✅ Playwright&lt;br&gt;
✅ Pytest&lt;br&gt;
✅ Page Object Model&lt;br&gt;
✅ API Testing&lt;br&gt;
✅ Authentication and Session Handling&lt;br&gt;
✅ Network Interception&lt;br&gt;
✅ Screenshots, Traces, Videos, and HTML Reports&lt;br&gt;
✅ GitHub Actions and CI/CD&lt;/p&gt;

&lt;p&gt;I am currently approaching Project 152 (P152).&lt;br&gt;
Through this process, I have realized something important: learning QA automation isn't just about learning how to write a test.&lt;br&gt;
It is equally crucial to understand what happens when a test fails. For instance, I recently set up a CI 'quality gate' for a project where the process went like this:&lt;/p&gt;

&lt;p&gt;Tests fail&lt;br&gt;
↓&lt;br&gt;
CI pipeline fails&lt;br&gt;
↓&lt;br&gt;
Evidence or data regarding the failure is collected&lt;br&gt;
↓&lt;br&gt;
Artifacts are uploaded&lt;/p&gt;

&lt;p&gt;If a test fails, the pipeline should fail too—but I didn't want the evidence or data concerning that failure to be lost along with the pipeline itself.&lt;br&gt;
It was a small project, certainly, but it taught me a valuable lesson about how a real-world CI pipeline should function.&lt;br&gt;
Throughout this journey, I will continue to document my project, mistakes, problem-solving approaches, and everything new I learn.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>cicd</category>
      <category>python</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
