Серия подготовки: к обзору тем Java-собеседования.

Автоматический тест — небольшая программа, которая вызывает ваш код на известных данных и проверяет результат. Например, передаёт цену 150 копеек и количество 2, затем требует сумму 300. Вместо ручной проверки после каждого изменения вы запускаете набор таких программ и видите, какое правило перестало выполняться.

Проверка ожидаемого результата называется assertion, или утверждением. JUnit помогает запускать тесты и сообщать об ошибках утверждений; Mockito позволяет управлять поведением зависимостей — объектов, к которым обращается проверяемый код. Разберём 20 вопросов: от назначения теста до базы данных и фоновых операций.

Основа примеров — Java 17/21, JUnit Jupiter 5.11.4 и Mockito 5.15.2, для Spring — Framework 6.2 и Boot 3.5. Все суммы и классы учебные. Фрагменты требуют соответствующих импортов и классов приложения; перед каждым объясняется, что уже подготовлено и какой результат нужен.

Как определить, что именно должен доказать тест

1. Что отличает полезный unit-тест от проверки реализации?

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

Для расчёта заказа полезно проверить сумму двух товаров, отказ при нулевом количестве и поведение при слишком больших числах. А требование «сначала вызван внутренний метод A, затем B» может лишь закрепить нынешнее устройство программы. Если расчёт переписан другим способом и по-прежнему верен, полезная проверка результата должна продолжать работать.

Подвох: нельзя получить ожидаемую сумму тем же алгоритмом, который тестируется. Ошибка повторится с обеих сторон и останется незамеченной. Выбирайте небольшие данные с независимым ответом: 150 × 2 легко проверить вручную. Один тест на каждый метод не гарантирует, что проверены важные правила.

2. Чем unit, интеграционный тест и проверка через HTTP отличаются?

Разница — в том, какие части действительно работают. Unit-тест может создать объект расчёта через new и вызвать его напрямую. Интеграционный тест проверяет взаимодействие нескольких настоящих частей: например, код сохранения и базу данных. Он способен обнаружить неверный SQL, которого в отдельном расчёте вообще нет.

HTTP — протокол обмена запросами и ответами между клиентом и сервером. Если отправить настоящий HTTP-запрос, проверяется ещё и сетевой путь: выбран ли нужный обработчик, прочитаны ли поля, какой числовой статус и тело вернулись. Более широкий тест требует больше подготовки и может падать по большему числу причин.

Подвох: название теста не объясняет его состав. Скажите конкретно: «Проверяю расчёт без Spring» или «Выполняю запрос к PostgreSQL». Spring-контекст — контейнер, который создаёт и соединяет объекты приложения. Его можно загрузить для теста, но это ещё не означает наличие настоящей базы или сетевого запроса.

Перед проверкой расчёта и отправки квитанции полезно ответить на четыре вопроса:

Четыре решения до написания теста
  1. Правило

    Что должно получиться: например, квитанция с правильной суммой.

  2. Настоящий код

    Какие объекты выполнят работу: расчёт, сервис или ещё и база.

  3. Условия

    Какие входы и ответы зависимостей задаём заранее.

  4. Проверка

    По какому результату отличим правильное выполнение от ошибки.

JUnit: запуск и точный смысл assertions

3. Что в JUnit 5 означает Jupiter и зачем нужен test engine?

JUnit 5 состоит из нескольких частей. Platform даёт общую основу поиска и запуска тестов. Jupiter задаёт способ их написания: аннотации, проверки и расширения. Его test engine, движок запуска, узнаёт тесты Jupiter и выполняет их на платформе.

Поэтому одной записи @Test над методом мало. Среда разработки или сборка должна подключить нужные библиотеки и движок. Аннотация Jupiter находится в org.junit.jupiter.api.Test. Случайный импорт похожей аннотации JUnit 4 способен изменить, какие тесты найдутся. Для старых тестов есть отдельный движок совместимости, если он подключён.

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

4. Создаётся ли новый экземпляр тестового класса перед каждым тестом?

Тестовый класс — такой же Java-класс с полями и методами. По стандартному режиму Jupiter PER_METHOD для отдельного теста создаёт новый экземпляр класса. Это помогает не переносить значения обычных полей из предыдущего теста в следующий. @BeforeEach отмечает подготовку перед запуском, @AfterEach — очистку после него.

Режим PER_CLASS использует один экземпляр на класс. Тогда изменяемые поля могут остаться от предыдущего метода; очистку нужно продумать. Такой режим также позволяет сделать @BeforeAll, общую подготовку до всех методов, нестатическим. Настройки проекта могут менять стандартный режим, поэтому их проверяют.

Подвох: новый экземпляр тестового класса не очищает статический список, общий Spring-объект или строки базы. Это разные владельцы данных. Для параметризованного теста подготовка и очистка выполняются на каждый набор аргументов. Проверки не должны случайно зависеть от порядка запуска.

5. Когда параметризованный тест лучше нескольких отдельных методов?

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

В примере PricePolicy.totalCents умножает неотрицательную цену в копейках на положительное количество; слишком большое произведение обнаруживает через Math.multiplyExact. @CsvSource даёт три строки: цена, количество, ожидаемая сумма. Для строки 150, 2, 300 JUnit создаёт объект расчёта, вызывает его и сравнивает ответ с 300. Затем повторяет действия для остальных строк.

@ParameterizedTest
@CsvSource({"150, 2, 300", "0, 3, 0", "99, 1, 99"})
void calculatesTotal(int price, int quantity, int expected) {
    PricePolicy policy = new PricePolicy();
    assertEquals(expected, policy.totalCents(price, quantity));
}

Результат — отдельный исход для каждого набора. assertEquals получает сначала ожидаемое, затем фактическое значение. Подвох: пустой текст, отсутствие значения и null не всегда означают одно и то же; у CSV есть свои правила их записи. Для сложных объектов подходит @MethodSource, метод подготовки аргументов. Он тоже не должен вычислять ожидания проверяемым алгоритмом. Несвязанные сценарии удобнее оставить отдельными тестами.

6. Чем assertEquals отличается от assertSame, assertArrayEquals и assertAll?

assertEquals отвечает на вопрос «равны ли значения по выбранным правилам?». assertSame проверяет другое: «это один и тот же объект в памяти?». Два отдельно созданных заказа способны иметь одинаковые поля и разные ссылки. Если требование допускает новый объект с теми же данными, проверять совпадение ссылок неправильно.

assertArrayEquals сравнивает содержимое массивов. assertAll объединяет связанные проверки, например номер и сумму одной квитанции: обычная ошибка первой проверки не мешает сообщить и об ошибке второй. Это помогает увидеть несколько проблем за один запуск, но не требует объединять все несвязанные требования в огромный метод.

Подвох: равенство определяется типом данных. У BigDecimal equals учитывает число десятичных знаков: 2.0 и 2.00 численно равны, но equals различает их. Проверяйте то, что требует задача. И если проверяете собственную реализацию equals, создавайте независимые экземпляры: сравнение ссылки с ней же способно скрыть ошибку метода.

7. Как правильно проверять исключение?

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

На вход ниже идут цена 150 и количество 0. () -> ... — короткая запись действия, которое JUnit выполнит внутри assertThrows. Объект PricePolicy уже создан отдельно. Ожидаемый результат — IllegalArgumentException с сообщением об ошибочном количестве. Возвращённое исключение сохраняется в error, затем отдельно проверяется сообщение.

PricePolicy policy = new PricePolicy();
IllegalArgumentException error = assertThrows(
    IllegalArgumentException.class,
    () -> policy.totalCents(150, 0)
);
assertEquals("quantity must be positive", error.getMessage());

Подвох: внутри проверяемого действия не стоит делать ещё и всю подготовку. Иначе нужное исключение может выбросить создание данных, а сам расчёт не выполнится. Слишком общий Exception.class скрывает такую ошибку. Текст сообщения закрепляют лишь там, где его точность действительно часть требования.

Mockito: что заменяется и что действительно проверяется

8. Чем mock, stub, fake и spy отличаются друг от друга?

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

Stub даёт подготовленный ответ. Mock позволяет ещё и выяснить, как к нему обращались. Fake содержит упрощённую рабочую логику, например хранит записи в списке вместо базы. Spy выполняет настоящие методы, кроме тех, чьё поведение заменили. Сравним их для сервиса заказа:

Как выбрать двойник по задаче проверки
ВариантКак помогает тестуЧто остаётся непроверенным
StubЗаранее отвечает, что свободных мест нет.Как настоящий сервис определяет наличие мест.
MockЗапоминает переданную квитанцию и число обращений.Доставлено ли настоящее письмо.
FakeХранит и ищет заказы в памяти.Блокировки и ограничения настоящей базы.
SpyВыполняет реальный код, но выбранное чтение заменено готовым ответом.Подменённая операция; остальные действия могут остаться настоящими.

Подвох: это роли, а не всегда разные виды Java-классов. Один объект Mockito может и отдавать ответ как stub, и запоминать обращения как mock. Если заменить вообще весь код, полезной работы для проверки может не остаться. Простое вычисление и значения квитанции обычно стоит оставить настоящими.

9. Почему @Mock остаётся null и как подготовить зависимости?

@Mock — пометка поля, а не выполняемая команда создания объекта. Её должна обработать Mockito-интеграция. Для Jupiter это обычно расширение MockitoExtension, подключённое через @ExtendWith. Можно также явно вызвать mock(...), не используя помеченное поле.

В примере ReceiptGateway — объект отправки квитанций, а Receipt — набор номера заказа и суммы. Расширение создаёт двойник gateway перед тестом. Настоящий ReceiptService получает его в конструкторе, затем обрабатывает подтверждение заказа B-17 с суммой 300.

@ExtendWith(MockitoExtension.class)
class ReceiptServiceTest {
    @Mock ReceiptGateway gateway;

    @Test
    void sendsReceipt() {
        ReceiptService service = new ReceiptService(gateway);
        service.confirm("B-17", 300);
        verify(gateway).send(new Receipt("B-17", 300));
    }
}

Последняя строка проверяет, что gateway получил одну квитанцию с этими данными. verify здесь проверяет обращение, а не доставку письма. Равенство полей Receipt позволяет сравнить его с отдельно созданным ожидаемым значением.

Подвох: @InjectMocks может помочь передать зависимости, но не воспроизводит контейнер Spring, его настройки и прокси — объекты-посредники, добавляющие обработку вокруг методов. Явное создание сервиса через конструктор хорошо показывает, какой код остался настоящим.

10. Чем stubbing отличается от verify и что возвращает ненастроенный mock?

Stubbing — настройка ответа двойника до выполнения действия. Например: «когда хранилище спросят об остатке, ответь 5». Это условие теста. verify выполняют после действия: «убедись, что отправитель получил нужную квитанцию». Это проверка произошедшего обращения.

Если mock не настроен, он возвращает значения по умолчанию: для разных типов это могут быть ноль, false, null или поддерживаемое пустое значение. Утверждение «всегда null» неверно. Случайно подходящий ноль может скрыть, что нужная настройка не сработала.

Подвох: если настроить when(service.total()).thenReturn(300), а затем проверить, что этот же mock вернул 300, сам расчёт не выполнится. Получится проверка собственной настройки. Обычно настоящим оставляют предмет проверки, а заменяют его зависимости. Ожидаемый результат должен получиться из работы настоящего кода.

11. Как работают argument matchers и совпадает ли anyString с null?

Настройка ответа должна определить, каким аргументам она подходит. Matcher — специальное указание Mockito: например, равен ли аргумент строке email или подходит ли любая строка. anyString() допускает и пустую строку, но не null.

Уведомитель sender ниже принимает канал и текст и возвращает признак принятия. Первая настройка отвечает true для канала email и строкового текста. Вторая отвечает false для email и null, если такой вход разрешён задачей. Это две настройки зависимости; проверяемый сервис должен быть вызван отдельно.

when(sender.send(eq("email"), anyString())).thenReturn(true);

// Если null допустим именно по контракту:
when(sender.send(eq("email"), isNull())).thenReturn(false);

eq("email") означает точное равенство. Если в одном вызове использовали matcher хотя бы для одного аргумента, остальные аргументы этого же вызова тоже задаются matchers. isNull() явно описывает отсутствие значения.

Подвох: неожиданный null не подходит настройке anyString и может дать ответ по умолчанию. Matchers не сохраняют в переменные как обычные функции проверки: они регистрируются механизмом Mockito. В строгом тесте оставляйте настройки тех ветвей, которые этот тест действительно выполняет.

12. Почему when(spy.method()) может выполнить настоящий метод?

В отличие от обычного mock, spy по умолчанию исполняет реальный код. Java сначала вычисляет аргумент when, поэтому выражение when(spy.read(...)) уже способно прочитать файл. Если файла нет, ошибка случится ещё во время настройки.

У учебного ReportReader метод read читает файл отчёта. Нужный вход — имя day.csv, подготовленный результат — строка prepared report. doReturn задаёт ответ без предварительного выполнения read. Следующий вызов read получает заданный текст, и assertEquals сравнивает его с ожиданием.

ReportReader reader = spy(new ReportReader());
doReturn("prepared report").when(reader).read("day.csv");
assertEquals("prepared report", reader.read("day.csv"));

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

Подвох: другие методы spy способны читать и менять реальные ресурсы. Учитывайте также версию и mock maker — механизм создания двойников: старые ограничения final-методов нельзя автоматически приписывать Mockito 5. Множество частичных подмен часто означает, что ответственность проверяемого объекта стоит разделить яснее.

13. Почему возникает UnnecessaryStubbingException и что означает strict stubbing?

Strict stubbing — строгая проверка настроек ответов. Она помогает заметить настройку, которая нигде не использовалась, либо обращение с неожиданными аргументами. Например, общая подготовка настроила поиск покупателя, а тест передал неверную сумму и сервис правильно отказал ещё до поиска. Настройка оказалась лишней.

Сначала выясните причину: сервис действительно должен был дойти до хранилища или этот сценарий проверяет ранний отказ? В первом случае может быть ошибка пути выполнения; во втором настройку лучше убрать из общей подготовки и оставить только нужным тестам.

Подвох: строгость зависит от интеграции и конфигурации: отдельный вызов mock(...) не означает автоматически ту же политику, что расширение Jupiter. lenient ослабляет проверки. Применять его ко всему набору, только чтобы исчезла ошибка, — значит скрыть и полезные сигналы. Точечное ослабление требует понятной причины допустимого неиспользования.

14. Когда нужен ArgumentCaptor и нужно ли проверять все вызовы?

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

Ниже gateway уже создан, а service.confirm("B-17", 300) уже выполнен. Captor перехватывает квитанцию из вызова send; getValue() отдаёт её тесту. Затем assertAll запускает две отложенные проверки: номер должен быть B-17, сумма — 300.

ArgumentCaptor<Receipt> captor = ArgumentCaptor.forClass(Receipt.class);
verify(gateway).send(captor.capture());
Receipt sent = captor.getValue();
assertAll(
    () -> assertEquals("B-17", sent.orderId()),
    () -> assertEquals(300, sent.totalCents())
);

Результат — проверка содержимого квитанции и нужного обращения к gateway. Если обычного equals достаточно, captor не обязателен. Подвох: это по-прежнему не проверка реальной доставки письма. И verifyNoMoreInteractions, запрет любых других обращений, нужен не повсюду: он может закрепить безвредные внутренние чтения. Количество важно, когда требование запрещает повторную отправку или другое лишнее действие.

Spring и база: какие ошибки остаются за пределами mock

15. Чем @WebMvcTest и @DataJpaTest отличаются от @SpringBootTest?

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

@WebMvcTest создаёт окружение MVC: можно проверить выбор контроллера, чтение тела, ограничения входных данных и ответ. Внешние для этого слоя зависимости сервиса предоставляют отдельно. @DataJpaTest сосредоточен на JPA — связи объектов с таблицами — и доступе к данным. @SpringBootTest загружает более полный контекст приложения.

Подвох: @SpringBootTest по умолчанию не запускает настоящий сервер. Это зависит от webEnvironment. MockMvc вызывает веб-обработку в тестовом окружении без настоящего сетевого обмена. Успех контроллерного теста с подменённым сервисом не доказывает правильность SQL; тест хранилища не доказывает нужный статус при неверном JSON. Выбирают состав проверки под конкретную ошибку.

16. Всегда ли @Transactional на тесте откатывает изменения приложения?

Транзакция объединяет изменения базы: при commit база окончательно принимает их, при rollback отменяет. В Spring-тесте @Transactional обычно создаёт транзакцию для тестового метода и по умолчанию откатывает её после окончания. Так подготовленные или изменённые этим способом данные не должны оставаться следующему тесту.

Но такое управление связано с потоком выполнения. Если тест отправляет настоящий HTTP-запрос серверу, сервер обрабатывает его в другом потоке и может начать собственную транзакцию. Она способна уже завершиться commit. Откат транзакции теста не отменяет отдельно сохранённые изменения сервера. Внутренняя REQUIRES_NEW, то есть самостоятельная транзакция, тоже может завершиться отдельно.

Подвох: assertTimeoutPreemptively запускает проверяемое действие в другом потоке, что также может вывести запись из тестовой транзакции. Кроме того, ORM умеет отложить SQL до flush — отправки накопленных изменений базе. Если нужно увидеть ошибку ограничения во время теста, синхронизацию вызывают в нужном месте. Flush ещё не означает успешный commit, поэтому обычный откатываемый тест не доказывает все условия окончательного сохранения.

17. Почему H2 или fake-репозиторий не доказывает правильность PostgreSQL и что даёт Testcontainers?

Один и тот же интерфейс хранилища может скрывать разные реализации. Список в памяти не воспроизводит типы, ограничения и одновременные изменения настоящей базы. H2 — самостоятельная база, и её SQL, блокировки и правила видимости изменений могут отличаться от PostgreSQL.

Чтобы проверить конкретный запрос PostgreSQL, полезно выполнить его именно в PostgreSQL с нужной структурой таблиц. Миграции — сохранённые изменения этой структуры; применение тех же миграций помогает приблизить тест к приложению. Testcontainers управляет запуском нужных внешних программ в контейнерах — изолированных окружениях, например с PostgreSQL.

Для этого требуется среда выполнения с совместимым Docker API. Библиотека сама по себе не превращает PostgreSQL во встроенный Java-объект. В интеграции Jupiter статическое поле контейнера разделяется методами класса, обычное поле запускается и останавливается для отдельного теста.

Подвох: общий контейнер не очищает автоматически данные между методами. И перезапуск не заменяет продуманную подготовку таблиц и строк. Нужно определить, какие данные создаёт каждый тест и кто их убирает. Для простой суммы база обычно не нужна; она нужна там, где проверяется настоящее взаимодействие с ней.

Управляемое время и завершение работы

18. Как проверять асинхронный результат без случайного Thread.sleep?

Асинхронная операция может продолжаться после возврата вызвавшего её метода. Если отправить задачу и сразу проверить результат, задача ещё могла не выполниться. Фиксированный Thread.sleep лишь ждёт выбранное число миллисекунд: на медленной машине этого мало, на быстрой тест зря теряет время.

Лучше ждать самого завершения. В примере ExecutorService выполняет задачу в отдельном потоке. Вход — действие, возвращающее строку ready. submit отдаёт Future, объект будущего результата, а get ждёт его не дольше секунды. Проверка ожидает строку ready, и блок finally завершает созданный executor.

ExecutorService executor = Executors.newSingleThreadExecutor();
try {
    Future<String> result = executor.submit(() -> "ready");
    assertEquals("ready", result.get(1, TimeUnit.SECONDS));
} finally {
    executor.shutdownNow();
}

Ограничение в секунду защищает от зависшего теста, а не измеряет скорость приложения. Подвох: отправка задачи не доказывает её успех. Ошибка фонового вычисления может обнаружиться при получении Future. Для более сложного сценария можно ждать явный сигнал через latch — средство ожидания события — либо управлять выполнением очереди. Оставленные после теста потоки мешают другим проверкам; один удачный запуск не доказывает отсутствие ошибок одновременного доступа.

19. Как проверить код, который использует текущее время?

Если правило «удержание истекает через десять минут» использует настоящие часы, они меняются между выполнением действия и проверкой. Вместо соревнования с ними передайте источник времени зависимостью. В Java для этого есть Clock; в приложении он может давать обычное время, а в тесте — фиксированное.

Вход ниже — момент 31 октября 2026 года, 09:00 UTC. Clock.fixed всегда возвращает этот момент. Учебный HoldPolicy получает часы в конструкторе, а expiresAt() прибавляет десять минут к clock.instant(). Ожидаемый результат — 09:10 того же дня.

Clock clock = Clock.fixed(
    Instant.parse("2026-10-31T09:00:00Z"), ZoneOffset.UTC);
HoldPolicy policy = new HoldPolicy(clock);
assertEquals(
    Instant.parse("2026-10-31T09:10:00Z"),
    policy.expiresAt()
);

Подвох: управляемые часы помогают только там, где код действительно обращается к переданному Clock. Если соседний метод самостоятельно читает системное время, часть проверки остаётся случайной. Для календарных правил и часовых поясов выбирайте примеры, где различия видны; допуск в несколько секунд способен скрыть ошибку вместо проверки точного правила.

20. Доказывает ли высокий coverage, что программа работает правильно?

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

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

Подвох: его показатели тоже требуют разбора: некоторые изменения не меняют смысл поведения. И проверка каждого внутреннего вызова ради большого процента способна сделать код трудным для изменения без дополнительной защиты правил. После рефакторинга — изменения внутреннего устройства при сохранении поведения — полезный тест продолжает проверять то же требование.

Как разобрать требование на собеседовании

Возьмите правило: подтверждение заказа должно отправить одну квитанцию с верной суммой, а неверное количество должно дать отказ. Сначала объясните проверку расчёта на маленьких числах. Затем оставьте настоящий сервис, передайте управляемый отправитель и проверьте квитанцию. Для записи заказа в базу добавьте отдельную проверку настоящего взаимодействия.

Для каждого теста назовите вход, выполняемый код, заменённые зависимости и ожидаемый результат. Добавьте обычный случай, границу и ошибку зависимости. Проверьте, что результат получен настоящим действием, данные не остались от предыдущего теста, а фоновая работа действительно закончилась. Это помогает обосновать выбор инструментов и увидеть, что ещё остаётся непроверенным.