🎯 Kotlin

Code-Testen in Kotlin: Eine ausführliche Anleitung

Что это такое и зачем? (аналогия из реальной жизни)

Представь, что ты написал диктант. Ты уверен, что всё правильно, но учитель всё равно проверяет твою работу красной ручкой. Зачем? Потому что даже самый внимательный человек может ошибиться: пропустить запятую, неправильно написать окончание или перепутать букву. Тестирование кода — это та самая «проверка красной ручкой», только для программ.

Давай представим другую ситуацию. Ты — шеф-повар в ресторане. Ты придумал новый рецепт пиццы. Прежде чем подать её гостям, ты обязательно попробуешь сам: достаточно ли соли, не пережарено ли тесто, хорошо ли плавится сыр. Если ты этого не сделаешь, гости могут остаться недовольны, а ресторан потеряет репутацию. Тесты в программировании — это точно такая же «дегустация». Ты проверяешь, работает ли каждая функция так, как задумано, прежде чем отдать программу пользователям.

Зачем вообще тратить время на тесты? Вот три главные причины:

  • Экономия времени в будущем. Ошибка, найденная на этапе написания кода, исправляется за 5 минут. Ошибка, найденная через месяц после выхода программы, может потребовать недели работы.
  • Уверенность в изменениях. Когда ты захочешь улучшить программу (добавить новую кнопку или изменить расчёт), тесты скажут тебе: «Всё хорошо, старые функции не сломались» или «Стоп, ты что-то сломал!».
  • Документация. Тесты — это живая инструкция к твоему коду. Читая тесты, другой программист (или ты сам через месяц) сразу поймёт, как должна работать каждая часть программы.
Запомни золотое правило: «Программа без тестов — это программа, которая однажды перестанет работать, и ты узнаешь об этом от пользователя, а не от автоматической проверки».

Теория: как это работает изнутри

Теперь давай заглянем «под капот» и разберёмся, какие виды тестов существуют и как они устроены. В мире программирования тесты делятся на несколько уровней. Представь, что мы тестируем автомобиль:

  • Юнит-тесты (Unit tests) — это проверка одного маленького винтика. Мы берём отдельную деталь (например, тормозной цилиндр) и проверяем, работает ли она правильно сама по себе. В коде это проверка одной функции или метода.
  • Интеграционные тесты (Integration tests) — это проверка того, как детали работают вместе. Мы проверяем, правильно ли тормозной цилиндр соединяется с тормозной колодкой и педалью. В коде это проверка взаимодействия нескольких классов или модулей.
  • Функциональные тесты (Functional tests) — это проверка всей машины целиком. Мы садимся за руль и проверяем, едет ли машина, поворачивает ли руль, загораются ли фары. В коде это проверка полного сценария работы программы.

В этой статье мы сосредоточимся на юнит-тестах, потому что это основа основ. Именно с них начинают учиться тестированию.

Как устроен юнит-тест внутри? У него есть три части, которые называют «AAA» (Arrange, Act, Assert — Подготовка, Действие, Проверка):

  1. Arrange (Подготовка). Мы создаём условия для теста: подготавливаем данные, создаём объекты. Например: «Создаём калькулятор, задаём числа 5 и 3».
  2. Act (Действие). Мы выполняем то, что хотим проверить. Например: «Вызываем функцию сложения с числами 5 и 3».
  3. Assert (Проверка). Мы проверяем, что результат совпадает с ожидаемым. Например: «Проверяем, что результат равен 8».

В Kotlin для тестирования используется специальная библиотека (набор готовых инструментов) под названием JUnit. Это как набор инструментов для автомеханика: есть специальные ключи, отвёртки и тестеры. JUnit даёт нам команды для написания и запуска тестов.

Синтаксис в Kotlin: все формы и варианты

Давай разберём, как писать тесты на Kotlin. Для начала тебе нужно добавить библиотеку JUnit в свой проект. Если ты используешь Gradle (систему сборки), добавь в файл build.gradle.kts вот такую строчку:

// Подключаем библиотеку JUnit для написания тестов
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")

Теперь рассмотрим основные инструменты, которые даёт нам JUnit:

Аннотация / Команда Что делает Пример использования
@Test Помечает функцию как тестовую. Без этой пометки JUnit не запустит твой тест. @Test fun testAddition() { ... }
assertEquals(ожидание, реальность) Проверяет, что два значения равны. Если не равны — тест падает с ошибкой. assertEquals(8, calculator.add(5, 3))
assertTrue(условие) Проверяет, что условие истинно (true). assertTrue(result > 0)
assertFalse(условие) Проверяет, что условие ложно (false). assertFalse(list.isEmpty())
assertNull(объект) Проверяет, что объект равен null (пусто). assertNull(database.findUser(999))
assertNotNull(объект) Проверяет, что объект не равен null. assertNotNull(database.findUser(1))
@BeforeEach Функция с этой пометкой запускается перед КАЖДЫМ тестом. Удобно для подготовки данных. @BeforeEach fun setUp() { calculator = Calculator() }
@AfterEach Функция с этой пометкой запускается после КАЖДОГО теста. Удобно для очистки данных. @AfterEach fun tearDown() { database.close() }
Важно: имя тестовой функции должно быть говорящим. Вместо test1() пиши testAdditionWithPositiveNumbers(). Через полгода ты скажешь себе спасибо за такое имя.

Примеры кода — от простого к сложному

Теперь перейдём к практике. Мы будем писать тесты для простого класса Calculator. Каждый пример — рабочий, с подробными комментариями.

Пример 1: Простейший тест сложения

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*

// Создаём класс, который будем тестировать
class Calculator {
    // Функция сложения двух чисел
    fun add(a: Int, b: Int): Int {
        return a + b
    }
}

// Класс с тестами. Обычно его называют так же, как тестируемый класс + "Test"
class CalculatorTest {

    @Test
    fun testAddition() {
        // Шаг 1: Подготовка (Arrange)
        val calculator = Calculator()
        val firstNumber = 5
        val secondNumber = 3

        // Шаг 2: Действие (Act)
        val result = calculator.add(firstNumber, secondNumber)

        // Шаг 3: Проверка (Assert)
        assertEquals(8, result, "5 + 3 должно быть равно 8")
        // Если тест пройдёт — зелёная галочка. Если нет — красный крест и сообщение об ошибке.
    }
}

Пример 2: Тест вычитания с отрицательным результатом

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*

class Calculator {
    fun subtract(a: Int, b: Int): Int {
        return a - b
    }
}

class CalculatorTest {

    @Test
    fun testSubtractionWithNegativeResult() {
        val calculator = Calculator()

        // Проверяем, что 3 - 5 = -2
        val result = calculator.subtract(3, 5)

        assertEquals(-2, result, "3 - 5 должно быть равно -2")
        // Дополнительно проверим, что результат отрицательный
        assertTrue(result < 0, "Результат должен быть отрицательным")
    }
}

Пример 3: Тест деления с проверкой исключения

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.assertThrows // Специальная функция для проверки ошибок

class Calculator {
    fun divide(a: Int, b: Int): Int {
        // Если делим на ноль — выбрасываем исключение (ошибку)
        if (b == 0) {
            throw IllegalArgumentException("На ноль делить нельзя!")
        }
        return a / b
    }
}

class CalculatorTest {

    @Test
    fun testDivisionByZero() {
        val calculator = Calculator()

        // Проверяем, что при делении на 0 выбрасывается исключение
        val exception = assertThrows {
            calculator.divide(10, 0)
        }

        // Проверяем текст сообщения об ошибке
        assertEquals("На ноль делить нельзя!", exception.message)
    }

    @Test
    fun testDivisionWithPositiveNumbers() {
        val calculator = Calculator()

        val result = calculator.divide(10, 2)

        assertEquals(5, result, "10 / 2 должно быть равно 5")
    }
}

Пример 4: Тест с использованием @BeforeEach для подготовки

import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*

class Calculator {
    fun multiply(a: Int, b: Int): Int = a * b
    fun power(base: Int, exponent: Int): Int {
        var result = 1
        for (i in 1..exponent) {
            result *= base
        }
        return result
    }
}

class CalculatorTest {
    // Объявляем переменную на уровне класса
    private lateinit var calculator: Calculator

    // Этот метод запускается перед КАЖДЫМ тестом
    @BeforeEach
    fun setUp() {
        calculator = Calculator() // Создаём новый калькулятор для каждого теста
        println("Создан новый калькулятор для теста")
    }

    @Test
    fun testMultiplication() {
        val result = calculator.multiply(4, 5)
        assertEquals(20, result)
    }

    @Test
    fun testPower() {
        val result = calculator.power(2, 3) // 2 в степени 3 = 8
        assertEquals(8, result)
    }
}

Пример 5: Тест для работы со списком строк

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*

class StringListProcessor {
    // Функция, которая возвращает список строк длиннее 3 символов
    fun filterShortStrings(strings: List): List {
        return strings.filter { it.length > 3 }
    }

    // Функция, которая объединяет строки через запятую
    fun joinWithComma(strings: List): String {
        return strings.joinToString(", ")
    }
}

class StringListProcessorTest {

    @Test
    fun testFilterShortStrings() {
        val processor = StringListProcessor()
        val inputList = listOf("кот", "собака", "дом", "автомобиль", "я")

        val result = processor.filterShortStrings(inputList)

        // Ожидаем, что останутся только "собака" и "автомобиль"
        assertEquals(2, result.size, "Должно остаться 2 строки")
        assertTrue(result.contains("собака"))
        assertTrue(result.contains("автомобиль"))
        assertFalse(result.contains("кот")) // "кот" — 3 символа, не подходит
    }

    @Test
    fun testJoinWithComma() {
        val processor = StringListProcessor()
        val inputList = listOf("один", "два", "три")

        val result = processor.joinWithComma(inputList)

        assertEquals("один, два, три", result)
    }
}

Пример 6: Тест для проверки граничных значений

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*

class AgeValidator {
    // Функция проверяет, может ли пользователь зарегистрироваться (возраст от 13 до 120 лет)
    fun isValidAge(age: Int): Boolean {
        return age in 13..120
    }
}

class AgeValidatorTest {

    @Test
    fun testAgeBoundaries() {
        val validator = AgeValidator()

        // Проверяем граничные значения
        assertFalse(validator.isValidAge(12), "12 лет — ещё нельзя")
        assertTrue(validator.isValidAge(13), "13 лет — уже можно")
        assertTrue(validator.isValidAge(120), "120 лет — ещё можно")
        assertFalse(validator.isValidAge(121), "121 год — уже нельзя")

        // Проверяем отрицательный возраст
        assertFalse(validator.isValidAge(-5), "Отрицательный возраст недопустим")
    }
}

Пример 7: Параметризованный тест (много проверок в одном тесте)

import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.api.Assertions.*

class Calculator {
    fun add(a: Int, b: Int): Int = a + b
}

class CalculatorTest {

    // Параметризованный тест — запускает один и тот же тест с разными данными
    @ParameterizedTest
    @CsvSource(
        "1, 2, 3",
        "0, 0, 0",
        "-1, 1, 0",
        "100, 200, 300",
        "-5, -3, -8"
    )
    fun testAdditionWithMultipleValues(a: Int, b: Int, expected: Int) {
        val calculator = Calculator()

        val result = calculator.add(a, b)

        assertEquals(expected, result, "$a + $b должно быть равно $expected")
    }
}

Сравнение с Python: чем похоже и чем отличается

Если ты уже знаком с Python, тебе будет легче понять Kotlin. Давай сравним, как выглядит тестирование на этих двух языках.

Характеристика Kotlin (с JUnit) Python (с unittest или pytest)
Библиотека JUnit 5 (jupiter) unittest (встроенная) или pytest
Объявление теста Аннотация @Test перед функцией Функция должна начинаться с test_ или класс наследует unittest.TestCase
Проверка равенства assertEquals(expected, actual) self.assertEqual(expected, actual) или assert expected == actual
Проверка исключений assertThrows<ExceptionType> { ... } self.assertRaises(ExceptionType): ... или with pytest.raises(...)
Подготовка данных @BeforeEach fun setUp() def setUp(self): (метод класса)
Параметризованные тесты @ParameterizedTest + @CsvSource @pytest.mark.parametrize (в pytest)
Типизация Строгая, все типы известны на этапе компиляции Динамическая, типы проверяются во время выполнения

Главное отличие: Kotlin — статически типизированный язык. Это значит, что многие ошибки (например, передача строки вместо числа) будут найдены ещё до запуска тестов, на этапе компиляции. В Python такие ошибки проявятся только во время выполнения теста. С одной стороны, Kotlin даёт больше безопасности, с другой — требует более строгого описания типов.

Сходство: И там, и там используется одна и та же философия AAA (Arrange-Act-Assert). И там, и там тесты пишутся в отдельных файлах, отдельно от основного кода.

Типичные ошибки и подводные камни

Даже опытные программисты иногда допускают ошибки в тестах. Вот список самых распространённых проблем, с которыми ты столкнёшься:

  1. Тест ничего не проверяет. Самая коварная ошибка. Ты написал тест, он проходит зелёным, но на самом деле он не содержит ни одной команды assert. Например, ты просто вызвал функцию, но не проверил результат. Такой тест бесполезен.
  2. Тест зависит от других тестов. Если тесты должны запускаться в строго определённом порядке — это плохо. Каждый тест должен быть независимым. Используй @BeforeEach, чтобы каждый тест начинался с «чистого листа».
  3. Тестирование слишком многого в одном тесте. Один тест — одна проверка. Если ты проверяешь и сложение, и умножение, и деление в одном тесте, при ошибке ты не поймёшь, что именно сломалось.
  4. Использование случайных данных. Если твой тест использует случайные числа, он может иногда проходить, а иногда нет. Такие тесты называются «flaky» (нестабильные). Всегда используй фиксированные значения.
  5. Игнорирование граничных значений. Программы чаще всего ломаются на границах: при делении на ноль, при пустом списке, при максимальном значении числа. Всегда тестируй эти случаи.
  6. Слишком сложная подготовка данных. Если для теста нужно создать 10 объектов и настроить 15 параметров — это сигнал, что твой код слишком сложный. Упрощай.
  7. Забыл добавить аннотацию @Test. Без неё функция не будет распознана как тест. Она просто молча не запустится, и ты будешь думать, что всё работает.
Золотое правило: «Если тест сложнее, чем сам код — ты что-то делаешь не так».

Лучшие практики и советы профессионалов

Как писать тесты, чтобы они приносили пользу, а не головную боль? Вот советы от разработчиков, которые пишут тесты каждый день:

  1. Пиши тесты ДО того, как написал код. Это называется TDD (Test-Driven Development — разработка через тестирование). Сначала ты пишешь тест, который падает (красный), потом пишешь минимальный код, чтобы тест прошёл (зелёный), потом улучшаешь код (рефакторинг).
  2. Один тест — одна логическая проверка. Если тест называется testAddition, он должен проверять только сложение. Не добавляй туда проверку умножения.
  3. Используй говорящие имена. testAdditionWithNegativeNumbers лучше, чем testAddNeg. Имя должно рассказывать историю: что мы тестируем, с какими данными, какой ожидаем результат.
  4. Тестируй поведение, а не реализацию. Не проверяй, какие внутренние методы были вызваны. Проверяй, что функция вернула правильный результат. Это позволит тебе менять внутреннюю реализацию, не переписывая тесты.
  5. Поддерживай тесты в чистоте. Тесты — это тоже код. Они должны быть такими же читаемыми, как и основной код. Не копируй один и тот же код в каждый тест — выноси общую логику в @BeforeEach или в отдельные функции.
  6. Запускай тесты часто. Идеально — после каждого изменения в коде. В современных средах разработки (IntelliJ IDEA, Android Studio) тесты можно настроить так, чтобы они запускались автоматически при сохранении файла.
  7. Не гонись за 100% покрытием. 100% покрытие тестами — это красивая цифра, но она не гарантирует отсутствие ошибок. Лучше иметь 70% хорошо написанных тестов, чем 100% бесполезных.

Вопросы для самопроверки

Проверь, как ты усвоил материал. Попробуй ответить на вопросы, не подглядывая в ответы.

  1. Вопрос: Что такое юнит-тест и для чего он нужен?
    Ответ: Юнит-тест — это автоматическая проверка одной маленькой части программы (обычно одной функции или метода). Он нужен, чтобы убедиться, что код работает правильно, и чтобы быстро находить ошибки при изменениях.
  2. Вопрос: Какие три шага включает в себя любой тест (принцип AAA)?
    Ответ: Arrange (Подготовка данных), Act (Выполнение действия), Assert (Проверка результата).
  3. Вопрос: Какой аннотацией в Kotlin нужно пометить функцию, чтобы она стала тестом?
    Ответ: @Test из библиотеки org.junit.jupiter.api.Test.
  4. Вопрос: Как проверить, что при делении на ноль выбрасывается исключение?
    Ответ: Использовать функцию assertThrows<IllegalArgumentException> { calculator.divide(10, 0) }.
  5. Вопрос: Для чего используется аннотация @BeforeEach?
    Ответ: Чтобы выполнять определённый код (например, создание объектов) перед каждым тестом. Это гарантирует, что каждый тест начинается с одинакового состояния.
  6. Вопрос: Что такое параметризованный тест?
    Ответ: Это тест, который запускается несколько раз с разными входными данными. В Kotlin для этого используется аннотация @ParameterizedTest и источник данных, например @CsvSource.
  7. Вопрос: Какая главная опасность теста, который не содержит команд assert?
    Ответ: Такой тест всегда будет проходить зелёным, даже если код работает неправильно. Он создаёт ложное чувство уверенности.
  8. Вопрос: Чем отличается тестирование в Kotlin от тестирования в Python?
    Ответ: В Kotlin используется строгая типизация и аннотации (например, @Test), а в Python — динамическая типизация и именование функций (например, test_). Также в Kotlin тесты обычно пишутся в отдельных классах, а в Python — в классах, наследующих unittest.TestCase.
  9. Вопрос: Почему не стоит использовать случайные данные в тестах?
    Ответ: Потому что тест может иногда проходить, а иногда нет (flaky test). Это делает тесты ненадёжными — ты никогда не знаешь, действительно ли код работает или тесту просто «повезло» с данными.
  10. Вопрос: Что такое TDD?
    Ответ: Test-Driven Development (Разработка через тестирование) — методология, при которой сначала пишется тест (который падает), потом пишется минимальный код для прохождения теста, а потом код улучшается (рефакторинг).

Итог и что изучить дальше

Мы проделали большой путь. Ты узнал, что тестирование — это не скучная обязанность, а мощный инструмент, который делает твою жизнь проще. Ты научился писать юнит-тесты на Kotlin с использованием JUnit, освоил основные команды проверки (assertEquals, assertTrue, assertThrows) и узнал, как правильно организовывать тесты.

Запомни главное: хороший программист отличается от отличного не умением писать сложный код, а умением писать код, который легко тестировать и который редко ломается. Тесты — это твоя страховка. Они позволяют тебе смело экспериментировать, улучшать код и не бояться что-то сломать.

Что изучить дальше?

  • Mock-объекты. Когда твоя программа работает с базой данных или интернетом, ты не хочешь тестировать её по-настоящему (это медленно и ненадёжно). Библиотеки вроде MockK позволяют создавать «заглушки» — объекты, которые имитируют реальное поведение.
  • Интеграционные тесты. После того, как ты освоишь юнит-тесты, научись проверять, как разные части программы работают вместе.
  • Покрытие кода (Code Coverage). Инструменты вроде JaCoCo показывают, какой процент твоего кода покрыт тестами. Это полезно, чтобы найти «белые пятна» — участки кода, которые ты забыл протестировать.
  • Continuous Integration (CI). Настрой автоматический запуск тестов при каждом изменении кода на GitHub или GitLab. Сервисы вроде GitHub Actions будут запускать твои тесты каждый раз, когда ты отправляешь новый код.

Помни: путь в тысячу миль начинается с первого шага. Твой первый шаг — написать тест для самой простой функции. Сделай это прямо сейчас, и ты уже никогда не будешь программировать по-старому.