Skip to content
IRC-CodingIRC-Coding
Unit TestsTestdoublesAAA Arrange Act AssertИзолированные тестыБыстрые тестыИнформативные тестыПоддерживаемые тесты

Свойства качественных Unit-тестов

Качественные Unit-тесты: изолированные, быстрые, информативные, поддерживаемые. Testdoubles, AAA, лучшие практики.

S

schutzgeist

10 min read
Свойства качественных Unit-тестов

Характеристики хороших 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 и останавливать сборку при сбое.

Ключевые компоненты

  1. Структура теста (AAA) AAA означает Arrange, Act, Assert. На этапе Arrange подготавливаются все необходимые объекты и тестовые данные. На этапе Act вызывается тестируемый метод. На этапе Assert проверяется результат.

  2. Тестовые двойники (Stub, Mock, Fake, Spy) Тестовые двойники заменяют реальные зависимости. Stub возвращает фиксированные ответы, mock проверяет, были ли вызваны определённые методы, fake представляет упрощённую реализацию, spy записывает информацию о вызовах.

  3. Соглашения об именовании Хорошее имя теста описывает сценарий и ожидаемый результат. Паттерны типа methodName_condition_expectedResult делают тесты живой документацией.

  4. Assertions Assertions это проверки в конце теста. Они должны быть точными и выдавать понятное сообщение при сбое.

  5. Тестовые данные (Factories, Builder) Тестовые данные должны легко создаваться и воспроизводиться. Factories и builders помогают строить сложные объекты без заполнения теста бойлерплейтом.

  6. Изоляция Unit-тест проверяет ровно одну единицу. Базы данных, сети, файлы и другие сервисы заменяются тестовыми двойниками.

  7. Скорость Unit-тесты должны выполняться за миллисекунды. Только так набор тестов остаётся исполняемым при количестве в тысячи тестов и даёт быструю обратную связь.

  8. Независимость Тесты не должны строиться друг на друге. Каждый тест создаёт свои данные и не оставляет состояний, которые повлияют на следующий тест.

  9. Детерминизм Тест при каждом прогоне выдаёт одинаковый результат. Случайность, время, сеть или глобальные состояния заменяются контролируемыми входными данными и тестовыми двойниками.

  10. Поддерживаемость Тесты это код. Они выигрывают от принципа 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-тест?

Unit-тест проверяет наименьшую тестируемую единицу программы, обычно метод или класс, в изоляции от внешних зависимостей.

2. Что означает AAA в unit-тестах?

AAA расшифровывается как Arrange, Act, Assert. Сначала подготовишь тестовые данные, потом вызовешь тестируемый метод, затем проверишь результат.

3. Что делает unit-тест корректным?

Корректный unit-тест проверяет ровно то поведение, которое он должен документировать, и основан на валидных предположениях. Одновременно он служит спецификацией для компонента.

4. Что означает изоляция в unit-тестах?

Изоляция означает, что unit-тест не требует внешних систем типа баз данных, сетевых вызовов или файловых систем. Внешние зависимости заменяются на test doubles.

5. Что такое test double?

Test double это замена реальной зависимости в тесте. К ним относятся stubs, mocks, fakes и spies.

6. В чём разница между stub и mock?

Stub предоставляет предопределённые ответы для зависимостей. Mock кроме того проверяет, были ли вызваны определённые методы с ожидаемыми параметрами.

7. Что такое fake?

Fake это упрощённая, работающая реализация зависимости. In-memory-репозиторий вместо реальной БД это типичный пример.

8. Что такое spy?

Spy это test double, который логирует вызовы методов, не полностью симулируя поведение. Помогает позже проверить взаимодействия.

9. Почему unit-тесты должны быть быстрыми?

Быстрые unit-тесты позволяют часто их запускать во время разработки и в CI-pipeline. Медленные тесты замораживают workflow и часто игнорируются.

10. Что означает выразительность в контексте тестов?

Выразительный тест имеет ясное имя, понятную структуру и точные сообщения об ошибках. При падении теста сразу видно, что пошло не так.

11. Какая хорошая конвенция именования unit-тестов?

Популярный паттерн: methodName_condition_expectedResult, например berechneRabatt_neukunde_standardartikel_erwartet0Prozent. Альтернатива: sollte_X_wenn_Y.

12. Что такое assertion?

Assertion это проверка, сравнивающая фактический результат с ожидаемым. Примеры: assertEquals, assertTrue, assertThrows.

13. Что такое волшебные числа в тестах?

Волшебные числа это жёстко закодированные значения без объяснения смысла. В тестах нужно использовать константы или переменные с говорящими именами, чтобы ясна была суть.

14. Почему тесты должны быть независимы друг от друга?

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

15. Что означает детерминированность unit-тестов?

Детерминированный тест при одинаковых входных данных всегда даёт один и тот же результат. Случайность, системные часы и внешнее состояние не должны появляться в unit-тестах.

16. Что понимают под поддерживаемостью тестов?

Поддерживаемые тесты чисто структурированы, избегают дублирования, используют выразительные имена и остаются валидными при рефакторинге кода. Они так же важны, как production-код.

17. В чём разница между unit-, интеграционными и E2E-тестами?

Unit-тесты проверяют отдельные компоненты в изоляции. Интеграционные тесты проверяют взаимодействие нескольких компонентов. End-to-end-тесты проверяют всю систему с точки зрения пользователя.

18. Что такое edge cases?

Edge cases это граничные ситуации типа пустых входных данных, нулевых значений, максимальных значений или неожиданного порядка. Хорошие тесты явно их учитывают, не только стандартный сценарий.

19. Что такое happy path?

Happy path описывает стандартный сценарий, когда всё работает как ожидается. Тесты должны охватывать и happy path, и случаи ошибок, и граничные случаи.

20. Что такое test factory?

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

21. Что означает безопасность рефакторинга у тестов?

Тесты безопасны для рефакторинга, если проверяют поведение, а не внутренние детали реализации. Тогда они остаются валидными при переделке production-кода.

22. Что такое flaky test?

Flaky test это тест, который иногда проходит, иногда падает, хотя код не менялся. Обычно признак недетерминированного поведения или внешних зависимостей.

23. Что такое code coverage?

Code coverage показывает, какой процент исходного кода исполняется при запуске тестов. Но высокий coverage сам по себе ничего не говорит о качестве тестов.

24. Почему тест должен иметь одну область ответственности?

Если тест проверяет несколько независимых поведений, при падении не ясно, какое предположение было неправильным. Несколько assertions допускаются, но должны быть связаны.

25. Что означает простота выполнения unit-тестов?

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-тестов и тестовых двойников

Главные источники

  1. https://martinfowler.com/articles/practical-test-pyramid.html
  2. https://junit.org/junit5/docs/current/user-guide/
  3. https://testing.googleblog.com
Назад к блогу
Share:

Nächster Artikel in Качество программного обеспечения

Weiterlesen
Integration Tests: основы тестирования компонентов

Похожие статьи