Conditional Test Logic¶
| Slug | Severity | Detection Scope | Protects |
|---|---|---|---|
conditional-logic |
Medium | per-test | Simple, Granular |
Summary¶
if X: assert(...) or try { sut() } catch(e) { assert(...) } inside a test body. Cyclomatic complexity > 1 means there's at least one path through the test that never reaches an assertion — vacuous by omission.
Aliases¶
- "conditional logic"
- "conditional test logic"
- "if inside test"
- "branching test body"
- "try-catch without fail"
- "platform skip in body"
- "loop without exit assertion"
- "vacuous by omission"
Description¶
Tests with branches have at least one execution path that silently passes without asserting anything. The semantic judgment: decide, per branch, whether the if encodes genuine optionality (→ split into two tests with explicit preconditions) or is compensating for an upstream oracle weakness (→ remove the if, pin the precondition in the fixture, assert unconditionally).
Platform-skip branches (if process.platform === 'win32') are a distinct subcase that should use the runner's skip mechanism instead.
Signals¶
- AST:
IfStatementinside a test-function body; the consequent containsexpect/assert; the alternate is absent or contains no assertions. try { sut() } catch (e) { expect(e.message).toBe(...) }with noassert.fail('should have thrown')after thetry. The catch is conditional on a throw actually happening.if (process.platform === 'win32') return;— platform skip expressed as body logic, notit.skip/pytest.skip.if (graphData.edges.length > 0) { expect(...) }paired withtoBeGreaterThanOrEqual(0)— passes vacuously on empty input.- Loops that assert inside but never on the loop's exit condition.
False-positive guards¶
Branch-in-test-body signals over-trigger in three shapes the audit must not flag:
- Runner-native skips are the fix, not the smell.
pytest.mark.skipif(...),it.skipIf(...),describe.skip,t.Skip(...),//go:build !windows, RSpecskip(:reason)— all express conditional execution at the runner layer, surface as "skipped" in CI reports, and carry an explicit reason. The smell isif (process.platform === 'win32') return;inside the body silently passing as if it ran. Don't flag tests that already use a runner-native skip mechanism with a reason string. try { sut(); assert.fail(...) } catch { ... }with the explicitfailafter the SUT call. This shape correctly asserts both halves of the throw contract: "an exception was raised" and "the exception had property X." It is the prescribed fix pattern in languages whose throw matcher is awkward or absent. The smell istry { sut() } catch { expect(...) }withoutassert.fail, because the catch block only runs if a throw happened. Don't flag the cured form.- Parameterized-table tests with symmetric per-row branches. A parameterized test that branches on a row's expected outcome (
if row.expected_exception: assert raises; else: assert eq(row.expected_value)) is structurally branching but verifies a deliberate matrix of cases; both arms assert. Flag the asymmetric form (one arm asserts, the other returns silently); do not flag the symmetric form, which is a parameterization technique with full assertion coverage on every row.
Prescribed Fix¶
| Shape | Transform |
|---|---|
if (cond) expect(...) with intended optionality |
Split into two tests: one where cond is true (fixture pins it), one where cond is false (asserts the other branch). |
if (cond) expect(...) compensating for a weak oracle |
Fix the fixture so cond is guaranteed true; remove the branch; assert unconditionally. |
try { sut() } catch (e) { expect(...) } |
Replace with the runner's throw matcher: expect(() => sut()).toThrow(...) / pytest.raises(T, match=...) / expect { ... }.to raise_error(T, /msg/). |
| Platform skip in body | Convert to it.skip / pytest.mark.skipif / //go:build !windows with a skip reason. |
| Loop without exit assertion | Assert on the collected results after the loop. |
Gate: preservation of regression-detection power. Test-count delta > 0 is acceptable (splits produce more tests).
Example¶
Before¶
it('parses trailing JSON', () => {
try {
parse('{ "a": 1 } garbage');
} catch (e) {
expect(e.message).toMatch(/trailing/);
}
});
After¶
it('rejects trailing garbage after JSON value', () => {
expect(() => parse('{ "a": 1 } garbage')).toThrow(/trailing/);
});
The original test passed silently if parse returned normally; the catch block was never reached and no assertion was checked. Moved to a throw matcher that verifies the exception and that one was raised.
Related modes¶
rotten-green— cousin; conditional-logic is "has an assertion but skips it on some paths", rotten-green is "has no assertion at all".vacuous-assertion—if-gated weak checks overlap.
Polyglot notes¶
Every runner has a throw matcher and a skip mechanism; the transforms are mechanical per-runner lookups. The detection (IfStatement in test body, try-without-fail-after) is AST-level and polyglot via tree-sitter / ast-grep.