Почему TDD — это не про «написать тесты», а про «думать иначе»
Представьте, что вы строите дом. У вас есть чертежи, материалы и бригада. Но вы начинаете класть кирпичи, не проверив фундамент. Через месяц выясняется, что стены пошли трещинами. Знакомо? В программировании это называется «потом исправим». И «потом» обычно наступает в 3 часа ночи перед деплоем.
Разработка через тестирование (TDD — Test-Driven Development) — это техника, которая переворачивает процесс с ног на голову. Вы не пишете код, а потом тесты. Вы сначала пишете тест — и только потом код, который этот тест проходит. Звучит безумно? На практике это экономит часы, нервы и делает код чище, чем утренний воздух в горах.
В этой статье мы разберем TDD в Java на конкретных примерах. Никакой воды — только код, цикл Red-Green-Refactor и реальные сценарии. Поехали.
Что такое TDD и как работает цикл Red-Green-Refactor
TDD базируется на трёх простых шагах, которые повторяются циклически:
- Red — пишем тест, который не проходит (он должен упасть).
- Green — пишем минимальный код, чтобы тест прошёл.
- Refactor — улучшаем код, не меняя его поведения. Тесты при этом остаются зелёными.
Кажется, что это замедляет разработку. На самом деле — ускоряет. Вы перестаёте гадать, «а работает ли это?». Вы знаете: работает, потому что тест зелёный.
Главные принципы TDD
- Не пиши ни строчки продакшн-кода без теста.
- Тест должен быть максимально простым и проверять ровно одно поведение.
- Код пишется ровно настолько, чтобы тест прошёл. Никаких «а давай ещё и это на всякий случай».
Теперь давайте перейдём к Java и JUnit 5 — самому популярному фреймворку для модульного тестирования.
Настройка проекта: Maven, JUnit 5 и структура
Для примеров будем использовать Maven. Добавьте в pom.xml зависимость:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
Структура папок стандартная для Maven:
src/
main/java/com/example/
test/java/com/example/
Все тестовые классы кладём в src/test/java. Имя тестового класса обычно совпадает с именем тестируемого класса + суффикс Test. Например, Calculator → CalculatorTest.
Первый TDD-цикл: пишем калькулятор с нуля
Задача: создать метод add(int a, int b), который возвращает сумму. По TDD мы начинаем не с класса, а с теста.
Шаг 1: Red — пишем тест, который упадёт
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class CalculatorTest {
@Test
void shouldAddTwoNumbers() {
Calculator calc = new Calculator();
int result = calc.add(2, 3);
assertEquals(5, result);
}
}
Пытаемся запустить — компилятор ругается: класса Calculator не существует. Отлично, красный свет горит.
Шаг 2: Green — пишем минимальный код
Создаём класс Calculator с методом, который просто возвращает сумму:
public class Calculator {
public int add(int a, int b) {
return a + b;
}
}
Запускаем тест — зелёный. Минимально возможная реализация сработала.
Шаг 3: Refactor
На этом этапе код уже простой, рефакторить нечего. Но представим, что мы хотим переименовать метод или добавить проверку на переполнение. Мы делаем это, и тест всё ещё проходит. Если что-то сломается — тест упадёт и сразу укажет на проблему.
Сложнее: TDD для сервиса с зависимостями (Mockito)
В реальных проектах код редко бывает изолирован. Часто есть база данных, внешние API или другие сервисы. TDD отлично работает и здесь — с помощью моков.
Представим, что у нас есть сервис UserService, который сохраняет пользователя и отправляет приветственное письмо. Мы не хотим реально отправлять письма в тестах.
Шаг 1: Red — тест для сохранения пользователя
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
@Mock
private EmailService emailService;
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
void shouldSaveUserAndSendEmail() {
User user = new User("test@example.com");
when(userRepository.save(user)).thenReturn(user);
User saved = userService.registerUser(user);
assertEquals(user, saved);
verify(emailService).sendWelcomeEmail(user.getEmail());
}
}
Тест не скомпилируется — классов UserService, EmailService и UserRepository нет. Красный.
Шаг 2: Green — минимальная реализация
Создаём интерфейсы и классы. Важно: не пишем логику отправки писем, только то, что нужно для прохождения теста.
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
public UserService(UserRepository userRepository, EmailService emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
public User registerUser(User user) {
User saved = userRepository.save(user);
emailService.sendWelcomeEmail(saved.getEmail());
return saved;
}
}
Тест зелёный. Мы не писали реальную отправку писем — это сделаем позже, когда дойдёт очередь до EmailServiceImpl. TDD заставляет нас фокусироваться на текущей задаче, а не на всех сразу.
Рефакторинг: как не бояться менять код
Самое страшное в рефакторинге — сломать что-то, что работало. TDD даёт суперсилу: вы можете переписывать внутренности метода, не боясь, что внешнее поведение изменится. Тесты — ваша страховка.
Пример: мы хотим добавить кэширование результатов в Calculator. Пишем новый тест, потом реализацию. Старые тесты всё ещё про