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.
- Split by objective. Break the omnibus test into focused tests with descriptive names. The name then is the failure message.
- Collapse field-by-field checks into one assertion. Use
toEqual/expect.objectContaining/ a reviewed snapshot so N asserts become one meaningful diff. - When grouping is genuinely cohesive, label the assertions. Jest/Vitest
expecthas no message arg, so usenode:assert's message parameter, Vitest'sexpect(actual, message), orjest-expect-message. - Want every check to run and report together? Prefer splitting; otherwise use soft assertions (
expect.softin Vitest) so one failure doesn't hide the rest. - 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-expects — Flags 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-expects — Vitest equivalent: enforces a maximum number of expect() assertions per test, catching tests that pack in too many checks.
- sonar java:S5961 — SonarSource '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.