Вам достался codebase, который работает годами, но почти не имеет tests. Добавление unit tests может казаться неприятной рутиной, особенно если project изначально не был рассчитан на testing. Но даже несколько простых tests могут позже сэкономить часы debugging.
В этом посте я объясню, почему это стоит effort, и покажу lightweight approach, который не потребует переписывать все приложение.
Зачем писать tests для старого code?
Несколько причин начать сейчас:
-
Защитить key logic. Представьте, что в method спрятан сложный расчет price или interest-rate. Test гарантирует, что будущие изменения его не сломают.
-
Ускорить debugging. Вместо запуска database, SMTP server или других services можно быстро проверить только нужный кусок.
-
Refactor маленькими шагами. Full rewrite рискован. Вынося небольшие pieces в testable units, вы сразу получаете пользу и наращиваете confidence для больших изменений позже.
Humble Object Approach
Идеальная architecture не нужна, чтобы начать testing. С Humble Object pattern вы:
-
Выносите business logic в standalone class без external dependencies.
-
Оставляете database access, email calls, logging и другую “plumbing” в исходном method.
-
Пишете tests для нового простого class. Остальной code остается untouched.
Простой пример
1. Original Controller
Вот controller, который смешивает calculations с email и database calls. Тестировать его напрямую означало бы одновременно mock-ать EF Core, DateTime.Now, SMTP и logger.
[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
Сначала определим небольшой interface для получения текущего времени:
public interface IClock
{
DateTime UtcNow { get; }
}
public class SystemClock : IClock
{
public DateTime UtcNow => DateTime.UtcNow;
}
Tip: Если вы на .NET 8 или новее, можно использовать встроенный
TimeProviderвместо customIClock.
Затем вынесем price calculation в отдельный 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
Теперь controller только связывает parts и обрабатывает 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. Пишем простые tests
Когда logic вынесена, tests становятся 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
Можно начать тестировать legacy code уже сегодня без massive rewrite. Вынеся только core logic в небольшие, легко тестируемые classes, вы:
-
Получите confidence перед большими изменениями.
-
Ускорите feedback loop при debugging.
-
Оставите остальной code без изменений.
Попробуйте этот pattern при следующем bug fix или feature. Вы можете удивиться, как быстро появляется value даже от нескольких tests.