УРОК 17 / 30 0%

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. 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. 2
    test/.../service/TaskServiceTest.java
    
    package 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()));
    
                List result = 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. 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. 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
    

    ---

🎯

Зачем это на собесе

После урока ты должен уметь ответить на:

Регрессии: unit-тесты ловят баги ДО продакшена. Без них — каждый refactor лотерея.
Скорость: 100 unit-тестов = 1 сек. 100 integration = минуты. Запускаем unit на каждый save.
Документация: тест показывает как использовать класс. Junior читает тесты вместо доки.
TDD: пишем тест ДО кода → думаем о контракте, потом реализуем.

5 вопросов на углубление

Раскрой вопрос и нажми «🤔 Хочу разобрать подробнее» — он попадёт в страницу ответов.

1
@Mock vs @Spy — в чём разница?

Разбор внутри: полностью поддельный объект и частичная подмена реального.

2
Когда использовать verifyNoMoreInteractions?

Разбор внутри: проверка лишних вызовов и риск хрупких тестов.

3
Для чего нужен ArgumentCaptor?

Разбор внутри: проверка аргументов void-методов и вложенных данных.

4
try/catch в тесте vs assertThrows

Разбор внутри: понятная проверка исключений без случайно зелёных тестов.

5
JUnit Assertions vs AssertJ — что читать проще?

Разбор внутри: fluent assertions и стандарт Spring Boot тестов.

Готов идти дальше?

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

🚀 Перейти к Уроку 18

Сначала пройди все секции и выбери хотя бы 1 вопрос

⬅️ Назад к Уроку 16
🎉
Новый тир
Новый тир достигнут!