Introduction
Legacy code is not necessarily bad code. It may contain valuable business rules that have evolved over many years, but those rules can be difficult to change when dependencies are tightly coupled and tests require real infrastructure.
The safest way to improve testability is often to make small structural changes around existing behavior instead of attempting a complete rewrite.
1. Identify hidden dependencies
Consider a class that directly reads the system clock:
public class SubscriptionService
{
public bool IsExpired(DateTime expiryDate)
{
return DateTime.UtcNow > expiryDate;
}
}
This code is simple, but its result depends on the current time. Tests can become awkward around expiration boundaries.
One approach is to inject a time provider:
public class SubscriptionService(TimeProvider timeProvider)
{
public bool IsExpired(DateTimeOffset expiryDate)
{
return timeProvider.GetUtcNow() > expiryDate;
}
}
In production, register the system provider:
services.AddSingleton(TimeProvider.System);
In tests, use a controllable test implementation or a small fake that returns a fixed time.
This makes boundary conditions reproducible. The example also uses DateTimeOffset, which makes the time zone offset explicit.
2. Introduce interfaces where they create a useful boundary
Suppose a service sends notifications directly through a third-party SDK. Testing its business logic may then require network access or a complicated SDK setup.
Introduce a small interface around the behavior the service needs:
public interface INotificationSender
{
Task SendAsync(
string recipient,
string message,
CancellationToken cancellationToken);
}
The service can depend on this abstraction rather than a concrete external client. Production uses an adapter that calls the SDK, while tests use a fake implementation.
Do not create interfaces automatically for every class. An abstraction is most useful when it isolates a volatile dependency, external system, or behavior that needs independent testing.
3. Replace static access with explicit dependencies
Legacy code often relies on global configuration, static helpers, or service locators. These make dependencies difficult to see.
Move external dependencies into constructors where practical. A class that requires a repository, clock, or notification service should make those requirements visible.
Explicit dependencies improve readability and allow tests to supply controlled implementations.
For particularly large legacy classes, introduce the smallest useful seam first. You do not need to redesign the entire application before testing one important business rule.
4. Characterize existing behavior before changing it
When the intended behavior is unclear, write characterization tests that capture what the system currently does.
For example, tests might document how a discount is calculated for zero items, negative quantities, or a customer with a special pricing rule.
These tests are not automatically proof that current behavior is correct. They establish a safety net so you can distinguish an intentional behavior change from an accidental regression.
Once the business owner confirms the expected behavior, update the tests to reflect the desired rules.
5. Test business logic separately from infrastructure
Unit tests should cover decisions and rules without requiring a database or network. Integration tests should cover the real boundaries that mocks cannot fully validate.
A practical test strategy includes:
- Unit tests for calculations and validation
- Service tests using controlled dependencies
- Integration tests for persistence and transactions
- Contract tests for important external integrations
- End-to-end tests for a small number of critical workflows
Avoid mocking every dependency in every test. Excessive mocks can reproduce your assumptions rather than the actual behavior of the system.
6. Refactor in small steps
A safe sequence is to identify a business rule, add tests around its current behavior, introduce one dependency boundary, and verify that the tests still pass.
Then make the desired change and repeat the process. Small commits make failures easier to diagnose and rollbacks easier to perform.
Conclusion
Making legacy C# code testable does not require a full rewrite or an elaborate architecture. Start by exposing hidden dependencies, isolating external systems, and documenting important behavior with tests.
The objective is to make future changes safer while preserving the business knowledge already embedded in the application.