Just for testing

Developer, architect, Ph.D.
I remember the day in that job interview, just before closing the meeting, my then future manager told that he wanted to ask one more question:
Did I know the difference between public, protected and private keywords?
Back then, I gave the textbook answer: The keywords represents the idea of encapsulation. The public modifier makes the member accessible from any class, protected from the same class and subclasses, and private only from the same class. I went on explaining the idea of encapsulation and why it mattered. That seemed to satisfy them - the conversation moved on, and I didn’t think much more about it at the time.
That code review
There it is in front of you: A class that’s elegant, clear, and cohesive. Its interface is minimal, its intent unmistakable. Everything sits neatly in one file, where reading it from top to bottom feels natural. Nothing seems out of place.
And yet, one thing bothers you: it’s hard to test in isolation. It's on the highest level of your infrastructure and has hard dependencies, making it difficult to approach it in isolation. You want to ensure it’s well-covered, but the structure makes it difficult.
You are considering your options:
Don't automate the testing (because it is a high level entity and it's going to be manually tested anyway)
Cover the functionality with E2E tests or high level UI tests
Break it down, extract functionality to separate / testable classes
Write tests for the private (or protected) members directly - as far as your tech-stack allows it
Say you choose the third or the fourth option, and went on for the code review. And your reviewer raises the infamous, difficult question:
"Did you do these changes just for testing?"
Well, what can you say to this? And yes, you surely did it just for testing.
The dilemma
Here’s the tricky part: the class was already good. Its design was simple, its behavior was clear, and the single-file structure helped readability. If you now start breaking it down into smaller components just to make them testable on their own and mockable for the rest, you might actually make it worse.
The resulting abstractions may not be meaningful on their own. They’ll scatter the logic across files and introduce more surface area - more dependencies, more indirection, and more cognitive load. In other words, you’ve improved testability but damaged clarity.
But sometimes, refactoring for testability is the right thing to do. Why? Because testing is not just about coverage — it’s about maintainability.
A class that’s hard to test in isolation is often hard to change safely. If you can’t verify its behavior quickly, every modification becomes risky. Refactoring for testability can help expose hidden dependencies, clarify responsibilities, and make the system easier to evolve. In those cases, improving testability is improving design.
When testability challenges encapsulation
Encapsulation tells us to hide internal details; separating what a class does from how it does it. Testing can challenge that idea by asking for visibility. In principle, we don’t test private members directly because we want to keep the freedom to change them later.
But in practice, the inability to test a private method can be a sign that something more fundamental is wrong - that the class holds multiple responsibilities, or that important business rules are buried too deep.
Testing pressure often reveals where the real design boundaries should have been.
That’s not a violation of encapsulation; it’s a refinement of it.
When refactoring for testability is the right move
Some private members deserve to be surfaced; not for convenience, but because they represent genuine, reusable logic. Refactoring to make them testable can well be the healthiest outcome.
It’s worth doing when:
The private logic expresses domain rules.
A decision-making algorithm, validation rule, or transformation often belongs to its own class. Extracting it not only makes it testable but also clarifies your domain model.
public class OrderService { public decimal CalculateTotal(Order order) { var discount = CalculateDiscount(order); // hard to test return order.Amount - discount; } // Private domain rule, hidden and untestable private decimal CalculateDiscount(Order order) { if (order.IsVip && order.Amount > 1000) return order.Amount * 0.10m; return 0m; } }Whereas the discount calculation can easily be extracted as:
public class VipDiscountRule : IDiscountRule { public decimal Apply(Order order) { ... } } public class OrderService(IDiscountRule discountRule) { public decimal CalculateTotal(Order order) => order.Amount - discountRule.Apply(order); }Now the domain rules are made independent of the service that uses them.
The logic has independent meaning.
If the private method can be described clearly in one sentence — “this calculates the discount rate,” “this maps configuration values” — then it’s already conceptually distinct enough to stand on its own.
public class SettingsLoader { public AppSettings Load(string json) { var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(json); // Private transformation logic var config = MapSettings(dict); return config; } private AppSettings MapSettings(Dictionary<string, string> dict) { return new AppSettings { ApiUrl = dict["apiUrl"], Timeout = int.Parse(dict["timeout"]), Region = dict["region"] }; } }public class SettingsMapper : ISettingsMapper { public AppSettings Map(Dictionary<string, string> source) { ... } } public class SettingsLoader(ISettingsMapper mapper) { public AppSettings Load(string json) => mapper.Map(JsonSerializer.Deserialize<Dictionary<string, string>>(json)); }Testing through the public API obscures intent.
If you need a maze of setup code or broad integration tests just to hit a particular branch, you’re not protecting encapsulation, you’re protecting opacity.
public class PaymentProcessor { public PaymentResult Process(PaymentRequest request) { // many steps... var risk = CalculateRisk(request.UserId, request.Amount); // private & hidden // ... } private RiskLevel CalculateRisk(Guid userId, decimal amount) { if (amount > 5000) return RiskLevel.High; if (amount > 1000) return RiskLevel.Medium; return RiskLevel.Low; } }Tests require huge setup just to hit
CalculateRisk(). Whereas this method, representing a part of your business rules, should be extracted for clarity and testability:public class AmountBasedRiskEvaluator : IRiskEvaluator { public RiskLevel Evaluate(decimal amount) // Previously the CalculatedRisk() { ... } } public class PaymentProcessor(IRiskEvaluator riskEvaluator) { public PaymentResult Process(PaymentRequest request) { var risk = riskEvaluator.Evaluate(request.Amount); // ... } }Refactoring exposes clearer dependencies.
When pulling private logic out forces you to think about what inputs it needs, that’s design improvement, not test-driven damage.
public class ReportGenerator { public string Generate(int userId) { var data = LoadUserData(); // private, unclear dependencies return BuildReport(data); } private UserData LoadUserData() { // uses global state, static classes, etc. return Database.GetUserData(Environment.CurrentUserId); } }After (dependencies become explicit):
public class DatabaseUserDataProvider : IUserDataProvider { public UserData Get(int userId) { return Database.GetUserData(userId); } } public class ReportGenerator(IUserDataProvider userDataProvider) { public string Generate(int userId) { var data = userDataProvider.Get(userId); return BuildReport(data); } }It’s clear now that the ReportGenerator has a DB dependency.
In these moments, refactoring “for testing” is really refactoring for maintainability. The tests just reveal what your architecture was already hinting at: that some logic deserves its own boundary.
When to keep it private
Of course, not every private method deserves to be exposed or refactored. Sometimes, the effort introduces more complexity than value. Simple orchestration, glue code, or mechanical steps that are already validated by higher-level tests are examples to this. Extracting those pieces for testing alone may overcomplicate things.
Encapsulation shouldn’t be a barrier to understanding. The goal isn’t to keep things private - it’s to keep them purposeful.
Coming back to encapsulation
That interview question wasn’t just about syntax. Over time I learned that encapsulation isn’t only about restricting access, but it’s about protecting meaning. A class should be understandable as a whole. Sometimes that means keeping things private; other times it means exposing just enough for tests and future maintainers to work safely.
When someone asks, “Did you do it just for testing?”, the honest answer might be,
“Yes, but not only for testing. I did it so that this code can keep living, safely and clearly, for years.”

