Характеристики хороших unit-тестов
В этой статье разбираются характеристики хороших unit-тестов с примерами вопросов к экзаменам, ключевыми компонентами и практическими рекомендациями.
Хорошие unit-тесты — это подушка безопасности любого программного обеспечения. Они помогают быстро заметить, когда изменение что-то сломало, и дают уверенность переделывать код без страха перед регрессией. На экзаменах и в повседневной работе ожидается, что ты не только пишешь тесты, но и можешь оценить, что делает unit-тест качественным.
В двух словах
Хорошие unit-тесты корректны, изолированы, быстры, информативны, удобны в поддержке и просто запускаются. Они дают быструю надёжную обратную связь, точно локализуют ошибки и позволяют безопасно рефакторить код.
Определение
Unit-тесты проверяют наименьшую тестируемую единицу программы, обычно отдельный метод или класс. Чтобы выполнять свою задачу, они должны иметь несколько характеристик:
1. Корректность
Тест должен проверять именно то поведение, которое он документирует. Неправильные предположения или неточные ожидания делают тест бесполезным. Корректный тест одновременно служит спецификацией поведения модуля.
2. Изоляция
Unit-тест не должен зависеть от внешних систем вроде баз данных, сетей, файловых систем или API. Такие зависимости заменяются на тестовые двойники вроде stubs, mocks, fakes или spies. Изолированные тесты работают стабильно и быстро.
3. Быстрота
Хороший unit-тест выполняется за миллисекунды. Это позволяет запускать сотни или тысячи тестов несколько раз в день без перерывов в разработке. Медленные тесты быстро игнорируют или удаляют из CI-pipeline.
4. Информативность
Имя теста, его структура и сообщение об ошибке должны быть понятны с первого взгляда. Чёткая AAA-структура и точные assertions помогают сразу понять, что пошло не так при падении теста.
5. Удобство в поддержке
Тесты должны быть структурированы так же аккуратно, как production-код. Отсутствие дублирования, минимум сложности, общие вспомогательные библиотеки и ясные зависимости делают тесты долгоживущими. Хорошие тесты выживают рефакторинги без изменений.
6. Простота запуска
Тесты должны запускаться одной командой. Они не нуждаются в ручной подготовке, специальном окружении или временных зависимостях. Результаты должны быть детерминированными: одна и та же входная информация даёт одинаковый результат.
Вопросы к экзаменам
- AAA (Arrange, Act, Assert): Каждый unit-тест состоит из трёх частей: подготовка данных, вызов тестируемого метода и проверка результата.
- Тестовые двойники: Stub предоставляет заранее определённые ответы, mock проверяет взаимодействия, fake является упрощённой реализацией, spy логирует вызовы.
- Именование: Имя типа
methodName_condition_expectedResultсразу показывает ожидаемое поведение. - Граничные случаи и ошибки: Нужно тестировать не только happy path, но и null-значения, пустые входные данные, граничные значения и исключения.
- Отсутствие магических чисел: Константы и переменные с говорящими названиями делают тесты понятнее и проще в поддержке.
- Информативные assertions: Assertions должны выдавать понятное сообщение об ошибке, например
assertEquals(expected, actual, "Новый клиент не должен получать скидку"). - Независимость: Тесты не должны зависеть друг от друга. Порядок выполнения не должен иметь значения.
- Детерминизм: Тест должен давать одинаковый результат при каждом запуске. Случайность, время или внешние состояния в unit-тестах недопустимы.
- Один тест, одна ответственность: Каждый тест должен проверять ровно одно поведение. Несколько assertions допускаются, но должны быть логически связаны.
- Интеграция с CI/CD: Unit-тесты должны автоматически запускаться в build-pipeline и останавливать сборку при сбое.
Ключевые компоненты
-
Структура теста (AAA) AAA означает Arrange, Act, Assert. На этапе Arrange подготавливаются все необходимые объекты и тестовые данные. На этапе Act вызывается тестируемый метод. На этапе Assert проверяется результат.
-
Тестовые двойники (Stub, Mock, Fake, Spy) Тестовые двойники заменяют реальные зависимости. Stub возвращает фиксированные ответы, mock проверяет, были ли вызваны определённые методы, fake представляет упрощённую реализацию, spy записывает информацию о вызовах.
-
Соглашения об именовании Хорошее имя теста описывает сценарий и ожидаемый результат. Паттерны типа
methodName_condition_expectedResultделают тесты живой документацией. -
Assertions Assertions это проверки в конце теста. Они должны быть точными и выдавать понятное сообщение при сбое.
-
Тестовые данные (Factories, Builder) Тестовые данные должны легко создаваться и воспроизводиться. Factories и builders помогают строить сложные объекты без заполнения теста бойлерплейтом.
-
Изоляция Unit-тест проверяет ровно одну единицу. Базы данных, сети, файлы и другие сервисы заменяются тестовыми двойниками.
-
Скорость Unit-тесты должны выполняться за миллисекунды. Только так набор тестов остаётся исполняемым при количестве в тысячи тестов и даёт быструю обратную связь.
-
Независимость Тесты не должны строиться друг на друге. Каждый тест создаёт свои данные и не оставляет состояний, которые повлияют на следующий тест.
-
Детерминизм Тест при каждом прогоне выдаёт одинаковый результат. Случайность, время, сеть или глобальные состояния заменяются контролируемыми входными данными и тестовыми двойниками.
-
Поддерживаемость Тесты это код. Они выигрывают от принципа DRY, ясных имён, небольших методов и слабой связанности. Поддерживаемые тесты переживают рефакторинги и остаются понятными.
Пример из практики (вычисление скидки)
Следующий пример показывает типичный unit-тест на Java с JUnit. Речь идёт о методе berechneRabatt, который вычисляет скидку для клиента и товара. Тест проверяет случай, когда новый клиент не получает скидку на стандартный товар.
Что здесь показано?
- Ясные имена: Имя теста описывает метод, условие и ожидаемый результат.
- AAA-структура: Секции Arrange, Act и Assert выделены комментариями и чётко разделены.
- Фокус: Проверяется только одна отдельная поведенческая характеристика.
- Информативная assertion: Сообщение об ошибке объясняет, почему этот тест важен.
// Naming: berechneRabatt_neukunde_standardartikel_erwartet0Prozent
@Test
public void berechneRabatt_neukunde_standardartikel_erwartet0Prozent() {
// Arrange
Kunde kunde = new Kunde(Kundentyp.NEUKUNDE);
Artikel artikel = new Artikel(Artikeltyp.STANDARD);
RabattService service = new RabattService();
// Act
int rabatt = service.berechneRabatt(kunde, artikel);
// Assert
assertEquals(0, rabatt, "Neukunde soll keinen Rabatt erhalten");
}
Объяснение: Тест изолирован, потому что не использует внешние системы. Он быстр, потому что создаёт только простые объекты. Он информативен, потому что имя и сообщение об ошибке сразу понятны. Если тест упадёт, ты сразу узнаешь, что вычисление скидки для новых клиентов работает неправильно.
Преимущества и недостатки
Преимущества
- Быстрая обратная связь: unit-тесты выполняются за миллисекунды и сразу показывают, работает ли изменение.
- Точная локализация ошибок: неудачный тест указывает прямо на затронутый компонент, а не на огромную систему.
- Безопасный рефакторинг: с хорошим набором тестов можно переделывать код, не опасаясь регрессии.
- Низкие затраты на поддержку при правильной структуре: чистые тесты легко понимать и адаптировать, когда меняются требования.
- Живая документация: тесты описывают поведение ПО и помогают новичкам разобраться в коде.
- Ранее обнаружение ошибок: дефекты находятся до попадания в production или на более высокие уровни тестирования.
Недостатки
- Начальные затраты: написание test doubles и хороших тестовых данных требует времени.
- Риск over-engineering: слишком много вспомогательных библиотек, сложные factory-методы или вложенные mocks могут сделать тесты сложнее самого production-кода.
- Ложное чувство безопасности: тест, проверяющий не то, может дать зелёный результат, в то время как код по-прежнему содержит ошибки.
- Содержание самих тестов: при плохой структуре каждое малое изменение требует корректировки множества тестов, что отнимает весь смысл.
- Не замена интеграционным тестам: unit-тесты проверяют отдельные части. Они не заменяют тесты взаимодействия нескольких компонентов.
FAQ: Характеристики хороших unit-тестов
1. Что такое unit-тест?
2. Что означает AAA в unit-тестах?
3. Что делает unit-тест корректным?
4. Что означает изоляция в unit-тестах?
5. Что такое test double?
6. В чём разница между stub и mock?
7. Что такое fake?
8. Что такое spy?
9. Почему unit-тесты должны быть быстрыми?
10. Что означает выразительность в контексте тестов?
11. Какая хорошая конвенция именования unit-тестов?
methodName_condition_expectedResult, например berechneRabatt_neukunde_standardartikel_erwartet0Prozent. Альтернатива: sollte_X_wenn_Y.12. Что такое assertion?
assertEquals, assertTrue, assertThrows.13. Что такое волшебные числа в тестах?
14. Почему тесты должны быть независимы друг от друга?
15. Что означает детерминированность unit-тестов?
16. Что понимают под поддерживаемостью тестов?
17. В чём разница между unit-, интеграционными и E2E-тестами?
18. Что такое edge cases?
19. Что такое happy path?
20. Что такое test factory?
21. Что означает безопасность рефакторинга у тестов?
22. Что такое flaky test?
23. Что такое code coverage?
24. Почему тест должен иметь одну область ответственности?
25. Что означает простота выполнения unit-тестов?
Стратегия обучения
1. Начало: напиши свой первый unit-тест по схеме AAA
Возьми простой метод, например метод расчета скидки для клиента. Напиши тест, используя структуру AAA, и назови его по шаблону methodName_condition_expectedResult.
Задание: напиши тест для метода isAdult(int age), который должен возвращать true, если возраст минимум 18 лет.
@Test
public void isAdult_age18_returnTrue() {
// Arrange
int age = 18;
// Act
boolean result = checker.isAdult(age);
// Assert
assertTrue(result, "Person aged 18 should be considered an adult");
}
Решение: тест корректен, изолирован и информативен. Он проверяет граничный случай и использует четкое утверждение с описанием ошибки.
2. Углубление: используй тестовые двойники
Представь, у тебя есть класс OrderService, который обращается к реальной базе данных. Напиши тест, где ты заменяешь базу данных на stub.
Задание: OrderService должен рассчитать общую сумму заказа. Для этого он использует PriceRepository. Напиши тест, который подменяет репозиторий.
@Test
public void calculateTotal_oneItem_price10_expected10() {
// Arrange
PriceRepository stubRepository = new PriceRepository() {
@Override
public double findPrice(String itemId) {
return 10.0;
}
};
OrderService service = new OrderService(stubRepository);
List<String> items = List.of("A1");
// Act
double total = service.calculateTotal(items);
// Assert
assertEquals(10.0, total, 0.001);
}
Решение: тест изолирует сервис от базы данных. Stub всегда возвращает цену 10.0, поэтому тест остается быстрым и детерминированным.
3. Практика: соотнесение видов тестовых двойников
Потренируйся соотносить тестовые двойники со сценариями. Вот небольшое упражнение:
| Сценарий | Подходящий тестовый двойник |
|---|---|
| Тебе нужен заранее определенный ответ для зависимости. | Stub |
| Ты хочешь проверить, был ли вызван определенный метод. | Mock |
| Ты заменяешь базу данных на In-Memory реализацию. | Fake |
| Ты хочешь потом проверить, какие вызовы произошли. | Spy |
4. Рефакторинг: улучши плохие тесты
Возьми следующий плохой тест и преобразуй его в хороший, поддерживаемый тест.
Плохая версия:
@Test
public void test1() {
int x = 5;
int y = 10;
assertEquals(new Calc().add(x, y), 15);
}
Улучшенная версия:
@Test
public void add_twoPositiveNumbers_returnSum() {
// Arrange
int addend1 = 5;
int addend2 = 10;
Calculator calculator = new Calculator();
// Act
int result = calculator.add(addend1, addend2);
// Assert
assertEquals(15, result, "5 + 10 should equal 15");
}
Решение: имя теста информативно, названия переменных говорят сами за себя, структура AAA четкая, а утверждение содержит сообщение об ошибке.
5. Интеграция: встрой тесты в pipeline сборки
Напиши или расширь проект так, чтобы unit-тесты запускались автоматически при сборке. В проекте Maven это происходит с помощью mvn test, в Gradle с помощью gradle test, в Node.js проекте с помощью npm test.
Задание: настрой CI pipeline, который при каждом commit запускает тесты и останавливает сборку при их неудаче.
Решение: простой GitHub Actions workflow может выглядеть так:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- run: mvn test
6. Самопроверка
В конце задай себе следующие вопросы:
- Каждый ли тест изолирован и детерминирован?
- Настолько ли описательны имена тестов, что ты можешь читать их как документацию?
- Проверяют ли твои тесты поведение, а не детали реализации?
- Используешь ли ты тестовые двойники для внешних зависимостей?
- Запускаются ли все тесты одной командой?
Если ты ответил “да” на все пункты, ты усвоил самые важные свойства хороших unit-тестов.
Анализ темы
- Технический стержень: структура AAA, тестовые двойники, утверждения, именование, изоляция
- Сложности: затраты на тестовые двойники, избежание over-engineering, поверхностные тесты
- Обеспечение качества: раннее обнаружение ошибок, безопасность рефакторинга, живая документация
- Экономичность: быстрая обратная связь, меньше простоев, лучше поддерживается
- Актуальность для экзаменов: IHK и профессиональная практика требуют знания свойств хороших unit-тестов и тестовых двойников
Главные источники
- https://martinfowler.com/articles/practical-test-pyramid.html
- https://junit.org/junit5/docs/current/user-guide/
- https://testing.googleblog.com



