ConstructiCat Logo
CodeBust.
Browse section ▾

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/forEach loops that build inputs or iterate over assertions.
  • try/catch used to "test" an error path, with expect calls hidden inside the catch.
  • 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/catch is 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:

  1. 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.
  2. Hoist branching outside the test. If a case only applies under some condition, decide that at definition time (e.g. describe/it chosen by config), not inside the test body — the assertions themselves stay unconditional.
  3. 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.
  4. Test error paths with matchers, not try/catch: expect(fn).toThrow(...), await expect(p).rejects.toThrow(...). These fail loudly when no error is thrown.
  5. 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.
  6. Replace conditional teardown with framework lifecycle hooks (afterEach) and automatic/idempotent cleanup, so no if is 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