Skip to content

feat: mutate ternary conditions - #546

Merged
boxed merged 1 commit into
boxed:mainfrom
espressolee:ternary-mutation
Aug 2, 2026
Merged

feat: mutate ternary conditions#546
boxed merged 1 commit into
boxed:mainfrom
espressolee:ternary-mutation

Conversation

@espressolee

Copy link
Copy Markdown
Contributor

Closes #196. Credit to @ryanfreckleton for #478 — that is where I started, but the tree has moved (node_mutation.py is now src/mutmut/mutation/mutators.py), so this is a fresh implementation rather than a rebase.

What was missing

A ternary is a branch nothing here mutated. Branch coverage does not see an uncovered arm either, so an untested arm read as a pass twice over. Before this, grep -rn "IfExp\|ternary\|if_exp" src/ tests/ docs/ returned nothing.

operator_if_exp yields two mutants per ternary, forcing it down each arm:

a if b else c  ->  a if (b) and False else c
                   a if (b) or True else c

The parentheses

The review on #478 asked to drop the precedence wrapping and use node.test directly. That is right for or True and wrong for and False, because and binds tighter than a top-level or. Over all 16 assignments of a if b or c else d:

mutant differs from original
(b or c) and False 6 / 16
b or c and False 2 / 16, and only when b is falsy
(b or c) or True 2 / 16
b or c or True 2 / 16 — identical to the wrapped form

Unparenthesised, b or c and False parses as b or (c and False) and still takes the true branch whenever b is truthy, i.e. a mutant tests mostly cannot kill.

or True genuinely does not need the parentheses. I wrap both anyway so there is one rule instead of two, and it costs nothing — happy to make it asymmetric if you prefer.

Test impact

test_function_with_annotation shifts because its fixture contains a ternary: the two new mutants take numbers 1 and 2 and push the arithmetic and index mutants to 3–5. The three original assertions are kept and renumbered, and the two new mutants are asserted alongside them, so the test still shows nothing was lost.

Verification

pytest tests/ on 4f12080:

failed passed
before 12 345
after 12 345

Identical FAILED sets — I diffed them. The e2e inline-snapshot suite is unchanged.

tests/utils/test_safe_setproctitle.py is flaky on this machine regardless of the change (1 pass in 5 runs both with and without it), so it moves in and out of that list on its own; I checked rather than attributing it.

ruff check, ruff format --check and mypy all pass on the two changed files.

Closes boxed#196.

A ternary is a branch that nothing here mutated. Branch coverage does not see
an uncovered arm either, so an untested arm read as a pass twice over. `grep -rn
"IfExp\|ternary\|if_exp"` over src/, tests/ and docs/ returned nothing before
this.

`operator_if_exp` yields two mutants per ternary, neutralising the condition in
each direction:

    a if b else c  ->  a if (b) and False else c
                       a if (b) or True else c

On the parentheses, which boxed#478's review asked to drop: they are load-bearing
for the `and False` half. `and` binds tighter than a top-level `or`, so an
unparenthesised `b or c` becomes `b or (c and False)`, which still takes the
true branch whenever `b` is truthy. Over the 16 assignments of a
`a if b or c else d`:

    (b or c) and False    differs from the original on 6 of 16
    b or c and False      differs on 2 of 16, and only when b is falsy

`or True` genuinely does not need them — parenthesised and not are identical on
all 16 — but wrapping both keeps one rule rather than two, and costs nothing.

test_function_with_annotation shifts because its fixture contains a ternary:
the two new mutants take numbers 1 and 2 and push the arithmetic and index
mutants to 3-5. The three original assertions are kept, renumbered, and the two
new mutants are asserted alongside them, so the test still shows nothing was
lost.

Measured on 4f12080, `pytest tests/`: 12 failed / 345 passed before and after,
with an identical FAILED set. (tests/utils/test_safe_setproctitle.py is flaky
here regardless of this change — 1 pass in 5 runs both with and without it.)
ruff check, ruff format --check and mypy pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@boxed
boxed merged commit 01ed58b into boxed:main Aug 2, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add mutation for ternary operators

2 participants