Improve testability by extracting hard to test code
If you struggle to test a private method, change the code. Free your functions and let your tests work for you.
Description
Section titled “Description”One of the biggest blockers to writing solid tests is the way code is structured. Logic hidden inside private methods is hard to test directly. You can test it indirectly through higher-level APIs, or you can try to hack around access restrictions. Hacking your way around is never a good idea. The common mantra “test the interface, not the implementation” reflects this: internal details are not meant to be the focus of tests.
But when you feel need to verify a change inside the private code itself, and it is not easy through the public methods, that is a signal to stop and think. If a part of your ‘implementation details’ is important enough to test, it is important enough to make it easily testable.
Don’t over think it: Just extract it. Extract complex ‘private code’ into standalone functions, or new classes if they need state, with clear public interfaces. This gives tests direct access to the code you want to test and isolates the complexity from the original context. As a bonus, it will become more reusable.
Common Concerns
Section titled “Common Concerns”Some developers worry this creates fragile tests tied to low-level details, tests that break during refactoring. But does this concern hold up in practice?
When you extract hard-to-test code into isolated functions, you create better-decoupled, more reusable building blocks. This fundamentally changes how refactoring affects your tests. Consider three scenarios:
-
The isolated code survives refactoring unchanged Because the extracted function is well-encapsulated with a clear responsibility, surrounding refactorings often don’t affect it at all. The improved isolation makes it reusable across different contexts, so both the code and its tests remain stable.
-
The refactoring makes the isolated code obsolete Sometimes you’ll replace or remove the extracted function entirely. In this case, deleting those unit tests is straightforward—no complex untangling required. This is actually a win: you can cleanly remove tests that no longer serve a purpose.
-
The refactoring requires reworking the isolated code Yes, sometimes the tests need adaptation too. But as Lukas Atkinson noted: This isn’t fragility, this is a pre-flight check list 1: The existing tests document known edge cases and guarantees. They become a valuable foundation for the new tests, ensuring you’ve thought through those same concerns in the refactored structure.
The lower-level tests are focused on proving that the building blocks are correct and robust. The direct access to the lower level code enables you to test many different scenarios, using many different inputs, without jumping through hoops of indirect access through a higher level interface. This does not replace higher-level tests. It complements them. When integration tests fail, you know the building blocks are sound, making it easier to find the real problem.
See also:
Section titled “See also:”Footnotes
Section titled “Footnotes”-
thewhiteboard.github.io: Simpler Tests through “Extract Method” Refactoring ↩