Что такое модульное тестирование?
Модульное тестирование (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):
add(2, 3)→5— базовые положительные числаadd(0, 0)→0— нулевые значенияadd(-3, 5)→2— отрицательное и положительноеadd(-3, -7)→-10— оба отрицательныеadd(0.1, 0.2)→0.3— десятичная точность (осторожно с плавающей точкой!)add(999999999, 1)→1000000000— большие числаadd(MAX_INT, 1)→ проверка поведения при переполнении
Тесты для divide(a, b):
divide(10, 2)→5— базовое делениеdivide(10, 3)→3.333...— нецелый результатdivide(0, 5)→0— нулевой числительdivide(10, 0)→ выбрасываетDivisionByZeroError— деление на нольdivide(-10, 2)→-5— отрицательный числительdivide(10, -2)→-5— отрицательный знаменательdivide(-10, -2)→5— оба отрицательныеdivide(1, 3)→0.333...— проверка десятичной точности
Тесты для percentage(value, percent):
percentage(200, 50)→100— базовый процентpercentage(100, 0)→0— ноль процентов (граница)percentage(100, 100)→100— 100 процентов (граница)percentage(100, -1)→ выбрасываетInvalidPercentageError— ниже диапазонаpercentage(100, 101)→ выбрасываетInvalidPercentageError— выше диапазонаpercentage(0, 50)→0— нулевое значениеpercentage(99.99, 33.33)→33.326667— десятичная точность
Тесты для compound(principal, rate, years):
compound(1000, 0.05, 10)→1628.89— базовый сложный процентcompound(1000, 0, 10)→1000— нулевая ставкаcompound(1000, 0.05, 0)→1000— ноль летcompound(0, 0.05, 10)→0— нулевой начальный капиталcompound(-1, 0.05, 10)→ выбрасываетInvalidArgumentError— отрицательный капиталcompound(1000, -0.01, 10)→ выбрасываетInvalidArgumentError— отрицательная ставкаcompound(1000, 0.05, -1)→ выбрасываетInvalidArgumentError— отрицательные годы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) сохраняет тесты чистыми и читаемыми