Почему TDD — это не очередная модная методология, а ваш новый супергеройский плащ
Представьте: вы пишете код, как художник — мазок за мазком, строчка за строчкой. Вы уверены, что всё гениально. Но проходит час, вы запускаете приложение, и оно падает с ошибкой, которую вы искали бы полдня. Знакомо?
А теперь представьте другой сценарий. Вы сначала пишете тест, который гарантированно упадёт. Потом — минимальный код, чтобы тест прошёл. И только потом — рефакторите, улучшаете, но уже с чистой совестью. Это и есть разработка через тестирование (TDD), и в Kotlin она превращается в настоящее удовольствие.
В этой статье мы не будем читать скучные лекции. Мы возьмём реальный пример, пройдём все три стадии TDD (Red — Green — Refactor) и покажем, как писать надёжный код без страха и упрёка. Готовы? Поехали!
TDD: три буквы, которые изменят ваш код
TDD (Test-Driven Development) — это не просто техника, это философия. Суть проста: сначала тест, потом код. Звучит безумно? На первый взгляд — да. На практике — экономит часы отладки.
Цикл разработки через тесты
- Red — пишем тест, который не проходит (он должен упасть).
- Green — пишем минимальный код, чтобы тест прошёл.
- Refactor — улучшаем код, не меняя его поведения.
И так — по кругу, маленькими шагами. Каждый шаг — это уверенность, что вы ничего не сломали. В Kotlin с его лаконичным синтаксисом и нулевой безопасностью TDD становится ещё приятнее.
Инструментарий: что нужно для TDD в Kotlin
Прежде чем погружаться в код, давайте подготовим арсенал. Для юнит-тестирования Kotlin вам понадобятся:
- JUnit 5 — стандарт де-факто для модульных тестов.
- MockK — библиотека для моков, созданная специально для Kotlin (никаких больше проблем с null и companion object).
- Gradle или Maven — для управления зависимостями.
Добавьте в build.gradle.kts:
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
testImplementation("io.mockk:mockk:1.13.8")
}
Готово. Теперь можно писать код, который не стыдно показать коллегам.
Пишем первый TDD-цикл: калькулятор с амбициями
Начнём с классики — калькулятора. Но не простого, а с поддержкой строковых операций. Задача: функция calculate принимает строку вида "2 + 3" и возвращает 5.
Шаг 1: Red — пишем падающий тест
Создаём тестовый класс:
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
class CalculatorTest {
@Test
fun `should add two numbers`() {
val calculator = StringCalculator()
val result = calculator.calculate("2 + 3")
assertEquals(5, result)
}
}
Запускаем — тест падает, потому что класса StringCalculator ещё не существует. Красный сигнал — всё идёт по плану.
Шаг 2: Green — минимальный код для прохождения
Пишем ровно столько, чтобы тест прошёл. Никаких архитектурных изысков:
class StringCalculator {
fun calculate(expression: String): Int {
val parts = expression.split(" + ")
return parts[0].toInt() + parts[1].toInt()
}
}
Теперь тест зелёный. Ура! Но код пока что уродлив — он заточен только под сложение двух чисел.
Шаг 3: Refactor — делаем код красивым
Рефакторим, но не меняем поведение. Добавляем поддержку разных операций, выносим логику в отдельные функции:
class StringCalculator {
fun calculate(expression: String): Int {
val (a, op, b) = expression.split(" ")
return when (op) {
"+" -> a.toInt() + b.toInt()
"-" -> a.toInt() - b.toInt()
"*" -> a.toInt() * b.toInt()
"/" -> a.toInt() / b.toInt()
else -> throw IllegalArgumentException("Unknown operation: $op")
}
}
}
Запускаем тесты снова — они зелёные. Отлично! Но мы ещё не закончили — следующий тест добавит новые требования.
Второй цикл: добавляем обработку ошибок
Теперь представим, что пользователь ввёл "10 / 0". Наш код упадёт с ArithmeticException. Хотим возвращать null при делении на ноль.
Red: пишем тест на деление на ноль
@Test
fun `should return null when divide by zero`() {
val calculator = StringCalculator()
val result = calculator.calculate("10 / 0")
assertEquals(null, result)
}
Тест падает — метод возвращает Int, а не Int?. Меняем сигнатуру.
Green: минимальные изменения
fun calculate(expression: String): Int? {
val (a, op, b) = expression.split(" ")
return when (op) {
"+" -> a.toInt() + b.toInt()
"-" -> a.toInt() - b.toInt()
"*" -> a.toInt() * b.toInt()
"/" -> {
val divisor = b.toInt()
if (divisor == 0) null else a.toInt() / divisor
}
else -> null
}
}
Refactor: улучшаем читаемость
Выносим проверку деления на ноль в отдельную функцию, добавляем обработку некорректного ввода:
fun calculate(expression: String): Int? {
return try {
val (a, op, b) = expression.split(" ")
performOperation(a.toInt(), op, b.toInt())
} catch (e: Exception) {
null
}
}
private fun performOperation(a: Int, op: String, b: Int): Int? {
return when (op) {
"+" -> a + b
"-" -> a - b
"*" -> a * b
"/" -> if (b == 0) null else a / b
else -> null
}
}
Тесты проходят. Код стал чище, а главное — мы уверены, что он работает.
Продвинутый TDD: работа с моками в Kotlin
В реальных проектах код редко бывает изолирован. Часто приходится работать с базой данных, API или файловой системой. Здесь на помощь приходит MockK — библиотека для мокирования в Kotlin.