Conditional Test Logic.
A test that uses `if`/`switch`/ternaries, loops, or `try`/`catch` to decide what to run or assert, so its behavior — and whether it verifies anything at all — depends on which branch executes at runtime.
##Signs and Symptoms
A test reads like a small program instead of a linear "arrange → act → assert" script. Look for control flow inside the test body:
if/else,switch, ternaries, or&&/||short-circuits that gate which assertions run.for/while/forEachloops that build inputs or iterate over assertions.try/catchused to "test" an error path, withexpectcalls hidden inside thecatch.- One test reused for several cases by branching on a flag or on environment state.
- Expected values computed in the test (often with a loop or formula) instead of hard-coded.
// Smell: assertions live inside conditional/branching code
test('user discount', () => {
const user = getUser();
if (user.isPremium) {
expect(price(user)).toBe(80); // may never run
} else {
expect(price(user)).toBe(100); // may never run
}
});
test('throws on bad input', () => {
try {
parse('!!!');
// if parse() does NOT throw, we fall through and assert nothing → test passes
} catch (err) {
expect(err.message).toMatch(/invalid/);
}
});
The tell-tale failure mode: the test stays green even when the code is broken, because the branch containing the assertion was never taken.
##Reasons for the Problem
Why it happens
- DRY taken too far. Authors try to cover several scenarios with a single "flexible" test, branching on inputs or a flag instead of writing one test per case (Meszaros: Flexible Test).
- Environment coupling. The SUT wasn't decoupled from its dependencies, so the test adapts to whatever state it finds at runtime.
- Defensive teardown.
if (resource) resource.close()creeps in to avoid tearing down fixtures that may not exist (Complex Teardown). - Computed expectations. The expected result is derived with the same algorithm as production code, dragging that logic — loops and all — into the test (Production Logic in Test).
- Error-path testing by hand.
try/catchis used to assert on a thrown error instead of a built-in matcher.
Why it hurts
- False confidence (the worst part). Most runners only fail a test when an assertion throws. If the asserting branch is skipped — or the code under test doesn't throw inside a
try— the test passes having verified nothing. - Untested test code. Branches and loops in a test are logic that itself has no tests; a bug in the test's own control flow goes unnoticed.
- Poor diagnostics. When a branching test fails you must first work out which path ran before you can interpret the failure.
- Reduced readability & maintainability. A linear test documents one behavior with one expected outcome; a branching test forces the reader to simulate execution to know what is actually guaranteed.
- Brittleness. Tests that key off runtime/environment state pass or fail non-deterministically.
##Treatment
Make each test a single, unconditional, linear path. Concretely:
- One scenario per test. Split a branching test into separate tests, or use the framework's data-driven API (
test.each,it.each, parameterized tests) so each case is its own clearly-named, independently-reported run. - Hoist branching outside the test. If a case only applies under some condition, decide that at definition time (e.g.
describe/itchosen by config), not inside the test body — the assertions themselves stay unconditional. - Hard-code expected values. Replace computed expectations with literal expected results (or an Expected Object / custom matcher). Don't re-implement production logic in the test.
- Test error paths with matchers, not
try/catch:expect(fn).toThrow(...),await expect(p).rejects.toThrow(...). These fail loudly when no error is thrown. - If a conditional assertion is truly unavoidable, pin the count with
expect.assertions(n)/expect.hasAssertions()so a skipped branch fails instead of silently passing. - Replace conditional teardown with framework lifecycle hooks (
afterEach) and automatic/idempotent cleanup, so noifis needed to guard teardown.
// Before — branching test, assertions may be skipped
test('user discount', () => {
const user = getUser();
if (user.isPremium) expect(price(user)).toBe(80);
else expect(price(user)).toBe(100);
});
// After — one explicit case per row, every assertion always runs
test.each([
['premium', { isPremium: true }, 80],
['regular', { isPremium: false }, 100],
])('price for %s user', (_label, user, expected) => {
expect(price(user)).toBe(expected);
});
// Before — try/catch that passes when nothing throws
try { parse('!!!'); } catch (e) { expect(e.message).toMatch(/invalid/); }
// After — fails if parse() does not throw
expect(() => parse('!!!')).toThrow(/invalid/);
##Detected by
- eslint-jest jest/no-conditional-expect — no-conditional-expect
- eslint-jest jest/no-conditional-in-test — no-conditional-in-test
- eslint-vitest vitest/no-conditional-expect — no-conditional-expect
- eslint-vitest vitest/no-conditional-in-test — no-conditional-in-test
- eslint-vitest vitest/no-conditional-tests — no-conditional-tests