Что такое модульное тестирование?

Модульное тестирование (unit testing) — это практика тестирования наименьших тестируемых частей программного приложения — отдельных функций, методов или классов — в полной изоляции от остальной системы. Когда вы тестируете функцию модульным тестом, вы вызываете её с определенными входными данными и проверяете, что она выдает ожидаемый результат — без обращений к базе данных, сетевых запросов, доступа к файловой системе и зависимости от других компонентов.

Рассмотрим функцию расчета стоимости доставки:

function calculateShipping(weight, distance, isExpress):
    if weight <= 0 or distance <= 0:
        throw InvalidArgumentError

    baseCost = weight * 0.5 + distance * 0.1

    if isExpress:
        return baseCost * 1.5

    return baseCost

Модульный тест для этой функции вызвал бы её с известными входными данными и проверил результат:

test "стандартная доставка 2кг, 100км":
    result = calculateShipping(2, 100, false)
    assert result == 11.0    // (2 * 0.5) + (100 * 0.1) = 11.0

test "множитель экспресс-доставки":
    result = calculateShipping(2, 100, true)
    assert result == 16.5    // 11.0 * 1.5 = 16.5

test "отрицательный вес выбрасывает ошибку":
    assertThrows InvalidArgumentError:
        calculateShipping(-1, 100, false)

Эти тесты проверяют поведение функции без запуска веб-сервера, подключения к базе данных или вовлечения любой другой части приложения.

Принципы FIRST

Хорошие модульные тесты следуют принципам FIRST — фреймворку, определяющему, что отличает эффективные модульные тесты от проблемных.

Fast (Быстрые)

Модульные тесты должны выполняться за миллисекунды, не за секунды. Разработчик должен иметь возможность запустить всю suite после каждого изменения кода без колебаний. Если ваш набор из 500 тестов выполняется 10 минут, разработчики перестанут их запускать. Если 3 секунды — будут запускать постоянно.

Что замедляет тесты: Подключения к базе данных, файловый I/O, сетевые вызовы, операторы sleep/wait, сложные процедуры настройки.

Как сохранять тесты быстрыми: Используйте тестовые двойники (моки, стабы) для устранения внешних зависимостей. Тестируйте чистую логику в изоляции.

Independent (Независимые)

Каждый тест должен выполняться самостоятельно, в любом порядке, без зависимости от результата другого теста. Тест A не должен создавать данные, от которых зависит Тест B. Тест C не должен очищать состояние, которое нужно Тесту D.

Плохой паттерн:

test "создать пользователя":
    user = createUser("alice@test.com")
    globalUser = user    // сохраняет состояние для следующего теста

test "обновить email пользователя":
    globalUser.email = "new@test.com"    // зависит от предыдущего теста
    update(globalUser)

Хороший паттерн:

test "создать пользователя":
    user = createUser("alice@test.com")
    assert user.email == "alice@test.com"

test "обновить email пользователя":
    user = createUser("bob@test.com")    // создает свои данные
    user.email = "new@test.com"
    update(user)
    assert user.email == "new@test.com"

Repeatable (Повторяемые)

Запуск одного теста 100 раз должен давать один и тот же результат каждый раз. Без случайности, без зависимости от времени, без привязки к внешним системам.

Если тест проходит в понедельник, но падает во вторник, потому что проверяет сегодняшняя_дата == "понедельник" — он нарушает повторяемость. Если тест иногда падает из-за таймаута сетевого вызова — он нарушает повторяемость.

Self-Validating (Самопроверяющиеся)

Каждый тест должен иметь четкий результат «прошел/упал» без необходимости человеческой интерпретации. Тест утверждает ожидаемый результат, а фреймворк сообщает «прошел» или «упал». Тест, который выводит данные в консоль для человеческого просмотра — не самопроверяющийся.

Timely (Своевременные)

Модульные тесты должны писаться вовремя — в идеале до или одновременно с кодом, который они тестируют, а не через недели или месяцы. В TDD (Test-Driven Development) тесты пишутся первыми, затем пишется код для их прохождения.

Написание тестов с опозданием приводит к нетестируемому коду — функциям с чрезмерными зависимостями, скрытыми побочными эффектами и сильной связанностью.

Тестовые двойники: Моки, Стабы и Фейки

Реальный код редко существует в изоляции. Функция может обращаться к базе данных, отправлять email или запрашивать внешний API. Модульные тесты не могут использовать реальные зависимости (они были бы медленными, ненадежными и дорогими), поэтому мы используем тестовые двойники — объекты-заменители, подставляемые вместо реальных зависимостей во время тестирования.

Стабы (Stubs)

Стаб предоставляет заранее определенные ответы на вызовы методов. Он не проверяет, как его вызвали — просто возвращает то, что вы ему указали.

// Реальная зависимость: PaymentGateway.charge(amount) -> boolean
// Стаб: всегда возвращает true (имитирует успешную оплату)

stubPaymentGateway.charge = () => return true

test "заказ подтверждается при успешной оплате":
    order = new Order(items, stubPaymentGateway)
    order.checkout()
    assert order.status == "confirmed"

Используйте стабы, когда нужно контролировать возвращаемые зависимостью значения, но не важно, как она была вызвана.

Моки (Mocks)

Мок проверяет, что произошли определенные взаимодействия. Он записывает вызовы методов и позволяет утверждать, что тестируемый код вызвал конкретные методы с конкретными аргументами.

mockEmailService = new Mock(EmailService)

test "подтверждение заказа отправляет email":
    order = new Order(items, mockEmailService)
    order.confirm()

    // Проверяем, что мок был вызван правильно
    mockEmailService.verify("sendEmail")
        .wasCalledOnce()
        .withArguments("user@test.com", "Order Confirmed")

Используйте моки, когда проверяете поведение — что код взаимодействовал с зависимостью ожидаемым образом.

Фейки (Fakes)

Фейк — это работающая реализация, которая использует упрощения. In-memory база данных — это фейк: она реализует тот же интерфейс, что и реальная база данных, но хранит данные в памяти вместо диска.

// Реальный: PostgresUserRepository (подключается к PostgreSQL)
// Фейк: InMemoryUserRepository (хранит в HashMap)

fakeRepo = new InMemoryUserRepository()

test "найти пользователя по email":
    fakeRepo.save(new User("alice@test.com"))
    found = fakeRepo.findByEmail("alice@test.com")
    assert found != null
    assert found.email == "alice@test.com"

Используйте фейки, когда нужно реалистичное поведение от зависимости, но невозможно использовать настоящую в тестах.

Когда использовать каждый

Тестовый двойникКогда использоватьЧто проверяет
СтабНужно контролировать возвращаемые значенияНичего (только предоставляет данные)
МокНужно проверить взаимодействияВызовы методов, аргументы, количество вызовов
ФейкНужно реалистичное поведение в памятиНичего (предоставляет реальное поведение)

Основы покрытия кода (Code Coverage)

Покрытие кода измеряет, какая часть исходного кода выполняется при запуске набора тестов. Выражается в процентах:

  • Покрытие строк: Какой процент строк был выполнен?
  • Покрытие ветвей: Какой процент ветвей if/else был пройден?
  • Покрытие функций: Какой процент функций был вызван?

Функция с 10 строками и тестами, выполняющими 8 из них, имеет 80% покрытия строк.

Ловушка 80%: Многие команды устанавливают цель покрытия (часто 80%) и считают её воротами качества. Но покрытие только измеряет, был ли код выполнен, а не был ли он правильно протестирован. Можно достичь 100% покрытия тестами, которые ничего не утверждают:

test "плохой тест с полным покрытием":
    calculateShipping(2, 100, false)    // выполняет код, ничего не утверждает

Этот тест достигает покрытия, но не ловит ни одного бага.

Полезные рекомендации по покрытию:

  • Используйте покрытие для поиска непротестированного кода, а не для доказательства качества
  • Покрытие ветвей ценнее покрытия строк
  • Фокусируйтесь на покрытии критической бизнес-логики, не утилитарного кода
  • 80-90% — разумная цель; 100% обычно не стоит затраченных усилий

Кто пишет модульные тесты?

Модульные тесты пишут разработчики. Это не QA-активность. Разработчик, который написал функцию, лучше всех подготовлен написать её модульные тесты, потому что понимает ожидаемое поведение, граничные случаи и детали реализации.

QA-инженеры должны:

  • Ревьюить качество модульных тестов при code review
  • Выявлять недостающие тестовые сценарии, которые разработчики могут упустить
  • Продвигать адекватное покрытие критической бизнес-логики
  • Понимать отчеты модульных тестов, чтобы знать, где существует риск качества

Упражнение: Напишите сценарии модульных тестов для калькулятора

Рассмотрим модуль калькулятора с четырьмя функциями:

function add(a, b):
    return a + b

function divide(a, b):
    if b == 0:
        throw DivisionByZeroError
    return a / b

function percentage(value, percent):
    if percent < 0 or percent > 100:
        throw InvalidPercentageError
    return value * (percent / 100)

function compound(principal, rate, years):
    if principal < 0 or rate < 0 or years < 0:
        throw InvalidArgumentError
    return principal * (1 + rate) ^ years

Напишите тестовые сценарии (название, вход, ожидаемый выход) для каждой функции. Покройте:

  • Happy path (нормальная работа)
  • Граничные случаи (ноль, пограничные значения)
  • Ошибочные случаи (невалидный ввод)
  • Специальные значения (очень большие числа, десятичные)
ПодсказкаДля каждой функции подумайте: Какой самый простой валидный ввод? Что происходит на границах (0, отрицательные числа, очень большие числа)? Какие входные данные должны вызвать ошибки? Есть ли проблемы точности с десятичной арифметикой?
Решение

Тесты для add(a, b):

  1. add(2, 3)5 — базовые положительные числа
  2. add(0, 0)0 — нулевые значения
  3. add(-3, 5)2 — отрицательное и положительное
  4. add(-3, -7)-10 — оба отрицательные
  5. add(0.1, 0.2)0.3 — десятичная точность (осторожно с плавающей точкой!)
  6. add(999999999, 1)1000000000 — большие числа
  7. add(MAX_INT, 1) → проверка поведения при переполнении

Тесты для divide(a, b):

  1. divide(10, 2)5 — базовое деление
  2. divide(10, 3)3.333... — нецелый результат
  3. divide(0, 5)0 — нулевой числитель
  4. divide(10, 0) → выбрасывает DivisionByZeroError — деление на ноль
  5. divide(-10, 2)-5 — отрицательный числитель
  6. divide(10, -2)-5 — отрицательный знаменатель
  7. divide(-10, -2)5 — оба отрицательные
  8. divide(1, 3)0.333... — проверка десятичной точности

Тесты для percentage(value, percent):

  1. percentage(200, 50)100 — базовый процент
  2. percentage(100, 0)0 — ноль процентов (граница)
  3. percentage(100, 100)100 — 100 процентов (граница)
  4. percentage(100, -1) → выбрасывает InvalidPercentageError — ниже диапазона
  5. percentage(100, 101) → выбрасывает InvalidPercentageError — выше диапазона
  6. percentage(0, 50)0 — нулевое значение
  7. percentage(99.99, 33.33)33.326667 — десятичная точность

Тесты для compound(principal, rate, years):

  1. compound(1000, 0.05, 10)1628.89 — базовый сложный процент
  2. compound(1000, 0, 10)1000 — нулевая ставка
  3. compound(1000, 0.05, 0)1000 — ноль лет
  4. compound(0, 0.05, 10)0 — нулевой начальный капитал
  5. compound(-1, 0.05, 10) → выбрасывает InvalidArgumentError — отрицательный капитал
  6. compound(1000, -0.01, 10) → выбрасывает InvalidArgumentError — отрицательная ставка
  7. compound(1000, 0.05, -1) → выбрасывает InvalidArgumentError — отрицательные годы
  8. compound(1000, 1.0, 30) → очень большое число — проверка без переполнения

Продвинутые паттерны модульного тестирования

Arrange-Act-Assert (AAA)

Структурируйте каждый модульный тест в три четкие фазы:

test "просроченный купон отклоняется":
    // Arrange — подготовка данных и зависимостей
    coupon = new Coupon("SAVE10", expiryDate: "2024-01-01")
    validator = new CouponValidator(clock: fixedClock("2024-06-15"))

    // Act — выполнение тестируемого поведения
    result = validator.validate(coupon)

    // Assert — проверка результата
    assert result.isValid == false
    assert result.reason == "Coupon has expired"

Этот паттерн делает тесты читаемыми и поддерживаемыми. Любой может взглянуть на тест и сразу понять, что настраивается, какое действие выполняется и какой результат ожидается.

Параметризованные тесты

Когда несколько тестовых случаев разделяют одну логику, но различаются только входом/выходом, используйте параметризованные тесты для избежания дублирования:

@parameterized([
    (2, 3, 5),
    (0, 0, 0),
    (-1, 1, 0),
    (100, 200, 300),
])
test "add возвращает сумму двух чисел"(a, b, expected):
    assert add(a, b) == expected

Одно определение теста, несколько наборов данных. Гораздо чище, чем писать четыре отдельные тестовые функции.

Конвенции именования тестов

Хорошие имена тестов описывают сценарий и ожидаемый результат:

  • test_divide_by_zero_throws_error — ясно и конкретно
  • test_expired_coupon_returns_invalid — описывает поведение
  • test_new_user_gets_welcome_email — читается как требование

Плохие имена тестов ничего полезного не сообщают:

  • test1 — бессмысленно
  • testDivide — что именно про деление?
  • testIt — тестировать что?

Профессиональные советы

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

Совет 2: Не тестируйте детали реализации. Если вы рефакторите функцию без изменения поведения, ноль тестов должно сломаться. Если ваши тесты ломаются от переименования внутренней переменной — они тестируют не то.

Совет 3: Используйте тестовые двойники умеренно. Каждый мок — это ложь о реальной системе. Слишком много моков, и ваши тесты проверяют конфигурацию мокирования, а не ваш код.

Ключевые выводы

  • Модульные тесты проверяют отдельные функции в полной изоляции
  • Принципы FIRST (Fast, Independent, Repeatable, Self-validating, Timely) определяют качество
  • Тестовые двойники (стабы, моки, фейки) заменяют реальные зависимости в модульных тестах
  • Покрытие кода измеряет выполнение, не корректность — используйте для поиска пробелов, не для доказательства качества
  • Разработчики пишут модульные тесты; QA ревьюит и выявляет недостающие сценарии
  • Паттерн AAA (Arrange-Act-Assert) сохраняет тесты чистыми и читаемыми