JUnit 5 + Mockito — unit-тесты сервисов
Цель урока
Сделать полноценный CRUD для задач с правильными HTTP-статусами (201/204/404), обработкой ошибок, и подготовить Task к подключению JPA.
Теория · для собеса
1 Unit-тест vs Integration-тест ▸
| | Unit | Integration |
|---|---|---|
| Что поднимается | Ничего (только JUnit) | Spring context, БД, Kafka |
| Скорость | Миллисекунды | Секунды-минуты |
| Изоляция | Полная (моки) | Частичная (Testcontainers) |
| Покрытие | Логика класса | Полный flow |
2 Что такое Mock ▸
Mock — это «подделка» объекта, которая:
- Заменяет реальную зависимость
- Позволяет задать ожидания (
when(mock.method()).thenReturn(value)) - Позволяет проверить вызовы (
verify(mock).method())
3 AAA-паттерн (Arrange-Act-Assert) ▸
@Test
void shouldReturnTask_whenTaskExists() {
// ARRANGE — настройка
Task task = new Task("test", null);
when(repo.findById(1L)).thenReturn(Optional.of(task));
// ACT — действие
Optional result = service.findById(1L);
// ASSERT — проверка
assertThat(result).isPresent();
assertThat(result.get().title()).isEqualTo("test");
verify(repo).findById(1L); // метод был вызван 1 раз
}
4
AssertJ (предпочтительнее, чем assertEquals)
▸
assertThat(result).isPresent();
assertThat(result.get().title()).isEqualTo("test");
assertThat(result).hasSize(3).extracting("title").contains("a", "b", "c");
---
Практика: улучшаем CRUD
-
1Зависимости
dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.assertj:assertj-core") }spring-boot-starter-testуже включает JUnit 5, Mockito, AssertJ, Hamcrest. -
2
test/.../service/TaskServiceTest.javapackage com.taskflow.service; import com.taskflow.dto.CreateTaskRequest; import com.taskflow.dto.TaskResponse; import com.taskflow.dto.UpdateTaskRequest; import com.taskflow.exception.AuthException; import com.taskflow.exception.ResourceNotFoundException; import com.taskflow.mapper.TaskMapper; import com.taskflow.model.Task; import com.taskflow.model.User; import com.taskflow.repository.TaskRepository; import com.taskflow.security.CurrentUser; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.time.Instant; import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @DisplayName("TaskService unit tests") class TaskServiceTest { @Mock private TaskRepository taskRepository; @Mock private TaskMapper taskMapper; @Mock private CurrentUser currentUser; @InjectMocks private TaskService taskService; private User alice; private User admin; private Task task1; private Task task2; private TaskResponse response1; @BeforeEach void setUp() { alice = new User("alice", "alice@example.com", "hash", User.Role.USER); alice.setId(1L); admin = new User("admin", "admin@example.com", "hash", User.Role.ADMIN); admin.setId(2L); task1 = new Task("Task 1", "Desc 1"); task1.setId(10L); task1.setOwner(alice); task2 = new Task("Task 2", "Desc 2"); task2.setId(11L); task2.setOwner(alice); response1 = new TaskResponse(10L, "Task 1", "Desc 1", false, 1L, "alice", Instant.now(), Instant.now()); } @Nested @DisplayName("findAll()") class FindAll { @Test @DisplayName("returns only user's tasks for regular USER") void returnsOnlyOwnTasks() { when(currentUser.get()).thenReturn(alice); when(taskRepository.findByOwnerId(1L)).thenReturn(List.of(task1, task2)); when(taskMapper.toResponse(task1)).thenReturn(response1); when(taskMapper.toResponse(task2)).thenReturn( new TaskResponse(11L, "Task 2", "Desc 2", false, 1L, "alice", Instant.now(), Instant.now())); Listresult = taskService.findAll(); assertThat(result).hasSize(2); verify(taskRepository).findByOwnerId(1L); verify(taskRepository, never()).findAll(); } @Test @DisplayName("returns all tasks for ADMIN") void returnsAllTasksForAdmin() { when(currentUser.get()).thenReturn(admin); when(taskRepository.findAll()).thenReturn(List.of(task1, task2)); when(taskMapper.toResponse(any(Task.class))).thenReturn(response1); List result = taskService.findAll(); assertThat(result).hasSize(2); verify(taskRepository).findAll(); verify(taskRepository, never()).findByOwnerId(any()); } } @Nested @DisplayName("findById()") class FindById { @Test @DisplayName("returns task when owner requests own task") void returnsOwnTask() { when(currentUser.get()).thenReturn(alice); when(taskRepository.findById(10L)).thenReturn(Optional.of(task1)); when(taskMapper.toResponse(task1)).thenReturn(response1); TaskResponse result = taskService.findById(10L); assertThat(result.title()).isEqualTo("Task 1"); } @Test @DisplayName("throws AuthException when non-owner USER requests") void throwsWhenAccessingOtherUsersTask() { User bob = new User("bob", "bob@example.com", "h", User.Role.USER); bob.setId(3L); when(currentUser.get()).thenReturn(bob); when(taskRepository.findById(10L)).thenReturn(Optional.of(task1)); assertThatThrownBy(() -> taskService.findById(10L)) .isInstanceOf(AuthException.class) .hasMessageContaining("Access denied"); } @Test @DisplayName("throws ResourceNotFoundException when task doesn't exist") void throwsWhenNotFound() { when(currentUser.get()).thenReturn(alice); when(taskRepository.findById(99L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> taskService.findById(99L)) .isInstanceOf(ResourceNotFoundException.class) .hasMessageContaining("99"); } } @Nested @DisplayName("create()") class Create { @Test @DisplayName("saves task with current user as owner") void createsTaskWithOwner() { CreateTaskRequest req = new CreateTaskRequest("New task", "Desc"); Task newTask = new Task("New task", "Desc"); Task savedTask = new Task("New task", "Desc"); savedTask.setId(20L); savedTask.setOwner(alice); TaskResponse response = new TaskResponse(20L, "New task", "Desc", false, 1L, "alice", Instant.now(), Instant.now()); when(currentUser.get()).thenReturn(alice); when(taskMapper.toEntity(req)).thenReturn(newTask); when(taskRepository.save(any(Task.class))).thenReturn(savedTask); when(taskMapper.toResponse(savedTask)).thenReturn(response); TaskResponse result = taskService.create(req); assertThat(result.id()).isEqualTo(20L); assertThat(newTask.getOwner()).isEqualTo(alice); // owner был выставлен verify(taskRepository).save(newTask); } } @Nested @DisplayName("delete()") class Delete { @Test @DisplayName("deletes task when owner") void deletesOwnTask() { when(currentUser.get()).thenReturn(alice); when(taskRepository.findById(10L)).thenReturn(Optional.of(task1)); taskService.delete(10L); verify(taskRepository).delete(task1); } @Test @DisplayName("throws AuthException when deleting other user's task") void cannotDeleteOthersTask() { User bob = new User("bob", "bob@e.com", "h", User.Role.USER); bob.setId(3L); when(currentUser.get()).thenReturn(bob); when(taskRepository.findById(10L)).thenReturn(Optional.of(task1)); assertThatThrownBy(() -> taskService.delete(10L)) .isInstanceOf(AuthException.class); verify(taskRepository, never()).delete(any()); } } } -
3Запуск
./gradlew test # все тесты ./gradlew test --tests TaskServiceTest # один класс ./gradlew test --tests "*FindById*" # по паттернуВ консоли:
TaskService unit tests > FindAll > returns only user's tasks for regular USER PASSED TaskService unit tests > FindAll > returns all tasks for ADMIN PASSED TaskService unit tests > FindById > returns task when owner requests own task PASSED TaskService unit tests > FindById > throws AuthException when non-owner USER requests PASSED ... -
4Покрытие (JaCoCo)
plugins { jacoco } jacocoTestReport { dependsOn test reports { xml.required = true // для SonarQube html.required = true // для просмотра } } tasks.test { finalizedBy(tasks.jacocoTestReport) }./gradlew test jacocoTestReport # Открыть: build/reports/jacoco/test/html/index.html---
Зачем это на собесе
После урока ты должен уметь ответить на:
5 вопросов на углубление
Раскрой вопрос и нажми «🤔 Хочу разобрать подробнее» — он попадёт в страницу ответов.
Разбор внутри: полностью поддельный объект и частичная подмена реального.
Разбор внутри: проверка лишних вызовов и риск хрупких тестов.
Разбор внутри: проверка аргументов void-методов и вложенных данных.
Разбор внутри: понятная проверка исключений без случайно зелёных тестов.
Разбор внутри: fluent assertions и стандарт Spring Boot тестов.
Готов идти дальше?
Выбери вопросы, которые тебе интересны, и изучи их. Потом — к следующему уроку.
🚀 Перейти к Уроку 18Сначала пройди все секции и выбери хотя бы 1 вопрос
⬅️ Назад к Уроку 16