You’ve inherited a codebase that’s been running for years but barely has any tests. Adding unit tests might feel like a chore—especially when the project wasn’t built with testing in mind. Yet even a few simple tests can save hours of debugging later.
In this post, I’ll explain why it’s worth the effort and walk you through a lightweight approach that won’t require rewriting your entire app.
Why Write Tests for Old Code?
First, a few good reasons to start now:
-
Protect key logic. Imagine there’s a complex price or interest-rate calculation buried in a method. A test makes sure future tweaks don’t break it.
-
Speed up debugging. Instead of spinning up a database, SMTP server, or other services, you can run a quick test on just the piece you care about.
-
Refactor in small steps. A full rewrite is risky. By extracting small chunks into testable units, you get immediate benefits—and build confidence for bigger changes down the road.
The Humble Object Approach
You don’t need a perfect architecture to start testing. With the Humble Object pattern, you:
-
Pull out business logic into a standalone class that has no external dependencies.
-
Leave database access, email calls, logging, and other “plumbing” in the original method.
-
Write tests for the new simple class. The rest of your code stays untouched.
A Simple Example
1. The Original Controller
Here’s a controller that mixes calculations with email and database calls. Testing this directly would mean mocking EF Core, DateTime.Now, SMTP, and your logger all at once.
[ApiController]
[Route("[controller]")]
public class LegacyOrderController : ControllerBase
{
private readonly SmtpClient _smtp = new("mail.company.internal");
private readonly ILogger _log;
private readonly DbContext _db;
public LegacyOrderController(DbContext db, ILogger log)
{
_db = db;
_log = log;
}
[HttpPost("create")]
public IActionResult Create(OrderDto dto)
{
var customer = _db.Customers.Find(dto.CustomerId);
var now = DateTime.Now;
decimal subtotal = dto.Lines.Sum(l => l.Price * l.Qty);
// Black Friday discount
if (now.Month == 11 && now.Day >= 25 && now.Day <= 30)
subtotal *= 0.8m;
decimal vat = subtotal * 0.12m;
decimal total = subtotal + vat;
customer.Balance -= total;
_db.SaveChanges();
_smtp.Send(
"sales@corp",
customer.Email,
"Thanks for your order",
$"Total = {total:C}");
_log.Write($"{now:u} Order {dto.Id} for {customer.Id} = {total}");
return Ok(new { id = dto.Id, total });
}
}
2. Extract the Logic
First, define a small interface for getting the current time:
public interface IClock
{
DateTime UtcNow { get; }
}
public class SystemClock : IClock
{
public DateTime UtcNow => DateTime.UtcNow;
}
Tip: If you’re on .NET 8 or later, you can use the built-in
TimeProviderinstead of a customIClock.
Next, move the price calculation into its own class:
public class OrderPricer
{
private readonly IClock _clock;
private const decimal VatRate = 0.12m;
public OrderPricer(IClock clock) => _clock = clock;
public decimal CalculateTotal(IEnumerable<OrderLineDto> lines)
{
decimal subtotal = lines.Sum(l => l.Price * l.Qty);
var now = _clock.UtcNow;
// Apply Black Friday discount
if (now.Month == 11 && now.Day >= 25 && now.Day <= 30)
subtotal *= 0.8m;
decimal vat = subtotal * VatRate;
return subtotal + vat;
}
}
3. Update the Controller
Now the controller just wires things up and handles side effects:
[ApiController]
[Route("[controller]")]
public class LegacyOrderController : ControllerBase
{
private readonly DbContext _db;
private readonly IClock _clock;
private readonly SmtpClient _smtp = new("mail.company.internal");
private readonly ILogger _log;
public LegacyOrderController(
DbContext db,
ILogger log,
IClock? clock = null)
{
_db = db;
_clock = clock ?? new SystemClock();
_log = log;
}
[HttpPost("create")]
public IActionResult Create(OrderDto dto)
{
var customer = _db.Customers.Find(dto.CustomerId);
var pricer = new OrderPricer(_clock);
decimal total = pricer.CalculateTotal(dto.Lines);
customer.Balance -= total;
_db.SaveChanges();
_smtp.Send(
"sales@corp",
customer.Email,
"Thanks for your order",
$"Total = {total:C}");
_log.Write(
$"{_clock.UtcNow:u} Order {dto.Id} for {customer.Id} = {total}");
return Ok(new { id = dto.Id, total });
}
}
4. Write Simple Tests
With the logic extracted, tests become straightforward:
public class FixedClock : IClock
{
public DateTime UtcNow { get; init; }
}
public class OrderPricerTests
{
[Fact]
public void CalculatesTotalWithVat_NoDiscount()
{
var clock = new FixedClock
{
UtcNow = new DateTime(2025, 7, 19)
};
var pricer = new OrderPricer(clock);
var total = pricer.CalculateTotal(new[]
{
new OrderLineDto { Price = 100, Qty = 1 }
});
Assert.Equal(112m, total);
}
[Fact]
public void AppliesBlackFridayDiscount()
{
var clock = new FixedClock
{
UtcNow = new DateTime(2025, 11, 27)
};
var pricer = new OrderPricer(clock);
var total = pricer.CalculateTotal(new[]
{
new OrderLineDto { Price = 100, Qty = 1 }
});
Assert.Equal(100m * 0.8m * 1.12m, total);
}
}
Wrap-Up
You can start testing legacy code today without a massive rewrite. By pulling out just the core logic into small, easily tested classes, you’ll:
-
Gain confidence before making bigger changes.
-
Speed up your feedback loop when debugging.
-
Keep the rest of your code unchanged.
Give this pattern a try on your next bug fix or feature. You might be surprised how quickly you get value from just a handful of tests.