Уроки
Что такое Unit-тесты?
После того как вы закончили писать очередной кусок кода, вы хотите проверить, что он выполняет именно то, что задумывалось изначально. Процесс проверки соответствия кода требованиям и есть тестирование. Вы уже тестировали свой код – просто запустить приложение и потыкать в UI тоже считается. Результат этого процесса – ваша уверенность (до некоторой степени), что код, который вы написали, достаточно хорош, чтобы отдать его дальше. Тестирование также позволяет вам улучшить знания о кодовой базе, лучше понять функциональные требования.

Но с ручным тестированием есть много проблем. Можно что-то забыть по дороге, отвлечься и пропустить баг, это отнимает много времени, и его сложно автоматизировать.

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

Определение с Википедии:
Модульное тестирование, иногда блочное тестирование или юнит-тестирование (англ. unit testing) — процесс в программировании, позволяющий проверить на корректность отдельные модули исходного кода программы, наборы из одного или более программных модулей вместе с соответствующими управляющими данными, процедурами использования и обработки.

Что можно считать отдельным модулем (unit)?
Unit работы – это сумма действий, которые происходят между двумя вызовами публичных методов в системе, имеющих конечный видимый результат.

Конечный результат это то, что можно получить, не глядя во внутреннее состояние программы. Это может быть:
  • Вызванный публичный метод, возвращающий значение
  • Заметное изменение состояния или поведения системы до и после вызова (например, запись в лог)
  • Обращение к сторонней системе, которую тест не может контролировать
Unit может быть как очень маленьким (один конкретный метод), так и достигать больших размеров (несколько классов/методов, объединенных одной целью).

Одним из самых сложных аспектов определения модульного теста является определение того, что можно считать «хорошим» тестом. Хорошие Unit-тесты должны удовлетворять следующим требованиям:
  • автоматические и повторяемые
  • должно быть легко написать новый тест
  • должны оставаться актуальными
  • любой человек в команде может легко запустить все тесты
  • должны работать быстро
  • должны быть консистентными (одинаковый результат между запусками, если ничего не меняли в коде)
  • должны быть изолированы (результат одного теста не влияет на выполнение других)
  • Если тест упал, то должно быть легко найти, что пошло не так и где проблема
Какие тесты можно считать хорошими
Можно легко запустить свои тесты, написанные ранее (неделю/месяц/год назад)
Если нет, то мы оказываемся в ситуации, где невозможно понять, сломали ли вы новым кодом функционал, написанный ранее. Требования к приложению постоянно меняются, и код меняется вместе с ними, и если у вас нет возможности (или вам лень) запускать тесты, проверяющие все ранее реализованные фичи, каждый раз, когда вы меняете код, вы не будете знать, работает ваше приложение или нет.

Любой член команды может запустить тесты, написанные мной
Это очен похоже на предыдущий пункт, но рассматриваемый с другой перспективы. Мы хотим убедиться, что не сломаем чей-то еще код, когда что-то меняем. Многие разработчики боятся вносить изменения в легаси код из-за того, что они не знают, зависит ли что-то в системе от кода, который они меняют. По сути, они рискуют привести систему в состояние с неизвестной стабильностью. Страшно не знать, работает ли приложение по-прежнему, особенно если вы не писали этот код. Если бы вы знали, что ничего не сломаете, вы бы гораздо меньше боялись взяться за менее знакомый код, и у вас есть легкий способ проверить, что ничего не сломалось - модульные тесты. Хорошие тесты могут быть доступны и запущены кем угодно.

Все тесты можно легко запустить за несколько минут
Если вы не можете быстро запустить тесты, вы будете запускать их реже (ежедневно, еженедельно или даже ежемесячно). Проблема заключается в том, что, когда вы меняете код, вы хотите получить обратную связь как можно раньше, чтобы посмотреть, не сломалось ли что-нибудь. Чем больше времени между запусками тестов, тем больше изменений, которые вы вносите в систему, и тем больше мест для поиска ошибок, когда вы обнаружите, что что-то сломали. Хорошие тесты должны выполняться быстро.

Все тесты можно запустить одним нажатием кнопки
Если вы не можете это сделать, вам нужно настроить машину, на которой тесты будут выполняться так, чтобы они работали правильно (настроить подключение к БД, например). Еще это может означать, что ваши модульные тесты не полностью автоматизированы. Если вы не можете полностью автоматизировать модульные тесты, вы, вероятно, будете избегать их многократного выполнения, как и все остальные в вашей команде. Никто не любит увязать в деталях настройки, чтобы запускать тесты только для того, чтобы убедится, что система все еще работает. У разработчиков есть дела поважнее, например, писать больше кода. Хорошие тесты должны выполняться легко, а не с кучей ручной работы.
Разница с интеграционными тестами
Давайте рассматривать интеграционные тесты как любые тесты, которые небыстрые/противоречивые и которые используют одну или несколько реальных зависимостей тестируемых модулей. Например, если в тесте используется реальное системное время, реальная файловая система или реальная база данных, будем думать о нем как об интеграционном тесте.

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

Интеграционные тесты увеличивают риск возникновения другой проблемы - тестирования слишком большого количества вещей. Тут можно провести аналогию с поломкой машины. Как узнать, в чем проблема? Как ее исправить? Двигатель состоит из множества частей, работающих вместе, каждая из которых зависит от других. Если автомобиль перестает двигаться, поломка может быть в любой из этих частей, а может быть и в нескольких.
То же самое происходит при написании кода. Много разработчиков тестируют свой код через ui. Нажатие какой-либо кнопки запускает серию событий, компоненты работают вместе, чтобы получить окончательный результат. Если тест неудачный, то это значит, что ошибка где-то внутри цепочки операций, и может быть трудно выяснить, что вызвало сбой всей цепочки.

Подведем итог: интеграционные тесты используют реальные зависимости; модульные тесты изолируют «единицу работы» от ее зависимостей, чтобы они легко согласовывались в своих результатах и могли легко контролировать и моделировать любой аспект поведения кода.
Когда писать тесты?
Теперь вы знаете, как писать структурированные и надежные тесты, и следующий вопрос - когда писать тесты? Многие считают, что лучшее время для написания модульных тестов - после того, как код был написан. И это абсолютно нормальная практика.

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

TDD отличается от традиционной разработки, но по сути довольно проста:
  • Напишите неудачный тест, чтобы доказать, что код или функциональность отсутствуют в конечном продукте. Тест пишется так, как если бы код уже работал, поэтому то, что тест не пройден, означает, что в коде есть ошибка.
  • Написать код, который соответствует ожиданиям вашего теста. Код при этом должен быть максимально простым и не делать ничего лишнего.
  • Рефакторинг кода.

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

Хоть описание и очень простое, сам по себе фреймворк достаточно сложный в использовании и может принести больше вреда, чем пользы. Если интересно почитать про практики применения и паттерны, то есть хорошая книжка – Kent Beck’s Test-Driven Development: by Example
Фреймворки для тестирования
Как мы уже поняли, ручные тесты – отстой. Вы пишете свой код, нажимаете нужные клавиши в приложении, чтобы проверить, что все работает правильно, а затем повторяете все это в следующий раз, когда пишете новый код. И вы должны не забыть проверить весь другой код, который мог сломаться из-за вашего. Больше ручной работы. Отлично. Выполнение тестов полностью вручную, повторение одних и тех же действий снова и снова подвержено ошибкам и требует много времени. Эти проблемы решаются с помощью инструментов для тестирования. Фреймворки модульного тестирования помогают разработчикам быстрее писать тесты с помощью набора известных API, автоматически выполнять эти тесты и легко просматривать результаты этих тестов. И они никогда не забывают выполнить все тесты :)

Давайте разберем, что они предлагают.
JUnit 5
Настройка
JUnit является наиболее популярным фреймворком для модульного тестирования в экосистеме Java. Давайте настроим JUnit в нашем проекте. Сначала довавьте зависимость на JUnit 5 в pom.xml, посмотрите на пример ниже:
<dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter-engine</artifactId>
      <version>5.9.2</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
Теперь нужно настроить Maven так, чтобы при запуске команды test тесты также выполнялись.
При запуске package команда test также выполняется автоматически.
Для этого добавим еще один плагин в <build><plugins>.... Посмотрите на пример кода ниже:
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.1.2</version>
</plugin>
Теперь напишем простой тест. Они добавляются в директорию src/test. Тем не менее, Idea позволяет сделать это проще. Нажмите правой кнопкой мыши на класс Main и выберите пункт Generate, а там уже кнопку Test.... Среда разработки предложит создать вам класс MainTest в том же самом пакете, но в src/test. Это очень удобно, чтобы создавать тесты для разных классов.

Посмотрите на пример теста ниже:
package com.example.demo;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class MainTest {
    @Test
    void testSum() {
        assertEquals(2, 1 + 1);
    }
}
Этот тест очень простой: он всего лишь проверяет, что 2 = 1 + 1. Но для первого знакомства этого будет достаточно. Теперь выполните команду test для Maven. Вы заметите следующую строчку в логах:
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.076 s -- in org.example.MainTest
Один тест выполнился успешно. Здорово!

Попробуем "сломать" тест так, чтобы он был неправильным. Замените код на assertEquals(3, 1 + 1) и запустите команду package. Вы увидите, что она завершилась ошибкой. То есть Maven не дал нам собрать наш проект, если хотя бы один тест падает. Здесь и кроется суть. Тесты проверяют работоспособность кода. Если один начал падать, значит, что-то не так.

Повторите этот эксперимент, но уже на GitHub. Создайте новую ветку и сделайте Pull Request. Вы увидите, что если тесты проходят, билд зеленый. Если же он падает, билд становится красным, и вы не можете влить Pull Request.

Как владелец репозитория вы всегда можете вливать Pull Request-ы, даже если билд упал. Но те люди, которых вы пригласили, или те, что работают по модели Fork + Pull Request (об этом мы упоминали в первом модуле), не смогут нажать Merge. Таким образом, вы можете гарантировать, что некорректный код не сможет попасть в master ветку.
Если у вас возникли сложности с настройкой, можете ознакомиться с примером готового проекта по этой ссылке.

Обычно названия для классов тестов выбирается как 'Class under test name' + Test.java, но это не обязательное условие. Помните, мы раньше обсуждали, что тест определяет какой-то один 'unit' работы? Можно выбирать имя на основе этого факта: FeatureNameTest.java. Главное, чтобы имя было осмысленным, а не просто Test1.java.

Чтобы запустить единственный тест, а не все сразу, есть несколько способов. Самый простой – через IDE. Большинство современных IDE имеют хорошую интеграцию с JUnit, и тест можно просто запустить из нее. Также можно использовать Maven, чтобы запустить не все, а только конкретные тесты:
  • mvnw test – выполнить все тесты в проекте.
  • mvnw -Dtest=MainTest test – выполнить все тесты из класса.
  • mvnw -Dtest=MainTest#testSum test – выполнить отдельный тест.
Любой тест состоит из следующих частей:
  • Название теста
  • Подготовка окружения
  • Совершение интересующего нас действия
  • Проверка результата
  • Освобождение ресурсов
Название теста
Очень важный пункт, к нему стоит отнестись с большим вниманием. Название теста – это первое, что вы увидите в отчете, когда что-то пойдет не так. Вы должны по одному названию теста уже получить примерное представление о том, что он проверяет. Поэтому стоит подумать о том, как вложить в несколько слов смысл того, что вы проверяете. Давать тестам имена Тест1, Тест2, Тест3 – это плохая идея.

Подготовка окружения и Освобождение ресурсов
Необязательные части. У некоторых тестов (как у нас в примере) их может не быть. Зачем они тогда вообще нужны? Во-первых, надо подготовить тестовые данные, например, открыть и прочитать их из файла. Во-вторых, у вашего кода могут быть зависимости, которые надо как-то настроить или подменить (об этом позже). Наши тесты должны быть независимыми, поэтому важно откатить все действия, которые могут повлиять на другие тесты. Также важно правильно закрыть файлы, соединение к БД и т.д.

Совершение действия и Проверка результата
То, ради чего все и затевалось. Чтобы что-то проверить, нам нужно что-то сделать. Выполняем какое-то воздействие на систему. К примеру, вызываем функцию, дергаем ручку. Получив результат этого действия, переходим к проверке результатов. Именно здесь вы проверяете, что мир изменился таким образом, как вы ожидали. Что функция посчитала нужные цифры, ручка вернула нужные данные, в БД записались правильные данные, и при этом не стерлось ничего, что не должно было стереться.
А что тестировать?
Есть ряд типичных практик, которые постоянно применяются. Первое, что стоить проверить, это типовой путь выполнения вашего кода (happy path), то есть то, что если вызвать функцию, то она вернет данные. Вы сначала проверяете его: что действительно вызвали функцию, и она действительно вернула данные. Вы знаете, как это должно работать, вы ожидаете, что это будет работать именно так, но на всякий случай пишете на это тест. Потому что если вдруг это сломается, будет плохо. Даже если вам в моменте кажется очевидным и простым, как функция работает, все равно стоит написать на это тест. Потому что завтра вы будете разрабатывать другую фичу и случайно сломаете эту, а без тестов вы узнаете об этом очень поздно.

Затем стоит проверить все краевые случаи. К примеру, если пользователь введет свое имя японскими иероглифами или в графу возраста введет строку. Что код должен делать в такой ситуации? Справится ли он с этим? Пишем по отдельному тесту на каждый такой случай.

Ну и всегда стоит проверить самую банальную ситуацию. Что будет, если запихивать null в любое место кода, куда только можно? Есть функция? Давайте попробуем отправить туда null. Ваш код, по-хорошему, должен быть устойчив к тому, что вместо аргументов придет null. Например, вернет более осмысленную ошибку.
Asserts
Давайте теперь подробнее посмотрим на главную задачу наших тестов – проверку того, что результат тот, который мы ожидали. Для этого мы используем функции, называемые ассертами. Эти функции являются всего лишь инструментом, который просто помогает вам выразить ваши намерения.

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

Все доступные ассерты в JUnit являются статическими методами класса org.junit.jupiter.api.Assertions.

Основные ассерты:
  • assertEquals – проверяет, что переданные аргументы равны
  • assertNotEquals – аналогично проверяет, что не равны
  • assertTrue – условие истинно
  • assertFalse – условие ложное
  • assertNull – переданный аргумент равен null
  • assertNotNull – не равен null
  • assertThrows – принимает два параметра. Класс исключения и код для выполнения. Проверяет, что код выбросит заданное исключение.
В любой ассерт можно дополнительно передать строковый параметр message. Это бывает полезно, если из кода теста не очевидно, зачем мы вообще хотим это проверить. Сообщение будет добавлено в отчет, сформированный Junit'ом, если тест упадет, и будет проще разобраться, что же пошло не так.

Важно! Если вы используете свои объекты в ассертах, к примеру assertEquals(course1, course2), то у этого объекта должны быть переопределены методы equals и hashcode. Иначе объекты будут сравниваться по ссылкам и тест проверит не то, что вы ожидаете.

Если вам кажется, что ассерты из предыдущих примеров сложно читаемые, то существуют внешние библиотеки для ассертов. Самые популярные – AssertJ и Hamcrest.
AssertJ
AssertJ – это библиотека, которая является попыткой сделать проверку результатов выполнения вашего кода более читаемой, похожей на обычный английский язык. Давайте посмотрим на простом примере. Пусть у нас есть тест, который проверяет, что записей в репозитории меньше 10.
class MyTest {
    @Test
    void testRecordCount() {
        List<Course> all = repository.findAll();
        assertTrue(all.size() < 10);
    }
}
А вот так будет выглядеть тот же тест с использованием AssertJ.
class MyTest {
    @Test
    void testRecordCountAssertJ() {
        List<Course> all = repository.findAll();
        assertThat(all).hasSizeLessThan(10);
    }
}
Это читается намного проще, практически как текст на английском языке.

Также AssertJ очень удобен для сложных проверок. Допустим, вам надо проверить, что среди всех курсов не существует курсов за авторством конкретного человека. Как это сделать? Получаем все курсы из репозитория, дальше в цикле достаем всех авторов и проверяем, не равен ли он нашему. С помощью AssertJ это записывается простым декларативным способом:
class MyTest {
    @Test
    void testAndreiDidNotWriteAnyCourse() {
        List<Course> all = repository.findAll();
        assertThat(all).extracting("author").doesNotContain("Андрей");
    }
}
Чем проще читается тест, тем он понятнее, и тем проще будет его чинить в будущем, если что-то пойдет не так.
Ссылка на документацию.
Hamcrest
Если стиль AssertJ вам не нравится, то существует еще один инструмент, выполненный в подобном духе, Hamcrest.
Он выполняет примерно ту же функцию — пытается сделать код тестов более читаемым, просто делает это иным образом. В отличие от AssertJ, где код пишется как последовательность вызовов у builder'a, здесь используется иерархическое дерево матчеров. Очень страшное название, но на деле это просто означает, что функции проверки иногда бывают вложены друг в друга.

Те же примеры, но и использованием Hamcrest:
class MyTest {
    @Test
    void testRecordCount() {
        List<Course> all = repository.findAll();
        assertThat(all.size(), is(lessThan(10)));
    }

    @Test
    void testAndreiDidNotWriteAnyCourse() {
        List<Course> all = repository.findAll();
        assertThat(all, is(not(contains(hasProperty("author", is("Андрей"))))));
    }
}
Результат очень похожий, ассерты выглядят как обычные предложения на английском.
Ссылка на документацию.
GoF паттерны
Мы немного отвлечемся от тестов и рассмотрим паттерны проектирования программного обеспечения. В конце модуля мы снова вернемся к тестам, и вы поймете, что тестируемость продукта напрямую связана с тем, как вы организуете код.
Большинство программных продуктов в современном мире разрабатываются с помощью Agile-подобных методологий (Scrum, Kanban и так далее). Во-первых, это дает заказчикам волю выстраивать требования к конечной системе постепенно. А во-вторых, позволяет наблюдать за тем, как развивается продукт в реальном времени. Текущее состояние отслеживается на демо-показах, которые могут проходить, например, раз в две недели.

Правда с другой стороны, это создает дополнительные сложности для разработчиков. Из-за того что требования к системе могут поменяться в любой момент, код продукта должен быть выстроен в соответствии с этими ожиданиями. На протяжении многих лет разработчики описывали лучшие практики структуризации программного кода для наилучшей поддержки в будущем. В 1994 году произошло знаменательное событие: выход в свет книги "Design Patterns: Elements of Reusable Object-Oriented Software". Данный труд собрал воедино опыт сотен разработчиков в виде конкретных рецептов.
Design Patterns были написаны четырьмя разработчиками, которых впоследствии назвали "Бандой четырех" (Gang of Four). Отсюда и название — GoF паттерны.

В книге описаны 23 различных паттерна (шаблона проектирования). Мы рассмотрим лишь некоторые из них. Тем не менее крайне рекомендуем прочитать книгу целиком и попробовать реализовать предложенные паттерны самостоятельно.

Несмотря на то что книга вышла более 25 лет назад, многие предлагаемые решения до сих пор остаются актуальными. Правда стоит заметить, что прогресс тоже не стоит на месте. Ряд паттернов теперь реализуются стандартными возможностями языков программирования. Например, Stream API, который мы будем рассматривать далее, целиком построен на одном из GoF паттернов. Попробуйте догадаться, на каком именно.
Декоратор
Пожалуй, один из самых популярных шаблонов проектирования в ООП языках и Java в частности. Рассмотрим следующий пример. Допустим, у нас есть сервис, который обновляет информацию о пользователе в БД.
public interface UserService {
  void updateUser(Long id, String firstName, String lastName);
}

public class UserServiceImpl implements UserService {
  private final UserRepository userRepository;

  @Override
  public void updateUser(Long id, String firstName, String lastName) {
    User user = userRepository.findById(id)
        .orElseThrow(() -> new NoSuchElementException("No User with ID=" + id));
    user.update(firstName, lastName);
  }
}
Метод UserRepository.findById возвращает Optional. Мы рассматривали его в предыдущем модуле. Если нужно освежить воспоминания, то вернитесь к нему еще раз в раздел "Не возвращайте null".

Далее нам поступает новое требование. Если попытка обновления завершилась с ошибкой, необходимо фиксировать информацию в системе аудита. Мы можем просто добавить соответствующую логику в метод.
public class UserServiceImpl implements UserService {
  private final UserRepository userRepository;
  private final AuditService auditService;

  @Override
  public void updateUser(Long id, String firstName, String lastName) {
    try {
      User user = userRepository.findById(id)
          .orElseThrow(() -> new NoSuchElementException("No User with ID=" + id));
      user.update(firstName, lastName);
    } catch (RuntimeException e) {
      auditService.auditFailedUserCreation(id, e);
      throw e;
    }
  }
}
Проходит время, и к нам поступает новое требование. Если создание пользователя было успешным, нужно отправлять e-mail администратору.
public class UserServiceImpl implements UserService {
  private final UserRepository userRepository;
  private final AuditService auditService;
  private final EmailService emailService;

  @Override
  public void updateUser(Long id, String firstName, String lastName) {
    try {
      User user = userRepository.findById(id)
          .orElseThrow(() -> new NoSuchElementException("No User with ID=" + id));
      user.update(firstName, lastName);
      emailService.notifyAdmin(new UserUpdatedEvent(id));
    }
    catch (RuntimeException e) {
      auditService.auditFailedUserCreation(id, e);
      throw e;
    }
  }
}
У этого класса есть ряд проблем.
  • Большое количество зависимостей, которое затрудняет понимание того, зачем сервис нужен.
  • Класс трудно тестировать.
  • При добавлении новой функциональности есть риск того, что старые тесты упадут.
Декоратор предлагает иной подход. Каждый раз, когда мы хотим добавить новую функциональность, мы создаем новую имплементацию UserService, которая инкапсулирует в себе логику и проксирует вызовы далее по необходимости.

Схематично это можно изобразить следующим образом.
Каждый класс отвечает за одну функциональность. При необходимости декоратор может делегировать работу далее по цепочке.

Если переписать код, он будет выглядеть следующим образом.
public class UserServiceImpl implements UserService {
  private final UserRepository userRepository;

  @Override
  public void updateUser(Long id, String firstName, String lastName) {
      User user = userRepository.findById(id)
          .orElseThrow(() -> new NoSuchElementException("No User with ID=" + id));
      user.update(firstName, lastName);
  }
}

public class AuditUserService implements UserService {
  private final UserService origin;
  private final AuditService auditService;

  @Override
  public void updateUser(Long id, String firstName, String lastName) {
    try {
      origin.updateUser(id, firstName, lastName);
    }
    catch (RuntimeException e) {
      auditService.auditFailedUserCreation(id, e);
      throw e;
    }
  }
}

public class EmailUserService implements UserService {
  private final UserService origin;
  private final EmailService emailService;

  @Override
  public void updateUser(Long id, String firstName, String lastName) {
    origin.updateUser(id, firstName, lastName);
    emailService.notifyAdmin(new UserUpdatedEvent(id));
  }
}
Теперь каждый класс выполняет одну заданную функцию. Их можно протестировать как изолированно друг от друга, так и вместе. Более того, декораторы зависят от интерфейса UserService, а не от имплементации UserServiceImpl. Это дает возможность менять порядок вызовов и добавлять новые декораторы при необходимости.
Фабричный метод
Допустим, мы разрабатываем ПО для автоматизации банковского обслуживания. Банк предоставляет три типа кредитных карт: Standard, Gold и Platinum. У каждого объекта карты есть два метода: getCreditLimit (остаток лимитного кредита) и getAnnualCharge (ежегодный платеж за использование).
public interface Card {
  BigDecimal getCreditLimit();
  BigDecimal getAnnualCharge();
}

public interface StandardCard implements Card {
  ...
}

public interface GoldCard implements Card {
  ...
}

public interface PlatinumCard implements Card {
  ...
}
Банк хочет, чтобы пользователи могли заказывать карты онлайн. Что ж, нам нужно как-то создавать объекты Card. Для этого можно воспользоваться простейшей статической фабрикой.
public class CardFactory {
  public static Card newCard(ClientAttributes attributes) {
    // some calculations
    // ...
    // return new Card instance
  }
}
Со временем появились новые требования. Например, у клиента не может быть более пяти стандартных карт. Чтобы перейти со Standard на Gold, нужно либо обладать определенным уровнем годового дохода, либо заплатить входную комиссию. Все клиенты, которые приобретают платиновую карту, переходят в категорию VIP.

Подобных условий может быть большое разнообразие. Также они могут меняться со временем. Если мы попытаемся учесть все данные требования в рамках метода newCard, то столкнемся с рядом проблем:
  • При появлении нового требования со стороны бизнеса (например, добавление нового типа карты), придется менять логику CardFactory.newCard (это нарушает Open-Closed principle).
  • Метод содержит в себе слишком много бизнес-логики, из-за чего его трудно будет тестировать.
  • Если в методе есть баг, это может затронуть процесс получения любых карт.
Паттерн фабричный метод помогает решить эту проблему. Во-первых, сделаем CardFactory интерфейсом.
public interface CardFactory {
  Card newCard(ClientAttributes attributes);
}
Во-вторых, для каждого типа карт создадим свою имплементацию фабрики.
public class StandardCardFactory implements CardFactory {
  public Card newCard(ClientAttributes attributes) {
    ...
    return new StandardCard();
  }
}

public class GoldCardFactory implements CardFactory {
  public Card newCard(ClientAttributes attributes) {
    ...
    return new GoldCard();
  }
}

public class PlatinumCardFactory implements CardFactory {
  public Card newCard(ClientAttributes attributes) {
    ...
    return new PlatinumCard();
  }
}
Теперь разные фабрики отвечают за создание разных объектов типа Card.

Это дает нам много преимуществ:
  • Каждая фабрика может быть протестирована изолированно от других.
  • При появлении нового типа карты старый код менять не потребуется. Нужно будет лишь создать новую фабрику.
  • Если понадобится изменить логику получения определенного типа карты, достаточно будет затронуть лишь одну фабрику.
Цепочка обязанностей
Поведенческий шаблон проектирования, который позволяет передавать запросы по цепочке от одного обработчика к другому. Каждый обработчик решает, может ли он обработать запрос, а также стоит ли передавать запрос дальше по цепочке.

Предположим, что мы автоматизируем работу call-центра. На каждый входящий звонок необходимо ответить. Можно реализовать это следующим образом:
public class CallCenter {
    public void answerIncomingCall(Call call) {
        if (!firstOperator.isOnCall()) {
            firstOperator.answer(call);
        } else if (!secondOperator.isOnCall()) {
            secondOperator.answer(call);
        } else if (!thirdOperator.isOnCall()) {
            thirdOperator.answer(call);
        } else if (!fourthOperator.isOnCall()) {
            fourthOperator.answer(call);
            // и так далее
        }
    }
}
Данный подход весьма громоздкий и не очень гибкий: если потребуется добавить нового оператора call-центра, то это можно будет сделать только в классе CallCenter, модифицировав его код.

Альтернативный вариант решения задачи – выстроить всех операторов друг за другом в цепочку, а затем направить звонок первому в этой цепочке. Тогда первый оператор либо ответит на звонок, либо, если он окажется занят, он передаст звонок далее по цепочке, то есть второму оператору. Если второй оператор будет занят, то звонок будет передан третьему и так далее. В конце цепочки можно поставить оператора-робота, который попросит звонящего перезвонить позже.

Реализация call-центра с цепочкой обязанностей:
public class CallCenter {

    private Operator firstOperator;

    public CallCenter() {
        Operator firstOperator = new HumanOperatorImpl("Вася");
        Operator secondOperator = new HumanOperatorImpl("Маша");
        Operator thirdOperator = new HumanOperatorImpl("Петя");
        Operator fourthOperator = new HumanOperatorImpl("Оля");
        Operator robotOperator = new RobotOperatorImpl();

        firstOperator.setNextOperator(secondOperator);
        secondOperator.setNextOperator(thirdOperator);
        thirdOperator.setNextOperator(fourthOperator);
        fourthOperator.setNextOperator(robotOperator);
    }

    public void answerIncomingCall(Call call) {
        firstOperator.answer(call);
    }
}
В этом примере, как и в предыдущем, цепочка операторов строится в конструкторе. Однако в первом примере она "зашита" в коде, а во втором её можно динамически изменять прямо во время работы программы. Например, можно вставить в середину нового оператора. Или изменить очерёдность операторов в цепочке. Ведь по сути цепочка операторов – это структура данных Список.

Реализация операторов:
public interface Operator {
    void answer(Call call);
    Boolean isOnCall();
    void setNextOperator(Operator next);
}

public class HumanOperatorImpl implements Operator {
    private String name;
    private Boolean onCall; // Сейчас оператор говорит по телефону?
    private Operator next; // Следующий оператор в цепочке

    public HumanOperatorImpl(String name) {
        this.name = name;
    }

    @Override
    public boolean isOnCall() {
        return onCall;
    }

    @Override
    public void setNextOperator(Operator next) {
        this.next = next;
    }

    @Override
    public void answer(Call call) {
        if (this.onCall) {
            // Текущий оператор говорит по телефону - переводим звонок на следующего
            next.answer(call);
        }
        System.out.println("Здравствуйте, оператор " + this.name + " слушает"); // Ответ на звонок
    }
}

public class RobotOperatorImpl implements Operator {
    @Override
    public void answer(Call call) {
        System.out.println("Нет свободных операторов, перезвоните позже.");
    }

    @Override
    public Boolean isOnCall() {
        return false;
    }

    @Override
    public void setNextOperator(Operator next) {
        throw new UnsupportedOperationException("Робот - это последний оператор");
    }
}
Стратегия
Стратегия (Strategy) – это шаблон проектирования, который определяет набор алгоритмов, инкапсулирует каждый из них и обеспечивает их взаимозаменяемость. В зависимости от ситуации мы можем легко заменить один используемый алгоритм другим. При этом замена алгоритма происходит независимо от клиента — объекта, который использует данный алгоритм.

Иными словами, когда мы используем Стратегию, у нас есть набор алгоритмов: алгоритм А, алгоритм Б, алгоритм В и так далее. И все они взаимозаменяемы, то есть в зависимости от ситуации можно подключать алгоритм А, или алгоритм Б, или любой другой из этого набора. Эти алгоритмы отделены от кода, который их использует (клиента). Поэтому при изменении (либо добавлении) алгоритма, нет необходимости модифицировать клиентский код - он остаётся неизменным, а меняются лишь сами алгоритмы.

Рассмотрим применение данного шаблона на примере реализации поведения автомобиля. Предположим, у автомобиля пока есть лишь одна функциональная возможность – ехать.

В нашей программе могут быть разные автомобили, но с ними, как это часто бывает, нужно работать неким единообразным образом. Поэтому мы создаём интерфейс Car и реализуем его:
interface Car {
   void drive();
}

class CarImpl {
   void drive() {...}
}
Пока у нас одна реализация Car, всё просто. Но со временем появляются автомобили с АБС. Тогда мы добавляем в интерфейс Car новый метод slowdown и делим нашу единственную реализацию на 2 класса, реализуя в каждом из них свой алгоритм торможения:
class ABSCarImpl {
   void drive() {...}
   void slowdown() {...}
}

class NoABSCarImpl {
   void drive() {...}
   void slowdown() {...}
}
С течением времени появляется требование добавить новую функциональность: поворачивать с использованием гидроусилителя руля, электроусилителя руля или без усилителя. Причём усилителем могут быть (или не быть) оборудованы любые существующие автомобили – как с АБС, так и без. Поэтому нам уже нужно 6 реализации интерфейса Car (3 варианта усилителя руля * 2 варианта АБС):
class HydraulicSteeringABSCarImpl {
   void drive() {...}
   void slowdown() {...}
   void steer() {...}
}

class ElectricSteeringABSCarImpl {
   void drive() {...}
   void slowdown() {...}
   void steer() {...}
}

class MechanicalSteeringABSCarImpl {
   void drive() {...}
   void slowdown() {...}
   void steer() {...}
}

class HydraulicSteeringNoABSCarImpl {
   void drive() {...}
   void slowdown() {...}
   void steer() {...}
}

class ElectricSteeringNoABSCarImpl {
   void drive() {...}
   void slowdown() {...}
   void steer() {...}
}

class MechanicalSteeringNoABSCarImpl {
   void drive() {...}
   void slowdown() {...}
   void steer() {...}
}
Такой подход ведёт к резкому увеличению количества классов, а также к дублированию кода, ведь методы поворота и торможения у некоторых реализаций оказываются одинаковые. Разумеется, можно вынести повторяющийся код в отдельные классы, а можно применить шаблон "Стратегия".

Для каждой функциональности автомобиля (тормозить, поворачивать) создадим соответствующий интерфейс:
interface SlowdownStrategy {
   void slowdown();
}

interface SteerStrategy {
   void steer();
}
Теперь создадим нужные реализации этих интерфейсов (это те самые "алгоритмы", о которых мы говорили в начале):
class HydraulicSteeringStrategy implements SteeringStrategy {
   void steer() { 
       // Поворот с гидроусилителем 
   }
} 

class ElectricSteeringStrategy implements SteeringStrategy {
   void steer() {
      // Поворот с электроусилителем
   }
}

class MechanicalSteeringStrategy implements SteeringStrategy {
   void steer() {
      // Поворот без усилителя
   }
}

class ABSSlowdownStrategy implements SlowdownStrategy {
   void slowdown() {
      // Торможение с АБС 
   }
} 

class NoABSSlowdownStrategy implements SlowdownStrategy {
   void slowdown() {
      // Торможение без АБС 
   }
}
Далее можно создавать объекты-автомобили, комбинируя стратегии любым образом, указывая их, к примеру, через конструктор или сеттер.

Благодаря использованию шаблона "Стратегия" мы соблюдаем принцип OCP – O из SOLID. А также избавляемся от необходимости менять клиентский код при добавлении новых стратегий.

"Стратегия" может пригодиться в тех ситуациях, когда программе необходимо использовать различные алгоритмы, когда нужно менять поведение объектов на стадии выполнения (Runtime), когда нужна возможность задавать поведение конкретным экземплярам класса. А обращение к алгоритмам через интерфейс позволяет клиентскому коду ничего не знать о деталях их реализации.
SOLID
SOLID – это аббревиатура, объединяющая 5 принципов. Вместе они образуют подход к разработке поддерживаемого программного обеспечения.

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

Рассмотрим каждую из них подробнее.
Single Responsibility Principle
Принцип единственной ответственности гласит, что каждый класс должен иметь только одну зону ответственности.

Допустим, мы разрабатываем систему, которая позволяет бронировать отели. При этом у нас есть следующие функции:
  • Получить информацию о комнате.
  • Забронировать комнату.
  • Отправить уведомление об успешном бронировании.
Попытка реализовать такую функциональность может выглядет так:
public class HotelService {

  public Room findRoom(RoomId roomId) {
    // поиск комнаты по ID
  }

  public Order bookRoom(RoomId roomId) {
    // забронировать комнату
  }

  public List<Order> getOrders() {
    // получить список текущих заказов
  }

  public void notifyOrdering(OrderId orderId) {
    // отправить уведомление об оформлении заказа
  }
}
У класса HotelService есть три ответственности: поиск информации о комнате, бронирование и уведомление. А это значит, что причин его менять в три раза больше. Например, из-за бага или необходимости добавить новую функциональность. Это может привести к тому, что класс станет большим и плохо поддерживаемым.

Мы можем исправить это, разделив его на три части. Посмотрите на пример кода ниже:
public class RoomService {

  public Room findRoom(RoomId roomId) {
    // поиск комнаты по ID
  }
}

public class OrderService {

  public Order bookRoom(RoomId roomId) {
    // забронировать комнату
  }

  public List<Order> getOrders() {
    // получить список текущих заказов
  }
}

public class NotifyService {

  public void notifyOrdering(OrderId orderId) {
    // отправить уведомление об оформлении заказа
  }
}
Теперь каждый класс можно редактировать в отдельности, не затрагивая остальных.
Open-Closed Principle
Принцип открытости-закрытости диктует, что в процессе добавления функциональности мы должны не менять старый код, но лишь добавлять новый. Звучит сложно, но самом деле идея тривиальна. Давайте рассмотрим ее на примере класса NotificationService из прошлого пункта. Допустим, что мы хотим обрабатывать два варианта уведомлений: SMS и email-ы. Мы можем добавить enum NotificationType и передавать его в качестве параметра метода notifyOrdering. Посмотрите на пример ниже:
public class NotifyService {

  public void notifyOrdering(OrderId orderId, NotificationType type) {
    if (type.equals(NotificationType.SMS)) {
      // логика обработки SMS
    } else if (type.equals(NotificationType.EMAIL)) {
      // логика обработки EMAIL
    }
  }
}
Если появится новый тип уведомлений, нам придется добавлять if в метод notifyOrdering. А если их станет достаточно много, метод будет постоянно расти в вертикальном направлении. Это не только усложняет понимание кода, но и увеличивает риск допустить ошибку. Например, можно забыть проверить какой-то NotificationType или проверить его там, где не нужно.

Чтобы отрефакторить это, давайте введем новый интерфейс NotificationAction. Посмотрите на пример ниже:
public interface NotificationAction {

  NotificationType type();

  void notify(OrderId orderId);
}
Каждая его реализация будет представлять собой конкретный тип уведомлений. Посмотрите на код ниже:
public class SmsNotificationAction {

  @Override
  public NotificationType type() {
    return NotificationType.SMS;
  }

  public void notify(OrderId orderId) {
    // логика уведомлений по sms
  }
}

public class EmailNotificationAction {

  @Override
  public NotificationType type() {
    return NotificationType.EMAIL;
  }

  public void notify(OrderId orderId) {
    // логика уведомлений по email
  }
}
Но как нам применить эту новую абстракцию, если пользователь явно передает NotificationType, который ему нужен? Очень просто: нужно внедрить список NotificationAction в NotificationService. Посмотрите на пример ниже:
public class NotificationService {

  private final List<NotificationAction> actions;

  public NotificationService(List<NotificationAction> actions) {
    this.actions = actions;
  }

  public void notifyOrdering(OrderId orderId, NotificationType type) {
    boolean foundAction = false;
    for (NotificationAction action : actions) {
      if (action.type().equals(type)) {
        foundAction = true;
        action.notify(orderId);
        break; // выходим из цикла, если нашли нужный action
      }
    }
    if (!foundAction) {
      // если не нашли подходящего action, кидаем исключение
      throw new AbsendNotificationException("Couldn't find NotificationAction for type=" + type);
    }
  }
}
Теперь NotificationService полностью закрыт от изменений. Даже если добавится новый способ уведомлений, нам не придется менять ни NotificationService, ни уже существующие реализации NotificationAction. Достаточно будет создать новый класс, который наследует NotificationAction, а также добавить новое enum значение для NotificationType.

Посмотрите ниже на пример того, как можно создать экземпляр NotificationService:
public class Main {

  public static void main(String[] args) {
    NotificationService service = new NotificationService(
        List.of(
            new SmsNotificationAction(),
            new EmailNotificationAction()
        )
    );
    // дальнейшие действия
  }
}
Как видите, NotificationService ничего не знает о конкретных реализациях для отправки уведомлений, а оперирует лишь интерфейсом NotificationAction.
Liskov Substitution Principle
Принцип подстановки Барбары Лисков гласит, что если мы зависим от интерфейса, то подстановка любой его реализации не должна ломать код. Посмотрите еще раз на интерфейс NotificationAction ниже:
public interface NotificationAction {

  NotificationType type();

  /**
   * Notifies user of order about its submission.
   *
   * @param orderId id of order
   * @throws NotificationActionException if couldn't send notification due to error
   */
  void notify(OrderId orderId);
}
Мы предполагаем, что метод notify может завершиться ошибкой, поэтому в javadoc мы явно зафиксировали информацию о типе исключения – NotificationActionException.

Javadoc – это стандарт документирования классов и методов в Java. Чтобы сгенерировать javadoc в Idea, нажмите правой кнопкой мыши на элемент и выберите пункт Generate -> Javadoc.

Отлично, теперь контракт известен. Давайте немного поправим NotificationService:
public class NotificationService {

  private static final Logger LOG = LoggerFactory.getLogger(NotificationService.class);

  private final List<NotificationAction> actions;

  public NotificationService(List<NotificationAction> actions) {
    this.actions = actions;
  }

  public void notifyOrdering(OrderId orderId, NotificationType type) {
    boolean foundAction = false;
    for (NotificationAction action : actions) {
      if (action.type().equals(type)) {
        foundAction = true;
        try {
          action.notify(orderId);
          break; // выходим из цикла, если нашли нужный action
        } catch (NotificationActionException e) {
          LOG.error("Couldn't execute action '{}'. Trying next one", action, e);
        }
      }
    }
    if (!foundAction) {
      // если не нашли подходящего action, кидаем исключение
      throw new AbsendNotificationException("Couldn't find NotificationAction for type=" + type);
    }
  }
}
Мы обернули action.notify в try-catch и в случае ошибки логируем информацию в консоль, но не прерываем выполнения метода. Вдруг в списке actions есть еще один экземпляр с нужным type? В этом случае пробрасывать исключение дальше неправильно, поскольку операция теоретически еще может завершиться успешно.

Логирование мы подробно рассмотрим далее в курсе. Пока можете считать, что это то же самое, что и вызов System.out.println.

Но давайте предположим, что SmsNotificationAction не стал соблюдать контракт и в случае ошибки кинул не NotificationActionException, а обычный RuntimeException? Значит, исключение не будет отловлено и цикл прервется раньше, чем необходимо. Проще говоря, бизнес-кейс нарушен.

Так вот, эта ситуация и является примером нарушения принципа подстановки Барбары Лисков. SmsNotificationAction кинул не то исключение, которое от него ожидается. А значит, тот код, который использует интерфейс NotificationService, может поломаться, потому что он полагается лишь на контракт.
Interface Segregation Principle
Принцип разделения интерфейсов является наименее важным, поэтому мы не будем на нем подробно останавливаться. Суть в том, что не надо добавлять все методы в один god interface, а лучше создать несколько, где каждый объявляет связанный набор методов.

Если вам интересно узнать про этот принцип подробнее, можете ознакомиться с этой статьей.
Dependency Inversion Principle
и тестирование
Принцип инверсии зависимости является одним из самых важных в SOLID. Более того, он напрямую связан с тестируемостью кода. Суть в следующем: классы должны зависеть от абстракций, но не это конкретных реализаций. Давайте посмотрим еще раз на объявление класса NotificationService:
public class NotificationService {

  private static final Logger LOG = LoggerFactory.getLogger(NotificationService.class);

  private final List<NotificationAction> actions;

  public NotificationService(List<NotificationAction> actions) {
    this.actions = actions;
  }

  public void notifyOrdering(OrderId orderId, NotificationType type) {
    boolean foundAction = false;
    for (NotificationAction action : actions) {
      if (action.type().equals(type)) {
        foundAction = true;
        try {
          action.notify(orderId);
          break; // выходим из цикла, если нашли нужный action
        } catch (NotificationActionException e) {
          LOG.error("Couldn't execute action '{}'. Trying next one", action, e);
        }
      }
    }
    if (!foundAction) {
      // если не нашли подходящего action, кидаем исключение
      throw new AbsendNotificationException("Couldn't find NotificationAction for type=" + type);
    }
  }
}
Ранее мы обговорили, что у NotificationAction есть две реализации: SMS и email. Здесь у начинающих разработчиков может возникнуть соблазн: давайте сразу присвоим нужные реализации внутри NotificationService и не будем передавать их в конструкторе. Посмотрите на пример кода ниже:
public class NotificationService {

  private static final Logger LOG = LoggerFactory.getLogger(NotificationService.class);

  private final List<NotificationAction> actions = List.of(
      new SmsNotificationAction(),
      new EmailNotificationAction()
  );

  public void notifyOrdering(OrderId orderId, NotificationType type) {
    boolean foundAction = false;
    for (NotificationAction action : actions) {
      if (action.type().equals(type)) {
        foundAction = true;
        try {
          action.notify(orderId);
          break; // выходим из цикла, если нашли нужный action
        } catch (NotificationActionException e) {
          LOG.error("Couldn't execute action '{}'. Trying next one", action, e);
        }
      }
    }
    if (!foundAction) {
      // если не нашли подходящего action, кидаем исключение
      throw new AbsendNotificationException("Couldn't find NotificationAction for type=" + type);
    }
  }
}
Кажется, что код стал проще. Ведь теперь нет необходимости передавать список NotificationAction при создании экземпляров NotificationService. Достаточно просто написать new NotificationService(), и на этом все! В чем же здесь подвох?

Во-первых, здесь нарушается Open-Closed Principle: если добавится новый вариант отправки уведомлений, нам придется явно править код в NotificationService. Тут вы можете возразить, что нам в любом случае придется каким-то образом менять код. Есть ли разница, указываем ли мы новую реализацию при создании NotificationService через new или же фиксируем ее прямо внутри класса? На самом деле, есть значительная разница. И заключается она в тестируемости кода.

Давайте предположим, что мы хотим протестировать NotificationService с учетом того, что мы зафиксировали реализации NotificationAction прямо внутри класса NotificationService. Посмотрите на пример теста ниже.
class NotificationServiceTest {

  @Test
  void shouldThrowExceptionIfActionsListIsEmpty() {
    NotificationService service = new NotificationService();
    assertThrows(
        AbsendNotificationException.class,
        () -> service.notifyOrdering(new OrderId(1), NotificationType.SMS)
    );
  }
}
Здесь мы хотим проверить, что метод notifyOrdering бросит AbsendNotificationException, если мы не смогли найти NotificationAction с типом SMS. По названию же теста понятно, что мы хотим объявить список actionsпустым. Но вот загвоздка. Конструктор NotificationService не принимает никаких параметров, а внутри класса "зашита" функциональность, которая всегда будет создавать список из двух NotificationAction. Следовательно, написать unit-тест на эту часть кода не представляется возможным.

Более того, проблемы могут возникнуть и с инициализацией SmsNotificationAction и EmailNotificationAction. Они взаимодействуют с внешним миром: пытаются отправить SMS или письмо по электронной почте. Значит, при создании эти объекты, скорее всего, пытаются подключиться по сети к каким-то шлюзам, через которые SMS и email-ы можно отправлять. Проще говоря, в тесте, когда вы создаете объект NotificationService, произойдет также попытка соединения с удаленными ресурсами, которые вы не настраивали. Тут тест либо упадет, либо будет выдавать неожиданный результат.

Чтобы проверить работоспособность SmsNotificationAction и EmailNotificationAction, можно применить интеграционные тесты. Мы рассмотрим их далее в курсе, когда перейдем к базам данным.

Еще одно следствие нарушения Dependency Inversion Principle – тесты могут упасть из-за багов в других частях кода. Предположим, что в SmsNotificationAction есть баг, из-за которого отправка уведомлений не работает. Так как NotificationServiceTest напрямую зависит от SmsNotificationAction (неявно экземпляр этого класса инстанцируется в момент создания NotificationService), то и баги в нем будут проникать в NotificationServiceTest. Иначе говоря, ошибка в одной части кода приведет к тому, что каскадом у вас будут падать и другие тесты. Хотя это неправильно: каждый unit-тест должен проверять лишь изолированную часть кода и не зависеть от изменений, которые происходят в других.
Пример тестирования
с соблюдением Dependency Inversion Principle
Что же делать в этой ситуации? Во-первых, давайте устраним нарушение принципа инверсии зависимости и вернем внедрение List<NotificationAction> через конструктор. Посмотрите на пример кода ниже:
public class NotificationService {

  private static final Logger LOG = LoggerFactory.getLogger(NotificationService.class);

  private final List<NotificationAction> actions;

  public NotificationService(List<NotificationAction> actions) {
    this.actions = actions;
  }

  public void notifyOrdering(OrderId orderId, NotificationType type) {
    boolean foundAction = false;
    for (NotificationAction action : actions) {
      if (action.type().equals(type)) {
        foundAction = true;
        try {
          action.notify(orderId);
          break; // выходим из цикла, если нашли нужный action
        } catch (NotificationActionException e) {
          LOG.error("Couldn't execute action '{}'. Trying next one", action, e);
        }
      }
    }
    if (!foundAction) {
      // если не нашли подходящего action, кидаем исключение
      throw new AbsendNotificationException("Couldn't find NotificationAction for type=" + type);
    }
  }
}
Теперь попробуем снова написать тест, проверяющий выбрасывание AbsendNotificationException:
class NotificationServiceTest {

  @Test
  void shouldThrowExceptionIfActionsListIsEmpty() {
    NotificationService service = new NotificationService(Collections.emptyList());
    assertThrows(
        AbsendNotificationException.class,
        () -> service.notifyOrdering(new OrderId(1), NotificationType.SMS)
    );
  }
}
Примеры всех тестов можете посмотреть в репозитории.
Как видите, тест выполняется успешно. Так как при создании объекта NotificationService мы сами решаем, с какими реализациями NotificationAction это нужно делать, то мы можем передать пустой список, чтобы сделать эмуляцию нужного поведения.

Теперь давайте напишем такой тест, который проверит, что вызов notify на нужный action был передан успешно.

Посмотрите на пример кода ниже:
class NotificationServiceTest {
  @Test
  void shouldSucceedNotifying() {
    final var actionResult = new AtomicBoolean(false);
    final var notificationAction = new NotificationAction() {
      @Override
      public NotificationType type() {
        return NotificationType.EMAIL;
      }

      @Override
      public void notify(OrderId orderId) {
        actionResult.set(true);
      }
    };

    NotificationService service = new NotificationService(List.of(notificationAction));

    assertDoesNotThrow(
        () -> service.notifyOrdering(new OrderId(1), NotificationType.EMAIL)
    );
    assertTrue(actionResult.get());
  }
}
Во-первых, мы создаем собственную реализацию NotificationAction, которая существует только в рамках теста. Не стоит завязываться на те реализации, которые уже есть в коде (SmsNotificationAction или EmailNotificationAction), потому что они могут меняться независимо от NotificationService. А как мы уже выяснили, мы не хотим, чтобы NotificationServiceTest зависел от изменений в NotificationAction.

Такие реализации, которые мы создаем специально для тестов, называются стабами.

Далее идет actionResult. Это просто контейнер, который хранит результат успешного выполнения операции notify и стаба. Если бы NotificationService.notifyOrdering возвращал какое-то значение, нам было бы достаточно проверять его в тестах. Но раз этого нет, то обходимся другими вариантами.

Далее мы рассмотрим более элегантную альтернативу.

После этого создаем экземпляр NotificationService и в конструкторе передаем настроенный стаб NotificationAction. Теперь вызываем notifyOrdering и проверяем, что он не бросил никаких исключений.

Ну и наконец смотрим, что в actionResult записано true.
Тест работает без ошибок. Но все равно он выглядит немного вычурным. Этот workaround с actionResult воспринимается как-то неправильно. К счастью, есть альтернатива – мокирование.

Моки и стабы – это почти одно и то же. Разница в том, что моки создаются с помощью библиотек динамически, а стабы мы пишем самостоятельно. Возьмем библиотеку Mockito. Сначала добавьте зависимость на нее в pom.xml:
<dependency>
      <groupId>org.mockito</groupId>
      <artifactId>mockito-core</artifactId>
      <version>5.4.0</version>
      <scope>test</scope>
</dependency>
Теперь давайте напишем еще один тест shouldSucceedNotifyingWithMocks. Посмотрите на пример кода ниже:
class NotificationServiceTest {
  
  @Test
  void shouldSucceedNotifyingWithMocks() {
    final var notificationAction = Mockito.mock(NotificationAction.class);
    Mockito.when(notificationAction.type())
        .thenReturn(NotificationType.EMAIL);

    NotificationService service = new NotificationService(List.of(notificationAction));

    assertDoesNotThrow(
        () -> service.notifyOrdering(new OrderId(1), NotificationType.EMAIL)
    );
    Mockito.verify(notificationAction, Mockito.times(1)).notify(Mockito.any());
  }
}
По смыслу этот тест равнозначен предыдущему. Давайте разберемся в нюансах.

Строчка Mockito.mock(NotificationAction.class) возвращает мок интерфейса NotificationAction. Мок – это обычная реализация, в которой все методы возвращают дефолтное значение (например, null). При этом тело метода отсутствует (то есть в них нет никакой логики).

Особенность Mockito еще и в том, что можно создавать моки не только от интерфейсов, но даже от классов: библиотека во время выполнения создает наследника.

После этого мы настраиваем вызов notificationAction.type() с помощью Mockito.when. Здесь мы сообщаем, что если кто-то вызовет на этом моке метод type(), в ответ он получит NotificationType.EMAIL.

Теперь мы передаем настроенный экземпляр NotificationAction в конструкторе, как и ранее, и вызываем NotificationService.notifyOrdering. Далее интерес представляет Mockito.verify. В нем мы можем проверить, что определенный метод у мока вызывался нужное количество раз. Параметр Mockito.any() означает, что нас не волнует, какой именно экземпляр OrderId был передан в NotificationAction.notify.

В целом, Mockito – не сложная библиотека.
Если вы хотите больше узнать о ее возможностях, можете ознакомиться с этой ссылкой.
Лучше моки или стабы?
Когда разработчики впервые узнают о Mockito, у них может возникнуть желание использовать эту библиотеку всегда и везде. Но мы рекомендуем вам отдавать предпочтение стабам и использовать моки только в тех ситуациях, когда стабов недостаточно.

Классический пример ограничения стабов мы рассмотрели на методе void. Так как он не возвращает никаких значений, проверить его работу с помощью только лишь стабов – сложная задача. В этом случае использование моков оправданно.
Проблема моков в том, что тесты становятся неустойчивыми к рефакторингу. Помните, в первом модуле мы обсуждали инкапсуляцию? Моки нарушают ее в том числе. Потому что тест начинает слишком много заботиться о том, как работает объект внутри и какие методы он вызывает. А тесты должны проверять поведение, а не внутреннюю логику вызовов. Обычно для этого достаточно добавить ассерт на возвращаемое значение.

Наш совет такой: старайтесь всегда использовать стабы, а моки применяйте только тогда, когда стабы не решают проблему.
Jacoco
Тесты полезны, когда они присутствуют. Если их мало, то и толку от них не очень много. В Java есть инструменты, которые проверяют процент покрытия кода тестами и завершают билд с ошибкой, если этот процент ниже некоторого заданного порогового значения.

Давайте добавим Jacoco в наш проект. Для это в pom.xml нужно будет добавить новый блок plugin:
<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <version>0.8.7</version>
  <executions>
    <execution>
      <goals>
        <goal>prepare-agent</goal>
      </goals>
    </execution>
    <execution>
      <id>report</id>
      <phase>prepare-package</phase>
      <goals>
        <goal>report</goal>
      </goals>
    </execution>
    <execution>
      <id>jacoco-check</id>
      <goals>
        <goal>check</goal>
      </goals>
      <configuration>
        <rules>
          <rule>
            <element>PACKAGE</element>
            <limits>
              <limit>
                <counter>LINE</counter>
                <value>COVEREDRATIO</value>
                <minimum>0.50</minimum>
              </limit>
            </limits>
          </rule>
        </rules>
      </configuration>
    </execution>
    <execution>
      <id>post-unit-test</id>
      <phase>test</phase>
      <goals>
        <goal>report</goal>
      </goals>
      <configuration>
        <dataFile>target/jacoco.exec</dataFile>
        <outputDirectory>target/jacoco-ut</outputDirectory>
      </configuration>
    </execution>
  </executions>
</plugin>
Здесь процент покрытия настраивается в блоке <minimum>, и он равен 50 процентам. Чтобы запустить сборку проекта без проверки покрытия, запустите test или package. При этом в директории target/jacoco-utс генерируется отчет о покрытии. Вы можете открыть в браузере index.html, чтобы посмотреть его.

Теперь выполните команду verify. Она идет следом за package и включает в себя выполнение тестов и сборку артефактов. Скорее всего, команда завершится ошибкой, а в логах вы найдете примерно такие сообщения:
Rule violated for package org.example.action: lines covered ratio is 0.00, but expected minimum is 0.50
Rule violated for package org.example.exception: lines covered ratio is 0.33, but expected minimum is 0.50
Добавив всего несколько строчек в xml-файл, мы смогли автоматизировать проверку процента покрытия кода тестами. Теперь давайте сделаем так, чтобы проверка выполнялась на CI. Откройте .github/workflows/build.yml, который мы писали в прошлом модуле, и замените команду mvn package на mvn verify. Сделайте Pull Request и проверьте, что при недостаточном количестве тестов билд падает автоматически и не дает влить Pull Request в master-ветку.

Если у вас возникли трудности с настройкой, посмотрите готовый пример здесь.
Главные выводы по модулю
  • Если вы затрудняетесь написать тест на ваш код, скорее всего, с ним что-то не так. Проверьте соблюдение/нарушение GoF паттернов и принципов SOLID.
  • Никогда не нарушайте принцип инверсии зависимости (Dependency Inversion Principle). Это фундамент качественного и тестируемого кода. Единственное исключение – внедрение тех зависимостей, которые не нужно переопределять. Например, если есть класс Cache который хранит данные внутри в HashMap, то можно объявить ее внутри, потому нет необходимости подменять ее на другую реализацию.
  • Функция main должна быть единственным местом в программе, где вы создаете экземпляры конкретных классов. Проще говоря, в main вы инстанцируете объекты и передаете в конструкторы нужные данные (например, другие объекты). В остальных частях кода выстраиваем зависимости только на абстракции (интерфейсы).
  • Разделяйте ваш код на отдельные и независимые части так, чтобы классы зависели от абстракций (интерфейсов), но не от конкретных реализаций.
  • Unit-тесты не должны зависеть друг от друга. Если вдруг начал падать SmsNotificationActionTest из-за бага, NotificationServiceTest должен продолжить работать.
  • Предпочитайте стабы мокам. Последние следует применять только в тех ситуациях, когда стабов недостаточно.
ТЕСТ
Проверь свои знания
на практике и прикрепляй
в репозиторий скриншот с результатами!
Проходи тест только после того, как изучишь все уроки спринта!
Пройти тест
Зачем нужны unit-тесты?
Тесты в любом случае нужно написать руками. Но запускаться они могут автоматически при каждой сборке.
Верно!
Верно!
Unit-тесты не выявляют наличие зависимостей.
Дальше
Проверить
Узнать результат
Выберите признаки хорошего unit-теста
Верно!
Если новая логика не приводит к новым результатам, то тест падать не должен
Unit-тест должен проверять корректность результата работы кода
Верно!
Верно!
Дальше
Проверить
Узнать результат
Зачем соблюдать принцип инверсии зависимости?
Неверно. Паттерн инверсии зависимости является одним из самых важных в построении приложений в парадигме ООП. Так что соблюдать его необходимо.
Верно! Так класс зависит лишь от абстракций (интерфейсов), и в тестах мы всегда сможет подставить стабы.
Верно! Если класс зависит от интерфейса, то в конструкторе мы можем передать любую реализацию, но не менять сам класс.
Неверно. Паттерн инверсии – важный принцип разработки ООП-программ.
Дальше
Проверить
Узнать результат
Класс зависит от интерфейса OrderRepository, который представляет хранилище.Пока есть реализация, которая хранит все в памяти. Мы добавили новую, которая хранит результаты в БД.

Какой принцип SOLID нужно соблюсти, чтобы замена реализации в том месте, где используется OrderRepository, не сломала код?
Неверно. Этот принцип означает, что у класса есть только одна зона ответственности.
Неверно. Это принцип гласит, что при добавлении новой функциональности мы не должны править старый код.
Верно! Принцип подстановки Барбары Лисков как раз говорит о том, что подстановка любой реализации на место интерфейса не должна ломать код.
Неверно. Этот принцип сообщает о разделении интерфейсов так, чтобы в каждом было сфокусировано небольшое количество методов, которые отвечают за конкретную часть логики.
Неверно. Этот принцип диктует, что классы должны зависеть от абстракций, но не от конкретных реализаций.
Дальше
Проверить
Узнать результат
Как мы проверяем корректность выполнения теста?
Печать в консоль информации в тестах – это антипаттерн, потому что проверка выходит неавтоматизированной (нужно явно смотреть, что напечаталось в консоль).
Верно!
Дальше
Проверить
Узнать результат
Что такое ассерт?
Неверно. Любой тест должен содержать хотя бы один ассерт. Так что подходом это назвать трудно.
Неверно. Такой процесс называется мокированием.
Верно! Например, есть assertEquals, который валидирует два значения на equals. Есть assertThrows, который бросает исключение, если переданная лямбда не выбросила исключение.
Дальше
Проверить
Узнать результат
Практическое задание
Выполняй его после прохождения всех уроков спринта
Practice
Вам необходимо разработать структуру классов, которые предоставят возможность сортировать List элементов разными алгоритмами.

Так как наш модуль посвящен тестированию и паттернам, а не алгоритмам, то писать их самим нет необходимости. Для примера нам хватит двух вариантов сортировки. В качестве первого вы можете просто вызвать Collections.sort(list) (внутри Java использует сортировку слиянием, а для второго реализовать простую сортировку пузырьком (смотрите пример в приложении 1).
!
Пользовательский путь следующий:
  1. Создаем список элементов.
  2. Выбираем алгоритм сортировки из представленных.
  3. Передаем список вместе с типом алгоритма в функцию.
  4. На выходе получаем отсортированный список.
Задание «Зоопарк», которое вы реализовали в прошлом модуле, нам больше не нужно. Вы можете либо добавить новые классы в отдельный package, либо предварительно удалить Зоопарк. Если пойдете по второму варианту, то удалите классы заранее в отдельном Pull Request, прежде чем отправлять работу на ревью ментору. Потому что иначе в изменениях Pull Request-а будет видно удаленные классы с прошлого задания, что усложнит процесс проверки.

Требования к реализации следующие:
  • Построенное решение должно уметь сортировать элементы типа Integer. То есть оперируем типом List<Integer>.
  • Инициализация всей программы должна происходить в main.
  • На каждый класс, кроме Main, должен быть написан отдельный unit-тест.
  • Необходимо выставить процент покрытия кода в Jacoco на 55%. При этом следует поправить запуск билда в .github/workflows/build.yml так, чтобы Jacoco не позволял влить Pull Request с недостаточным покрытием (то есть запустить verify вместо package).
  • Несмотря на то, что алгоритмов сортировки всего два, нужно предусмотреть архитектуру так, чтобы всегда можно было добавить новый алгоритм или поправить старый.
  • Если клиент передает List для сортировки, то он не должен редактироваться на месте – в ответ нужно возвращать новый, но уже отсортированный список. Подумайте, как бы вы это реализовали. Возможно, стоит сделать какую-то обертку над List?
  • У каждой реализации алгоритма может быть задано ограничение на максимальное количество элементов. Если массив превышает это значение, то алгоритм кидает исключение. При этом если какой-то алгоритм кинул такое исключение, мы все равно должны проходиться по циклу в надежде на то, что в списке будет еще один алгоритм с тем же типом сортировки. Если здесь вам сложно понять суть, перечитайте еще раз модуль в части примеров с NotificationService и NotificationAction.

Дедлайн 10.10.2023
Приложение 1
Алгоритм сортировки пузырьком для простого массива типа int.
class Sorts {

  public static void bubbleSort(int[] arr) {
     for (int i = 0; i < arr.length; i++) {
        for (int j = i + 1; j < arr.length; j++) {
           if (arr[i] > arr[j]) {
             int temp = arr[i];
             arr[i] = arr[j];
             arr[j] = temp;
           }
        }
     }
  }
}