Should I test that?

Should I change code to make it testable?

Verdict

Yes

Yes, change business logic whose design blocks a test: pass the clock, the database rows and outside services in as parameters or constructor arguments, make no other change, and test the logic in CI.

Why

Yes, change code to make it testable when the code is worth a test and its design blocks one. My typical case is a service function in a web backend that reads the clock or queries the database inline, so a unit test cannot choose its inputs. Blast radius is users and Change frequency is regularly, because customers see the result and the rules change monthly. Detectability is eventually, because wrong logic returns a plausible value. Reversibility is with-effort, since stored results need a repair script. Test cost is moderate: moving the dependency into a parameter and writing the test takes about an hour, so rule R11 gives Test.

When the decision changes
WhenDecisionWhy
The logic that reads the clock or the database inline calculates charges, refunds or invoice totalsTest mandatory: pass the dependencies in, test each amount and boundary, and have a second person review the testsBlast radius rises to money and Reversibility to costly, because a wrong charge ends in refunds
The logic that reads the database inline decides which users may read a recordTest mandatory: pass the data in and test allowed, denied and cross-account requestsBlast radius rises to safety-or-legal and Detectability to never, because a leaked record raises no error
The module changes a few times a yearTest minimally: pass the dependency in and write one test of the main pathChange frequency falls to rarely, so a failure has fewer chances to happen
No seam is possible without days of redesign, such as a 3,000-line class that opens its own database and partner connectionsTest it differently: leave the design, and alert when the numbers the class produces changeTest cost rises to heavy, while Detectability stays eventually and Reversibility with-effort
The hard-to-test code is a data migration that runs onceTest it differently: change nothing for tests; rehearse the migration on a copy of production data and diff the resultChange frequency falls to once, so a test in the suite would never run again
The hard-to-test code is a label or a colour the developer sees on screen right after the changeDo not test or restructure the label or colour; look at the screen after the changeDetectability moves to immediately and Reversibility to trivial, because a revert removes a wrong label

What breaks if you don't test

Code nobody can test stays untested, and every later change ships unchecked. A rule that goes wrong for one kind of record returns a number of the right type, the developer's manual check on seed data passes, and users find the error days later.

What you lose if you over-test

Changing code for tests beyond a seam costs more than it returns. An interface for every class, each with one implementation, doubles the files a reader opens and invites mocks that assert calls users never see. A test-only flag such as if (isTest) makes the suite run a path production never takes.

How to test

  1. Find what the test cannot control: the clock, randomness, the database, or the network.
  2. Pass it in as a parameter or a constructor argument, created for real where the application starts. Microsoft's unit testing guide shows this seam for DateTime.Now; Java has Clock.fixed for tests.
  3. Where the function only needs data, let the caller load the rows, so the rule becomes a pure function.
  4. Test the rule through its public function with fixed inputs in CI.
  5. When the seam needs more than an automated IDE refactoring, first pin today's behaviour with a characterization test, then cut the seam. Martin Fowler's Legacy Seam defines a seam as a place where behaviour changes without editing the code there.

When the answer changes

  • The logic moves money or decides who may see a record.
  • A seam needs days of redesign rather than an hour.
  • The code runs once.

Real incident + Code example

The progress bar that stopped short of 100

On an e-learning platform I worked on, percentComplete() loaded a course's lessons from the database itself, so a test needed a seeded database and nobody wrote one. When optional lessons shipped, it kept dividing by every lesson. Learners in the 40 courses with optional lessons stopped below 100 percent and never received certificates. A customer's training manager reported the wrong percentage 16 days later, and a script issued 1,300 certificates. The fix moved the query to the caller:

import { expect, it } from "vitest";

type Lesson = { id: string; optional: boolean };

// The caller loads the lessons; the rule no longer touches the database
export function percentComplete(lessons: Lesson[], done: Set<string>): number {
  const required = lessons.filter((l) => !l.optional);
  if (required.length === 0) return 100;
  const finished = required.filter((l) => done.has(l.id)).length;
  return Math.floor((finished / required.length) * 100);
}

it("ignores optional lessons", () => {
  const lessons = [
    { id: "a", optional: false },
    { id: "b", optional: false },
    { id: "c", optional: true },
  ];
  expect(percentComplete(lessons, new Set(["a", "b"]))).toBe(100);
});

The version before the fix returns 66 here.

FAQ

Should I change code to make it more testable?

Yes, change code to make it more testable when the change is a seam: a dependency such as the clock or the database passed in instead of created inside.

Should all classes be testable?

No, not every class needs a seam. Classes that hold business rules should be testable in isolation. A screen label and a one-off migration need no seam: look at the label after the change, and rehearse the migration on a copy of production data and diff the result.

Is it bad to add code only for tests?

Adding a parameter or a constructor argument only for tests is acceptable, because production passes the real dependency through the same path. An isTest flag is not acceptable: the suite then checks a path production never takes.

Should I make a private method public to test it?

No, do not make a private method public only to test it; test it through the public method that calls it. When those tests cannot reach its branches, extract it into a class with a public method.