ConstructiCat Logo
CodeBust.
Browse section ▾

Assertion Roulette.

A test packs many undocumented assertions into one method, so when it fails you can't tell which assertion fired or why — you have to gamble.

##Signs and Symptoms

A single test contains a string of bare assertions with no explanatory messages and no clear single objective. When it goes red, the failure report (especially a CI summary line or a shared assertion helper) tells you that something broke, but not which check or why — you play "roulette" to find the culprit.

Tell-tale signs:

  • Many expect(...) / assert(...) calls in one test, none carrying a descriptive message or label.
  • The test name is generic ('works', 'user profile', 'test register') so a failure communicates nothing on its own.
  • Assertions span several unrelated concerns (an Eager Test reusing one fixture to check everything at once).
  • A failure makes you reach for the debugger or line numbers to figure out what was actually being verified.
  • Because most asserts fail-fast, the first failure short-circuits the rest, so you never see the later checks in a single run.
test('user profile', () => {
  const user = createUser({ name: 'Ada', age: 36 });
  expect(user.name).toBe('Ada');
  expect(user.age).toBe(36);
  expect(user.isAdult).toBe(true);   // if THIS is the red one,
  expect(user.slug).toBe('ada');     // the report just says
  expect(user.roles).toContain('member'); // "expected false to be true"
  expect(user.createdAt).toBeInstanceOf(Date);
});

Note: modern runners (Jest, Vitest) print the failing line and a diff, which softens the "which line?" problem. The smell still bites when the test mixes objectives, has a vague name, loops over assertions, hides them behind shared helpers, or runs where only a summary message survives.

##Reasons for the Problem

Why it happens

  • Eager Test / fixture reuse. Expensive setup tempts you to pile on "while I'm here" checks rather than write a second test.
  • Whole-object verification done the hard way. Asserting an object field-by-field naturally produces a long run of undocumented asserts.
  • Copy-paste growth. Tests accrete assertions over time without anyone splitting them.
  • Historical tooling. Classic xUnit assertions reported only pass/fail, so an unlabeled assert gave no context on failure — the origin of Meszaros's original smell.
  • Deadline pressure. One big test feels faster to write than several focused ones.

Why it hurts

  • Diagnosability / reliability. Per testsmells.org, "multiple assertion statements in a test method without a descriptive message impacts readability/understandability/maintainability as it's not possible to understand the reason for the failure." You burn time locating which assertion fired.
  • Hidden defects (false confidence). Fail-fast assertions stop at the first failure, so later assertions never execute. You fix one, rerun, find the next — multiple bugs are masked, and a "green after one fix" feels safer than it is.
  • Readability. The test stops documenting a single behavior; its intent is buried in a list, and a generic test name adds nothing.
  • Maintainability. It's unclear whether a new check belongs in this test or a new one, so the pile keeps growing and unrelated concerns become coupled.

##Treatment

Aim for the principle behind a Single-Condition Test: each test verifies one behavior and can fail for exactly one reason.

  1. Split by objective. Break the omnibus test into focused tests with descriptive names. The name then is the failure message.
  2. Collapse field-by-field checks into one assertion. Use toEqual / expect.objectContaining / a reviewed snapshot so N asserts become one meaningful diff.
  3. When grouping is genuinely cohesive, label the assertions. Jest/Vitest expect has no message arg, so use node:assert's message parameter, Vitest's expect(actual, message), or jest-expect-message.
  4. Want every check to run and report together? Prefer splitting; otherwise use soft assertions (expect.soft in Vitest) so one failure doesn't hide the rest.
  5. Guard against regression by capping assertions per test (see detectors).

Before — assertion roulette:

test('register user', () => {
  const res = register({ email: 'a@b.com', age: 36 });
  expect(res.ok).toBe(true);
  expect(res.user.email).toBe('a@b.com');
  expect(res.user.isAdult).toBe(true);
  expect(res.welcomeEmailSent).toBe(true);
});

After — one behavior per test, with a whole-object assert:

describe('register', () => {
  it('accepts a valid adult signup', () => {
    expect(register({ email: 'a@b.com', age: 36 }).ok).toBe(true);
  });

  it('stores the normalized user record', () => {
    const { user } = register({ email: 'a@b.com', age: 36 });
    expect(user).toEqual(
      expect.objectContaining({ email: 'a@b.com', isAdult: true }),
    );
  });

  it('sends a welcome email on signup', () => {
    expect(register({ email: 'a@b.com', age: 36 }).welcomeEmailSent).toBe(true);
  });
});

If you must keep one test, make every assertion self-describing:

import { strict as assert } from 'node:assert';
assert.equal(res.ok, true, 'registration should succeed');
assert.equal(res.welcomeEmailSent, true, 'welcome email should be sent');

##Detected by

  • eslint-jest jest/max-expectsFlags the count-based proxy of Assertion Roulette: reports when a test exceeds N expect() calls (default 5). Docs note that more assertions tend to mix multiple objectives.
  • eslint-vitest vitest/max-expectsVitest equivalent: enforces a maximum number of expect() assertions per test, catching tests that pack in too many checks.
  • sonar java:S5961SonarSource 'Test methods should not contain too many assertions' — caps assertions per test (default 25 for JUnit/AssertJ), the standard static-analysis proxy for this smell.