ConstructiCat Logo
CodeBust.
Browse section ▾

Test Code Duplication.

Test Code Duplication is when the same setup, action, or assertion code is copy-pasted across many tests, so a single change forces edits in many places and tests rot into fragile, near-identical copies.

##Signs and Symptoms

You recognize this smell when tests look like they were written with copy-paste rather than reuse:

  • The same fixture/object construction is rebuilt verbatim at the top of test after test.
  • Identical assertion sequences (the same 3–4 expect calls in the same order) recur across tests.
  • New tests are obviously cloned from an old one with a single line tweaked.
  • The same magic literals (IDs, URLs, dates, error strings) appear over and over.
  • A change to one constructor or API signature breaks dozens of tests at once (shotgun surgery).
  • You see near-duplicate tests that differ only in input/expected values — a clear candidate for a parameterized/table test.
test('flight can be cancelled', () => {
  const airport = new Airport('YYC', 'Calgary');                              // duplicated
  const flight  = new Flight('AC123', airport, new Date('2026-06-01T10:00')); // duplicated
  flight.cancel();
  expect(flight.status).toBe('CANCELLED');
});

test('flight can be delayed', () => {
  const airport = new Airport('YYC', 'Calgary');                              // copy-paste
  const flight  = new Flight('AC123', airport, new Date('2026-06-01T10:00')); // copy-paste
  flight.delay(30);
  expect(flight.status).toBe('DELAYED');
});

##Reasons for the Problem

Why it happens

  • Copy-pasting the previous test is the fastest way to write the next one.
  • Tests are treated as a "second-class citizen" — not refactored or held to the same DRY standard as production code.
  • No shared fixtures, Creation Methods, or test-data builders exist, and authors are unfamiliar with parameterized/table-driven tests.

Why it hurts

  • Maintainability: Meszaros ties this smell directly to Fragile Test — when "the same code sequences appear many times in many tests," one production change means editing the same thing in N places. Maintenance cost scales with the number of copies, not with the number of distinct behaviors.
  • Readability: repeated boilerplate buries the one line that actually makes each test different, so readers can't quickly see what is being verified.
  • Reliability: copy-paste invites copy-paste errors, and fixes get applied to one copy but not its siblings, leaving inconsistent, contradictory tests.
  • False confidence: a flawed assertion that was duplicated is now wrong in many places at once, and cloned tests silently drift until they no longer exercise what their names claim.

Caveat — DRY vs DAMP: tests also value being Descriptive And Meaningful Phrases. Don't over-abstract to the point that a reader must chase helpers to understand a test. Extract genuine, intent-revealing duplication; keep the essential, per-test detail visible and local.

##Treatment

Eliminate the incidental duplication while keeping each test's essence obvious:

  1. Extract Test Utility / Creation Methods (Object Mother, Test Data Builder) for repeated object construction, so each test names only the values it cares about.
  2. Use beforeEach / Implicit Setup for context that is genuinely shared and relevant to every test in the block — but avoid hiding state a test depends on (that trades duplication for an Obscure Test).
  3. Extract Custom Assertions / verification helpers for recurring multi-step assertion sequences, ideally verifying one logical condition.
  4. Collapse near-identical tests into Parameterized / table-driven tests (it.each / test.each) so input + expected output pairs live in one table.
  5. Replace duplicated magic literals with named constants or builder defaults.

Before — duplicated construction and three near-identical tests:

test('rejects negative amount', () => {
  expect(() => validateAmount(-1)).toThrow(RangeError);
});
test('rejects zero amount', () => {
  expect(() => validateAmount(0)).toThrow(RangeError);
});
test('rejects NaN amount', () => {
  expect(() => validateAmount(NaN)).toThrow(RangeError);
});

After — a Creation Method removes setup duplication, and a table replaces the clones:

// shared Creation Method: tests state only the overrides that matter
const aFlight = (overrides = {}) =>
  new Flight('AC123', new Airport('YYC', 'Calgary'),
             new Date('2026-06-01T10:00'), overrides);

it.each([-1, 0, NaN])('rejects invalid amount %p', (amount) => {
  expect(() => validateAmount(amount)).toThrow(RangeError);
});

Run a copy-paste detector (below) over your test sources, then refactor the largest/most-repeated blocks first.

##Detected by

  • eslint-sonarjs no-identical-functionsFunctions should not have identical implementations
  • eslint-sonarjs no-duplicate-stringString literals should not be duplicated
  • sonar javascript:S4144Functions should not have identical implementations
  • pmd cpdCopy/Paste Detector (CPD) — duplicated code blocks