← Back to all articles

How to Add Unit Tests to a Legacy Project

Published

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:

  1. Pull out business logic into a standalone class that has no external dependencies.

  2. Leave database access, email calls, logging, and other “plumbing” in the original method.

  3. 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 TimeProvider instead of a custom IClock.

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:

  1. Gain confidence before making bigger changes.

  2. Speed up your feedback loop when debugging.

  3. 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.