{"title":"Review AI-Generated pytest Tests with Mutation Checks","excerpt":"A generated test file can execute every branch of a pricing rule and still pass when the threshold moves by one, because coverage records that a line ran and nothing records that an assertion depended on it. This guide covers scoping a mutmut run to a single function, telling a surviving mutant apart from a mutant that any calling test kills, the two review patterns — a mock placed one layer too low and an assertion that restates its own fixture — that make every mutant survive, and the pragmas and settings that quietly remove code from the run.","canonicalUrl":"https://automationtester.in/blog/ai-in-testing/review-ai-generated-pytest-tests-mutation-checks","category":{"name":"AI in Testing","slug":"ai-in-testing"},"tags":["python","pytest","mutation-testing","ai-assisted-testing","code-review","test-design"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-09-25T13:30:00.000Z","dateModified":"2026-10-14T11:09:52.936Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/review-ai-generated-pytest-tests-mutation-checks.webp","alt":"Dark technical illustration read from left to right. Ten green horizontal bars enter from off the left edge, stacked evenly inside a large faint rounded panel crossed by a regular dashed vertical grid. A dashed green vertical line runs top to bottom through the middle of the stack with a ringed dot at each end, and a small rounded square sits on it at the height of every bar; eight of those squares are green and two are orange. From each green square a thin line runs rightward and ends in an arrowhead beside a thick green vertical segment, then a ringed green dot. Those thick segments stack into a broken vertical rail, and at the two heights where the rail has no segment there are instead two short orange dashes marking the gap. At each of those two heights an orange line runs unbroken from the square, straight through the gap and off the right edge of the frame, ending in an arrowhead. Beside each orange line, to the right of a faint grey vertical divider, sits a pair of orange pills of decreasing length; the other rows in that region carry much fainter grey pills. Thin grey rules run edge to edge across the top and bottom of the frame."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> Coverage proves a line ran. A surviving mutant proves no assertion depended on it. Change one character of the behaviour under review: <code class=\"language-text\">&lt;</code> to <code class=\"language-text\">&lt;=</code>, or a threshold literal by one, and rerun only the tests that touch it. Any generated test that still passes is asserting something other than the rule it claims to protect, and the review note writes itself: the mutant, the test that should have caught it, and the boundary value it never used.</div>\n\n<p>Here is a pull request that a reviewer approves in forty seconds. A pricing rule, and two tests for it:</p>\n\n<pre class=\"language-python\"><code class=\"language-python\"># pricing.py\nfrom decimal import Decimal\n\nBULK_THRESHOLD = 10\n\ndef discount_for(quantity: int, unit_price: Decimal) -&gt; Decimal:\n    if quantity &lt; BULK_THRESHOLD:\n        return Decimal(\"0\")\n    return (unit_price * quantity * Decimal(\"0.15\")).quantize(Decimal(\"0.01\"))</code></pre>\n\n<pre class=\"language-python\"><code class=\"language-python\"># test_pricing.py  — generated\ndef test_discount_applied_for_bulk_order():\n    assert discount_for(50, Decimal(\"2.00\")) == Decimal(\"15.00\")\n\n\ndef test_no_discount_for_small_order():\n    assert discount_for(5, Decimal(\"2.00\")) == Decimal(\"0\")</code></pre>\n\n<p>Both branches execute. Statement and branch coverage of <code class=\"language-text\">discount_for</code> are both 100%. And the pair cannot detect the single most likely defect in the function, which is that the threshold is off by one.</p>\n\n<p>Move <code class=\"language-text\">BULK_THRESHOLD</code> to 11. Both tests still pass: 5 is below either threshold and 50 is above either. Change <code class=\"language-text\">&lt;</code> to <code class=\"language-text\">&lt;=</code>. Both tests still pass, for the same reason. The rule these tests exist to defend, \"bulk pricing starts at ten units,\" is the one thing they never look at.</p>\n\n<div class=\"callout callout-warning\"><strong>A clean mutation report is only as strong as the mutants that were generated.</strong> mutmut 3 does not mutate code outside functions at all, so a threshold defined at module scope produces no mutant. With <code class=\"language-text\">mutate_only_covered_lines</code> enabled it also skips anything your coverage configuration excludes, and <code class=\"language-text\"># pragma: no mutate</code> plus <code class=\"language-text\">do_not_mutate_patterns</code> can remove more. Read those settings as part of the diff before you read the score. Checked against mutmut 3.7.0 and coverage.py 7.16.0 on 2026-09-07.</div>\n\n<h2 id=\"a-surviving-mutant-is-a-missing-assertion\">A surviving mutant is a missing assertion</h2>\n\n<p>Mutation testing makes that argument mechanical. A mutation tool edits your source, runs the tests, and records whether anything failed. A mutant that gets caught is <em>killed</em>; one that does not is a <em>survivor</em>. Every survivor is a change to production behaviour that your suite would have merged.</p>\n\n<p>mutmut 3.7 keeps its mutation catalogue deliberately small and deliberately subtle. The three you will meet on almost every review are stated outright in its documentation:</p>\n\n<ul>\n<li>Integer literals are incremented: \"so 0 becomes 1, 5 becomes 6, etc.\"</li>\n<li><code class=\"language-text\">&lt;</code> is changed to <code class=\"language-text\">&lt;=</code>.</li>\n<li><code class=\"language-text\">break</code> is changed to <code class=\"language-text\">continue</code> and vice versa.</li>\n</ul>\n\n<p>Those are exactly the edits a tired author makes by accident, which is the point: \"in general the idea is that the mutations should be as subtle as possible.\" Run it against the function above and you get two survivors and one kill:</p>\n\n<pre class=\"language-bash\"><code class=\"language-bash\">$ mutmut run \"pricing.discount_for*\"\n$ mutmut browse</code></pre>\n\n<pre class=\"language-text\"><code class=\"language-text\">pricing.discount_for\n  x_discount_for__mutmut_1   BULK_THRESHOLD -&gt; 11        survived\n  x_discount_for__mutmut_2   &lt; -&gt; &lt;=                     survived\n  x_discount_for__mutmut_3   \"0.15\" -&gt; \"XX0.15XX\"        killed</code></pre>\n\n<p>The kill is the one worth ignoring. mutmut mutates string literals by wrapping them. The documentation's own example is <code class=\"language-text\">logging.info(\"Foo\")</code> becoming <code class=\"language-text\">logging.info(\"XXFooXX\")</code>, so <code class=\"language-text\">Decimal(\"XX0.15XX\")</code> raises <code class=\"language-text\">InvalidOperation</code> before any assertion runs. Any test that calls the function at all kills that mutant. It tells you the function was invoked, which is what coverage already told you.</p>\n\n<p>Sort survivors and trivial kills apart before you write a single review comment. A suite can have a high mutation score built almost entirely from mutants that crash on contact, and the two survivors above are worth more than fifty of those.</p>\n\n<h2 id=\"one-function-at-a-time-not-the-repository\">One function at a time, not the repository</h2>\n\n<p>Mutation runs are usually abandoned because someone started with <code class=\"language-text\">mutmut run</code> across a whole service and watched it for an hour. Reviewing a pull request does not need that. mutmut takes Unix filename patterns, and the unit can be a single function:</p>\n\n<pre class=\"language-bash\"><code class=\"language-bash\">mutmut run \"pricing*\"                    # one module\nmutmut run \"pricing.discount_for*\"       # one function</code></pre>\n\n<p>Scope it to the functions the pull request touched. That turns a mutation run from an overnight job into something that finishes while you read the diff, and it keeps the result attributable: a survivor in a function nobody changed is not this author's problem.</p>\n\n<p>Two configuration choices decide whether the run is usable at all. Both live in <code class=\"language-text\">pyproject.toml</code>:</p>\n\n<pre class=\"language-toml\"><code class=\"language-toml\">[tool.mutmut]\nsource_paths = [ \"src/\" ]\npytest_add_cli_args_test_selection = [ \"tests/unit\" ]\n# Only mutate lines coverage.py saw execute. Default is false, which means\n# mutmut falls back to \"any function that gets called\".\nmutate_only_covered_lines = true\n# Stop counting a test as relevant to a function once the call is this deep.\nmax_stack_depth = 8</code></pre>\n\n<p><code class=\"language-text\">max_stack_depth</code> is the one people miss, and mutmut is explicit about the trade it makes: a lower value \"will increase mutation speed and lead to more localized tests, but will also lead to more surviving mutants that would otherwise have been caught.\" Without it, a utility called incidentally from three hundred integration tests drags all three hundred into every mutant's verdict, and a survivor stops telling you which test was supposed to catch it.</p>\n\n<p>The other number to know before you start is the timeout. mutmut kills a slow mutant after <code class=\"language-text\">(duration_of_original_tests + timeout_constant) * timeout_multiplier</code> seconds, defaulting to a constant of 1.0 and a multiplier of 15.0. That formula is multiplicative in your suite's own runtime, so a selection containing one test that sleeps for two real seconds does not cost two seconds per mutant. It moves the ceiling for every mutant in the run. Point <code class=\"language-text\">pytest_add_cli_args_test_selection</code> at the fast unit tests and leave the sleeping ones out.</p>\n\n<h2 id=\"the-mock-that-deletes-the-code-under-test\">The mock that deletes the code under test</h2>\n\n<p>When every mutant in a function survives, not one, all of them, the function almost certainly never ran. The usual cause in generated tests is a mock placed one layer too low:</p>\n\n<pre class=\"language-python\"><code class=\"language-python\"># Wrong. checkout.discount_for is the behaviour this test claims to cover,\n# and this line replaces it. Every mutant inside discount_for survives.\ndef test_checkout_applies_bulk_discount(monkeypatch):\n    monkeypatch.setattr(\"checkout.discount_for\", lambda q, p: Decimal(\"15.00\"))\n\n    assert checkout_total(50, Decimal(\"2.00\")) == Decimal(\"85.00\")</code></pre>\n\n<pre class=\"language-python\"><code class=\"language-python\"># Right. Mock the boundary the test does not own — the tax service — and let\n# the pricing rule execute. Mutants inside discount_for now have somewhere\n# to show up.\ndef test_checkout_applies_bulk_discount():\n    tax = create_autospec(TaxService, instance=True)\n    tax.rate_for.return_value = Decimal(\"0\")\n\n    assert checkout_total(50, Decimal(\"2.00\"), tax) == Decimal(\"85.00\")</code></pre>\n\n<p>Use <code class=\"language-text\">create_autospec</code> or <code class=\"language-text\">autospec=True</code> rather than a bare <code class=\"language-text\">Mock</code>, because the standard library is blunt about what a bare one costs: \"if you refactor some of your code, rename members and so on, any tests for code that is still using the old api but uses mocks instead of the real objects will still pass. This means your tests can all pass even though your code is broken.\" Autospec fixes that by limiting the mock to the real object's API and giving mocked methods \"the same call signature as the original so they raise a <code class=\"language-text\">TypeError</code> if they are called incorrectly.\"</p>\n\n<p>The related failure is a patch that silently does nothing. <code class=\"language-text\">unittest.mock</code>'s rule is that \"you patch where an object is looked up, which is not necessarily the same place as where it is defined\". If <code class=\"language-text\">checkout.py</code> says <code class=\"language-text\">from pricing import discount_for</code>, then patching <code class=\"language-text\">pricing.discount_for</code> has no effect, because <code class=\"language-text\">checkout</code> already holds a reference to the real function. The test passes, the mock is never consulted, and nothing in the output says so. A mutation run does say so: the mutants get killed by a test the author believed was stubbed out.</p>\n\n<h2 id=\"assertions-that-only-restate-the-fixture\">Assertions that only restate the fixture</h2>\n\n<p>The second pattern that produces uniform survival is an assertion whose expected value came from the setup two lines above it:</p>\n\n<pre class=\"language-python\"><code class=\"language-python\">def test_order_totals(order_factory):\n    order = order_factory(subtotal=Decimal(\"100.00\"), currency=\"USD\")\n\n    assert order.subtotal == Decimal(\"100.00\")   # the factory said so\n    assert order.currency == \"USD\"               # the factory said so\n    assert order.total &gt; 0                       # true for every non-empty order</code></pre>\n\n<p>Nothing here depends on the pricing rule, so no mutation of it can fail this test. The discriminating question at review time is not \"is this assertion true\" but \"what value would make it false\". If the answer is \"only a broken factory\", the test belongs to the factory, not to the order.</p>\n\n<p>Rewrite it with a value the test computed independently of the code path it is checking:</p>\n\n<pre class=\"language-python\"><code class=\"language-python\">@pytest.mark.parametrize(\n    (\"quantity\", \"expected\"),\n    [\n        pytest.param(9, Decimal(\"0\"), id=\"one-below-threshold\"),\n        pytest.param(10, Decimal(\"3.00\"), id=\"exactly-at-threshold\"),\n        pytest.param(11, Decimal(\"3.30\"), id=\"one-above-threshold\"),\n    ],\n)\ndef test_bulk_discount_starts_at_ten_units(quantity, expected):\n    assert discount_for(quantity, Decimal(\"2.00\")) == expected</code></pre>\n\n<p>Those three rows kill both survivors from earlier. The 9/10 pair kills the threshold-literal mutant, and the same pair kills the <code class=\"language-text\">&lt;</code>-to-<code class=\"language-text\">&lt;=</code> mutant, because moving the comparison moves the boundary between them. The 11 row is not redundant padding: it is what distinguishes \"the boundary is right\" from \"the boundary is right and the arithmetic still scales\".</p>\n\n<p>Note that the expected figures were derived from the written rule: fifteen per cent of quantity times unit price, rounded to the cent, and not from running the function and pasting what it returned. An oracle copied from the implementation cannot disagree with the implementation, which is the failure mutation testing is least able to help with.</p>\n\n<h2 id=\"what-the-tool-quietly-declines-to-mutate\">What the tool quietly declines to mutate</h2>\n\n<p>A clean mutation report is only as strong as the set of mutants that were generated, and mutmut skips more than most people expect.</p>\n\n<p>Module-level code is not mutated at all in mutmut 3. The documentation points you at mutmut 2, \"which has a different execution model\", if you need it. Constants defined at import time, dataclass field defaults and configuration dictionaries therefore produce no mutants, and a threshold that lives at module scope rather than inside the function is invisible to the run.</p>\n\n<p>With <code class=\"language-text\">mutate_only_covered_lines = true</code>, mutmut \"also honours the lines coverage.py is told to leave out of its measurement, so code marked with <code class=\"language-text\"># pragma: no cover</code>, or matched by your <code class=\"language-text\">exclude_lines</code>/<code class=\"language-text\">exclude_also</code> settings, is not mutated either.\" An exclusion added to make a coverage gate green silently removes that code from mutation as well.</p>\n\n<p>And mutation can be turned off in the source. <code class=\"language-text\"># pragma: no mutate</code> suppresses a line, <code class=\"language-text\"># pragma: no mutate block</code> suppresses a whole indentation block including an entire function when placed inline on the <code class=\"language-text\">def</code>, and <code class=\"language-text\">do_not_mutate_patterns</code> suppresses by regex across the codebase. All three are legitimate: the documented use is logging calls and <code class=\"language-text\">break</code>-versus-<code class=\"language-text\">continue</code> optimisations. All three are also a way to make a report look clean. Read them as part of the diff.</p>\n\n<pre class=\"language-bash\"><code class=\"language-bash\"># Worth a look before trusting any mutation number in a pull request.\ngit diff --unified=0 origin/main -- '*.py' | grep -E 'pragma: no (mutate|cover)'\ngit diff origin/main -- pyproject.toml setup.cfg | grep -E 'do_not_mutate|only_mutate|max_stack_depth'</code></pre>\n\n<h2 id=\"where-branch-coverage-still-earns-its-place\">Where branch coverage still earns its place</h2>\n\n<p>None of this retires coverage. Branch coverage answers a question mutation testing does not: which paths were never taken at all. Coverage.py tracks, for each line that can jump to more than one next line, which destinations were actually visited, and reports the missing transition by number: in its own <code class=\"language-text\">if</code>/<code class=\"language-text\">else</code> example, a flag that is always true reports the missing branch as <code class=\"language-text\">2-&gt;5</code>.</p>\n\n<p>That is the right first pass on a generated test file, and it is cheap. Run it with <code class=\"language-text\">--branch</code>, look for partial branches in the code the pull request touched, and treat each one as a case the author did not think of. Then mutate the branches that <em>were</em> taken, because that is the set coverage has already declared satisfied and cannot say anything more about. The two tools disagree about different things, which is why running one is not a reason to skip the other.</p>\n\n<h2 id=\"apply-this-now\">Apply this now</h2>\n\n<p>Take the next pull request containing generated tests and pick the one function in it that encodes a business rule with a number in it: a threshold, a limit, a retention window, a rate band. Run <code class=\"language-text\">mutmut run \"&lt;module&gt;.&lt;function&gt;*\"</code> against it with the fast unit selection only.</p>\n\n<p>Then split the report in two. Trivial kills, where the mutated source raises before an assertion runs, go in one pile and are not evidence. Survivors go in the other, and each one becomes a review comment of exactly three lines: the mutant mutmut printed, the test whose name claims to cover that rule, and the input value that would have separated them.</p>\n\n<p>What you should have at the end is a diff that adds boundary rows rather than test functions, and a short list of rules whose thresholds are now pinned by a test that fails when they move. If the mutation run produced no survivors and no interesting kills either, check <code class=\"language-text\">source_paths</code> and the pragmas before believing it.</p>\n\n<h2 id=\"questions-that-come-up-in-review\">Questions that come up in review</h2>\n\n<h3 id=\"is-there-a-mutation-score-worth-enforcing\">Is there a mutation score worth enforcing in CI?</h3>\n\n<p>Not as a global gate, at least not first. A repository-wide percentage is dominated by trivial kills and by code nobody disputes, so it moves for reasons unrelated to test quality and teams learn to ignore it. mutmut does support publishing one. <code class=\"language-text\">mutmut export-cicd-stats</code> feeds <code class=\"language-text\">mutmut badge</code>, which emits Shields endpoint JSON, and that is genuinely useful as a trend on a module you are actively hardening. Enforce it on a package you have already cleaned, not on the whole tree on day one.</p>\n\n<h3 id=\"how-does-this-differ-from-property-based-testing\">How is this different from generating more inputs?</h3>\n\n<p>They find opposite things. Property-based generation searches the input space for a case your assertions reject; mutation testing holds the inputs fixed and searches the <em>source</em> for a change your assertions ignore. A property test with a weak property will happily explore ten thousand inputs and kill nothing, and a mutation run will tell you so. Where they meet is useful: a surviving mutant often names the input dimension your generator was never given a strategy for.</p>\n\n<h3 id=\"the-run-takes-too-long-on-our-suite\">The run takes too long on our suite. What is the first thing to change?</h3>\n\n<p>The test selection, not the mutation settings. mutmut runs your chosen tests once per mutant, so anything slow is multiplied by the mutant count, and the timeout ceiling scales with your suite's own runtime as well. Point <code class=\"language-text\">pytest_add_cli_args_test_selection</code> at unit tests only, add <code class=\"language-text\">max_stack_depth</code>, and scope the run with a pattern. Reach for parallelism after those three, because they change the size of the problem rather than the speed of the machine.</p>\n\n<h3 id=\"should-a-generated-test-ever-be-deleted-outright\">Should a generated test ever be deleted outright?</h3>\n\n<p>Yes, and the survivors tell you which. A test that kills no mutant that another test does not already kill, and whose assertions all restate its own setup, is costing you runtime and a maintenance obligation in exchange for nothing. Deleting it is not a reduction in coverage of behaviour, because it was covering none. The ones to keep and repair are those aimed at a real rule with the wrong assertion attached.</p>\n\n<h3 id=\"does-any-of-this-work-without-installing-a-mutation-tool\">Does any of this work without installing a mutation tool?</h3>\n\n<p>The technique does, at the cost of doing it by hand. Change the threshold literal by one in your working copy, run the tests, and change it back. If nothing fails, the review comment is the same one you would have written from a report. That is worth doing on the spot for a single suspicious rule, and it is how you convince a team that the tool is worth adding: a live demonstration that the suite they trust does not notice the change is more persuasive than a percentage.</p>\n\n<h2 id=\"primary-references\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://mutmut.readthedocs.io/en/latest/\">mutmut — documentation</a>. The mutation catalogue stated in \"Example mutations\": integer literals incremented, <code class=\"language-text\">&lt;</code> changed to <code class=\"language-text\">&lt;=</code>, <code class=\"language-text\">break</code> and <code class=\"language-text\">continue</code> swapped, with the design goal that \"the mutations should be as subtle as possible\". Also the filename-pattern scoping (<code class=\"language-text\">mutmut run \"my_module.my_function*\"</code>), the <code class=\"language-text\">source_paths</code> and <code class=\"language-text\">pytest_add_cli_args_test_selection</code> configuration, <code class=\"language-text\">mutate_only_covered_lines</code> and its interaction with <code class=\"language-text\"># pragma: no cover</code>, <code class=\"language-text\">max_stack_depth</code> and its stated trade-off, the <code class=\"language-text\">no mutate</code> pragmas and <code class=\"language-text\">do_not_mutate_patterns</code>, the timeout formula <code class=\"language-text\">(duration_of_original_tests + timeout_constant) * timeout_multiplier</code> with defaults 1.0 and 15.0, the string-literal mutation example <code class=\"language-text\">logging.info(\"XXFooXX\")</code>, that mutmut 3 does not mutate code outside functions, and <code class=\"language-text\">export-cicd-stats</code> with <code class=\"language-text\">mutmut badge</code>.</li>\n<li><a href=\"https://pypi.org/project/mutmut/\">mutmut on PyPI</a>. The current release, 3.7.0, and the fork requirement that means Windows use goes through WSL, checked 2026-09-07.</li>\n<li><a href=\"https://docs.python.org/3/library/unittest.mock.html#autospeccing\">Python — unittest.mock: Autospeccing</a>. That a plain mock survives a rename in the code under test, so \"your tests can all pass even though your code is broken\", and that autospecced functions \"have the same call signature as the original so they raise a <code class=\"language-text\">TypeError</code> if they are called incorrectly\". Also that a <code class=\"language-text\">spec</code> turns an attribute typo into an <code class=\"language-text\">AttributeError</code> rather than a silently passing assertion.</li>\n<li><a href=\"https://docs.python.org/3/library/unittest.mock.html#where-to-patch\">Python — unittest.mock: Where to patch</a>. That \"you patch where an object is looked up, which is not necessarily the same place as where it is defined\", and the worked <code class=\"language-text\">from a import SomeClass</code> case where patching the defining module has no effect at all.</li>\n<li><a href=\"https://coverage.readthedocs.io/en/latest/branch.html\">Coverage.py — Branch coverage measurement</a>. What a partial branch is, that the coverage percentage is \"actual executions divided by the execution opportunities\" with each branch destination counting as one, and the reporting form for a missing transition such as <code class=\"language-text\">2-&gt;5</code> in the <code class=\"language-text\">if</code>/<code class=\"language-text\">else</code> example. Version 7.16.0.</li>\n<li><a href=\"https://docs.pytest.org/en/stable/how-to/parametrize.html\">pytest — How to parametrize fixtures and test functions</a>. The <code class=\"language-text\">@pytest.mark.parametrize</code> signature and <code class=\"language-text\">pytest.param(..., id=...)</code>, which is what lets each boundary row report under a name a reviewer can read in the failure output.</li>\n</ul>\n"}