Почему "сначала тесты" — это не мазохизм, а суперсила
Представьте: вы пишете код, всё работает. Вы счастливы. Через месяц возвращаетесь к проекту, меняете одну строчку — и всё ломается. Знакомо? Добро пожаловать в клуб разработчиков, которые не пишут тесты. А теперь представьте другую реальность: вы меняете код, запускаете тесты — и через 10 секунд знаете, что ничего не сломалось. Это не магия, это TDD (Test-Driven Development) — разработка через тестирование.
В этой статье мы разберём TDD на примере C#. Без скучной теории. Только практика, код и реальные кейсы. Если вы думаете, что TDD — это для "чистых архитекторов" или "энтерпрайз-монстров", — вы удивитесь. Это инструмент, который спасёт ваш код и нервы.
Что такое TDD и как работает Red-Green-Refactor
TDD — это не про тестирование. Это про проектирование. Вы не пишете тесты после кода, а наоборот: сначала тест, потом минимальный код, который его проходит, и только потом рефакторинг. Этот цикл называется Red-Green-Refactor:
- Red — пишем тест, который заведомо не пройдёт (красный).
- Green — пишем минимальный код, чтобы тест прошёл (зелёный).
- Refactor — улучшаем код, не меняя его поведение.
Кажется странным? Давайте на примере. Допустим, нам нужен калькулятор с методом сложения. Обычно мы пишем класс, потом тест. В TDD — наоборот.
Шаг 1: Red — пишем тест, который упадёт
using NUnit.Framework;
[TestFixture]
public class CalculatorTests
{
[Test]
public void Add_ShouldReturnSumOfTwoNumbers()
{
// Arrange
var calculator = new Calculator();
// Act
var result = calculator.Add(2, 3);
// Assert
Assert.AreEqual(5, result);
}
}
Запускаем — тест падает, потому что класса Calculator не существует. Красный — это нормально. Теперь наша задача — сделать зелёный.
Шаг 2: Green — минимум кода, чтобы тест прошёл
public class Calculator
{
public int Add(int a, int b)
{
return a + b; // Минимальная реализация
}
}
Тест зелёный. Ура. Но код пока уродливый. Переходим к шагу 3.
Шаг 3: Refactor — улучшаем, но не ломаем
На этом этапе мы можем добавить проверки, обработку исключений или изменить архитектуру. Главное — тесты должны оставаться зелёными.
public class Calculator
{
public int Add(int a, int b)
{
if (a < 0 || b < 0)
throw new ArgumentException("Only positive numbers allowed");
return a + b;
}
}
Тест упадёт? Да. Значит, нужно обновить тест или изменить логику. В TDD рефакторинг — это всегда диалог с тестами.
Как выбрать фреймворк для TDD в C#: NUnit vs xUnit
В C# есть три популярных фреймворка: NUnit, xUnit и MSTest. Мы рассмотрим первые два, так как они самые гибкие.
| Критерий | NUnit | xUnit |
|---|---|---|
| Атрибуты | [Test], [SetUp] | [Fact], [Theory] |
| Параметризация | [TestCase] | [InlineData] |
| Изоляция тестов | Новый экземпляр класса на каждый тест | Новый экземпляр класса на каждый тест |
Лично я рекомендую xUnit для новых проектов — он чище и лучше интегрируется с .NET Core. Но если команда привыкла к NUnit — не переживайте, оба фреймворка отлично поддерживают TDD.
Реальный пример: пишем систему заказов через TDD
Представьте, что мы разрабатываем интернет-магазин. Нужен класс OrderService, который рассчитывает скидку. Правило: если сумма заказа больше 1000 рублей — скидка 10%. Давайте пройдём TDD-цикл.
Итерация 1: базовая скидка
Пишем тест:
[Fact]
public void CalculateDiscount_WhenOrderTotalAbove1000_ShouldReturn10Percent()
{
var service = new OrderService();
var result = service.CalculateDiscount(1500);
Assert.Equal(150, result);
}
Тест красный. Пишем минимальный код:
public class OrderService
{
public decimal CalculateDiscount(decimal total)
{
if (total > 1000)
return total * 0.1m;
return 0;
}
}
Зелёный. Пока всё просто.
Итерация 2: граничные условия
TDD требует тестировать границы. Что, если сумма ровно 1000? По логике — скидки нет. Пишем тест:
[Fact]
public void CalculateDiscount_WhenOrderTotalIs1000_ShouldReturnZero()
{
var service = new OrderService();
var result = service.CalculateDiscount(1000);
Assert.Equal(0, result);
}
Код уже проходит — зелёный. Но мы не останавливаемся.
Итерация 3: отрицательная сумма
Что, если кто-то передаст отрицательную сумму? TDD заставляет думать о краевых случаях заранее.
[Fact]
public void CalculateDiscount_WhenNegativeTotal_ShouldThrowException()
{
var service = new OrderService();
Assert.Throws<ArgumentException>(() => service.CalculateDiscount(-100));
}
Красный. Добавляем проверку:
public decimal CalculateDiscount(decimal total)
{
if (total < 0)
throw new ArgumentException("Total cannot be negative");
if (