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
- Blast radiususers
- Change frequencyregularly
- Detectabilityeventually
- Reversibilitywith-effort
- Test costmoderate
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 | Decision | Why |
|---|---|---|
| The logic that reads the clock or the database inline calculates charges, refunds or invoice totals | Test mandatory: pass the dependencies in, test each amount and boundary, and have a second person review the tests | Blast 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 record | Test mandatory: pass the data in and test allowed, denied and cross-account requests | Blast radius rises to safety-or-legal and Detectability to never, because a leaked record raises no error |
| The module changes a few times a year | Test minimally: pass the dependency in and write one test of the main path | Change 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 connections | Test it differently: leave the design, and alert when the numbers the class produces change | Test cost rises to heavy, while Detectability stays eventually and Reversibility with-effort |
| The hard-to-test code is a data migration that runs once | Test it differently: change nothing for tests; rehearse the migration on a copy of production data and diff the result | Change 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 change | Do not test or restructure the label or colour; look at the screen after the change | Detectability 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
- Find what the test cannot control: the clock, randomness, the database, or the network.
- 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 hasClock.fixedfor tests. - Where the function only needs data, let the caller load the rows, so the rule becomes a pure function.
- Test the rule through its public function with fixed inputs in CI.
- 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.
Related questions
- Should I write tests before refactoring?Yes
- Should I add tests to legacy code?Yes
- Should I test private methods?No
- Should I use mocks in unit tests?Code under test: Yes
- Should I use TDD?Code under test: Yes
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
isTestflag 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.