Skip to main content

Week 7 Day 2 — Unit Testing Services with JUnit 5, AssertJ, and Mockito

Goal

Today I want to understand how to write good unit tests for service classes.

Main questions:

  1. What is a service unit test?
  2. Why should unit tests avoid Spring context?
  3. What is JUnit 5?
  4. What is AssertJ?
  5. What is Mockito?
  6. What is a mock?
  7. What is a stub?
  8. What is verification?
  9. What is ArgumentCaptor?
  10. How do I test service business logic?
  11. How do I test exceptions?
  12. How do I avoid bad unit tests?

1. Quick Review from Week 7 Day 1

In Day 1, I learned:

  • Tests protect behavior.
  • Different problems need different test types.
  • Unit tests do not start Spring.
  • Slice tests load one part of Spring.
  • Integration tests check multiple parts together.
  • @SpringBootTest loads the full application context.
  • @WebMvcTest tests controllers.
  • @DataJpaTest tests repositories.
  • Choose the smallest test that proves the behavior.

Memory sentence:

Choose the smallest test that proves the behavior.

Today I focus on the smallest and fastest type:

Unit tests for service/business logic.

2. What Is a Service Unit Test?

A service unit test checks one service class without starting Spring.

Example target:

public class TaskService {
...
}

The test creates the service manually:

TaskService taskService = new TaskService(taskRepository, clock);

Dependencies are usually mocked:

TaskRepository taskRepository = mock(TaskRepository.class);
Clock clock = Clock.fixed(...);

Important:

No @SpringBootTest.
No @WebMvcTest.
No @DataJpaTest.
No database.
No real HTTP.
No Spring context.

Memory sentence:

A service unit test tests service logic without Spring.


3. Why Avoid Spring in Unit Tests?

Starting Spring is useful when I need Spring.

But for simple service logic, Spring is not necessary.

Unit tests are faster because they avoid:

application context startup
bean scanning
auto-configuration
database connection
web layer
security filters
migrations
external infrastructure

Bad:

@SpringBootTest
class TaskServiceTest {
}

for simple pure business logic.

Better:

class TaskServiceTest {
}

Memory sentence:

If the test does not need Spring, do not start Spring.


4. Testing Tools

Common tools:

JUnit 5 = test framework
AssertJ = readable assertions
Mockito = mocks and verification

Example:

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

Memory sentence:

JUnit runs the test, AssertJ checks results, Mockito fakes dependencies.


5. JUnit 5

JUnit 5 is the testing framework.

It provides annotations like:

@Test
@BeforeEach
@AfterEach
@Nested
@DisplayName
@ParameterizedTest

Most common:

@Test
void createsTask() {
}

Memory sentence:

JUnit defines and runs test methods.


6. Basic JUnit Test

Class under test:

public class DiscountCalculator {

public int calculateDiscount(int price, int percent) {
return price * percent / 100;
}
}

Test:

class DiscountCalculatorTest {

private final DiscountCalculator calculator = new DiscountCalculator();

@Test
void calculatesDiscount() {
int result = calculator.calculateDiscount(100, 10);

assertThat(result).isEqualTo(10);
}
}

This is a pure unit test.

No Spring.

Memory sentence:

A unit test can be just one class and one assertion.


7. AssertJ

AssertJ gives fluent assertions.

Example:

assertThat(result).isEqualTo(10);

More examples:

assertThat(name).isEqualTo("Steve");
assertThat(tasks).hasSize(2);
assertThat(tasks).extracting(TaskDto::title).containsExactly("Task A", "Task B");
assertThat(optional).isPresent();
assertThat(user.isEnabled()).isTrue();

Memory sentence:

AssertJ makes assertions readable.


8. JUnit Assertions vs AssertJ

JUnit:

assertEquals(10, result);
assertTrue(user.isEnabled());

AssertJ:

assertThat(result).isEqualTo(10);
assertThat(user.isEnabled()).isTrue();

Both work.

Many Spring Boot projects use AssertJ because it is expressive.

Memory sentence:

AssertJ reads like English.


9. Mockito

Mockito creates fake dependencies.

Example:

TaskRepository taskRepository = mock(TaskRepository.class);

Then I can tell the mock what to return:

when(taskRepository.findById(1L))
.thenReturn(Optional.of(task));

Memory sentence:

Mockito lets me replace real dependencies with controllable test doubles.


10. Mock vs Stub

People often mix these words.

Simple beginner version:

Stub

A stub provides fake data.

Example:

when(taskRepository.findById(1L))
.thenReturn(Optional.of(task));

Mock

A mock can also verify interaction.

Example:

verify(taskRepository).save(any(TaskEntity.class));

Memory sentence:

Stub means fake return. Mock means fake object that can also verify calls.


11. Service Example

Domain entity:

public class TaskEntity {

private Long id;
private String title;
private String status;

public TaskEntity(Long id, String title, String status) {
this.id = id;
this.title = title;
this.status = status;
}

public TaskEntity(String title, String status) {
this.title = title;
this.status = status;
}

public void complete() {
if ("DONE".equals(status)) {
throw new IllegalStateException("Task is already done");
}

this.status = "DONE";
}

public Long getId() {
return id;
}

public String getTitle() {
return title;
}

public String getStatus() {
return status;
}
}

DTO:

public record TaskDto(
Long id,
String title,
String status
) {
}

Repository:

public interface TaskRepository {

Optional<TaskEntity> findById(Long id);

TaskEntity save(TaskEntity task);
}

Service:

public class TaskService {

private final TaskRepository taskRepository;

public TaskService(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}

public TaskDto findById(Long id) {
TaskEntity task = taskRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Task", id));

return toDto(task);
}

public TaskDto create(String title) {
if (title == null || title.isBlank()) {
throw new IllegalArgumentException("Title must not be blank");
}

TaskEntity task = new TaskEntity(title, "OPEN");

TaskEntity saved = taskRepository.save(task);

return toDto(saved);
}

public TaskDto complete(Long id) {
TaskEntity task = taskRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Task", id));

task.complete();

return toDto(task);
}

private TaskDto toDto(TaskEntity task) {
return new TaskDto(
task.getId(),
task.getTitle(),
task.getStatus()
);
}
}

Exception:

public class ResourceNotFoundException extends RuntimeException {

public ResourceNotFoundException(String resource, Long id) {
super(resource + " not found: " + id);
}
}

Now I can unit test this service.


12. Basic Service Unit Test Structure

class TaskServiceTest {

private final TaskRepository taskRepository = mock(TaskRepository.class);

private final TaskService taskService = new TaskService(taskRepository);

@Test
void returnsTaskById() {
// Arrange
TaskEntity task = new TaskEntity(1L, "Learn unit tests", "OPEN");

when(taskRepository.findById(1L))
.thenReturn(Optional.of(task));

// Act
TaskDto result = taskService.findById(1L);

// Assert
assertThat(result.id()).isEqualTo(1L);
assertThat(result.title()).isEqualTo("Learn unit tests");
assertThat(result.status()).isEqualTo("OPEN");
}
}

Memory sentence:

Arrange data, call method, assert result.


13. Arrange, Act, Assert

A clean test has three parts:

Arrange
Act
Assert

Example:

@Test
void returnsTaskById() {
// Arrange
TaskEntity task = new TaskEntity(1L, "Learn unit tests", "OPEN");
when(taskRepository.findById(1L)).thenReturn(Optional.of(task));

// Act
TaskDto result = taskService.findById(1L);

// Assert
assertThat(result.title()).isEqualTo("Learn unit tests");
}

Memory sentence:

A good test is easy to read in three steps.


14. Testing Successful Read

@Test
void returnsTaskById() {
TaskEntity task = new TaskEntity(1L, "Prepare documents", "OPEN");

when(taskRepository.findById(1L))
.thenReturn(Optional.of(task));

TaskDto result = taskService.findById(1L);

assertThat(result).isEqualTo(
new TaskDto(1L, "Prepare documents", "OPEN")
);
}

This tests:

repository returns entity
service maps entity to DTO
service returns expected DTO

Memory sentence:

Test the observable result, not private implementation.


15. Testing Not Found

Service:

public TaskDto findById(Long id) {
TaskEntity task = taskRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Task", id));

return toDto(task);
}

Test:

@Test
void throwsWhenTaskNotFound() {
when(taskRepository.findById(99L))
.thenReturn(Optional.empty());

assertThatThrownBy(() -> taskService.findById(99L))
.isInstanceOf(ResourceNotFoundException.class)
.hasMessage("Task not found: 99");
}

Memory sentence:

Unit tests should cover both success and failure paths.


16. Testing Create

Service:

public TaskDto create(String title) {
if (title == null || title.isBlank()) {
throw new IllegalArgumentException("Title must not be blank");
}

TaskEntity task = new TaskEntity(title, "OPEN");

TaskEntity saved = taskRepository.save(task);

return toDto(saved);
}

Test:

@Test
void createsTask() {
when(taskRepository.save(any(TaskEntity.class)))
.thenReturn(new TaskEntity(1L, "Learn Mockito", "OPEN"));

TaskDto result = taskService.create("Learn Mockito");

assertThat(result.id()).isEqualTo(1L);
assertThat(result.title()).isEqualTo("Learn Mockito");
assertThat(result.status()).isEqualTo("OPEN");
}

This checks result.

But it does not check what was passed to save.

For that, use ArgumentCaptor.


17. Argument Matchers

Mockito matchers:

any()
anyLong()
anyString()
eq(...)
isNull()

Example:

when(taskRepository.save(any(TaskEntity.class)))
.thenReturn(savedTask);

Example with exact value:

verify(taskRepository).findById(eq(1L));

Important:

If you use matchers for one argument, use matchers for all arguments in that call.

Memory sentence:

Mockito matchers help ignore unimportant argument details.


18. Verification

Verification checks that a mock was called.

Example:

verify(taskRepository).findById(1L);

Example:

verify(taskRepository).save(any(TaskEntity.class));

Example never called:

verify(taskRepository, never()).save(any());

Example called once:

verify(taskRepository, times(1)).save(any());

Memory sentence:

Verification checks interactions with mocks.


19. When to Verify

Verify interactions when the interaction is part of the behavior.

Good examples:

email was sent
repository save was called
event was published
external API client was called
delete method was called

Avoid verifying every internal call.

Bad style:

verify(repository).findById(1L);
verify(mapper).toDto(task);
verify(clock).instant();
verify(repository, times(1)).findById(1L);

when the final result already proves the behavior.

Memory sentence:

Verify important side effects, not every implementation detail.


20. ArgumentCaptor

ArgumentCaptor captures an argument passed to a mock.

Useful when I want to inspect what was saved or sent.

Example:

ArgumentCaptor<TaskEntity> captor =
ArgumentCaptor.forClass(TaskEntity.class);

verify(taskRepository).save(captor.capture());

TaskEntity savedTask = captor.getValue();

assertThat(savedTask.getTitle()).isEqualTo("Learn Mockito");
assertThat(savedTask.getStatus()).isEqualTo("OPEN");

Memory sentence:

ArgumentCaptor lets me inspect what was passed to a mock.


21. Create Test with ArgumentCaptor

@Test
void createsOpenTask() {
when(taskRepository.save(any(TaskEntity.class)))
.thenAnswer(invocation -> {
TaskEntity task = invocation.getArgument(0);
return new TaskEntity(1L, task.getTitle(), task.getStatus());
});

TaskDto result = taskService.create("Learn ArgumentCaptor");

ArgumentCaptor<TaskEntity> captor =
ArgumentCaptor.forClass(TaskEntity.class);

verify(taskRepository).save(captor.capture());

TaskEntity taskToSave = captor.getValue();

assertThat(taskToSave.getTitle()).isEqualTo("Learn ArgumentCaptor");
assertThat(taskToSave.getStatus()).isEqualTo("OPEN");

assertThat(result.id()).isEqualTo(1L);
assertThat(result.title()).isEqualTo("Learn ArgumentCaptor");
assertThat(result.status()).isEqualTo("OPEN");
}

This test checks:

service creates entity with correct title
service creates entity with OPEN status
service saves it
service returns DTO from saved entity

Memory sentence:

Use ArgumentCaptor when the created object matters.


22. thenReturn vs thenAnswer

thenReturn returns a fixed value:

when(taskRepository.save(any(TaskEntity.class)))
.thenReturn(new TaskEntity(1L, "Task A", "OPEN"));

thenAnswer calculates response from invocation:

when(taskRepository.save(any(TaskEntity.class)))
.thenAnswer(invocation -> {
TaskEntity task = invocation.getArgument(0);
return new TaskEntity(1L, task.getTitle(), task.getStatus());
});

Use thenAnswer when returned value depends on input.

Memory sentence:

thenReturn is fixed; thenAnswer is dynamic.


23. Testing Validation Logic

Service:

public TaskDto create(String title) {
if (title == null || title.isBlank()) {
throw new IllegalArgumentException("Title must not be blank");
}

TaskEntity task = new TaskEntity(title, "OPEN");
TaskEntity saved = taskRepository.save(task);

return toDto(saved);
}

Test blank title:

@Test
void rejectsBlankTitle() {
assertThatThrownBy(() -> taskService.create(" "))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Title must not be blank");

verify(taskRepository, never()).save(any());
}

This checks:

exception is thrown
repository is not called

Memory sentence:

When validation fails, verify that no write happens.


24. Testing Complete Task

Service:

public TaskDto complete(Long id) {
TaskEntity task = taskRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Task", id));

task.complete();

return toDto(task);
}

Test:

@Test
void completesOpenTask() {
TaskEntity task = new TaskEntity(1L, "Learn testing", "OPEN");

when(taskRepository.findById(1L))
.thenReturn(Optional.of(task));

TaskDto result = taskService.complete(1L);

assertThat(result.status()).isEqualTo("DONE");
}

Notice:

No save() verification is needed here if the design relies on JPA dirty checking.

But in a pure unit test, there is no JPA.

The test only proves service/domain behavior.

Memory sentence:

Unit tests do not prove JPA dirty checking.


25. Testing Already Done Task

Domain logic:

public void complete() {
if ("DONE".equals(status)) {
throw new IllegalStateException("Task is already done");
}

this.status = "DONE";
}

Test through service:

@Test
void throwsWhenCompletingAlreadyDoneTask() {
TaskEntity task = new TaskEntity(1L, "Task A", "DONE");

when(taskRepository.findById(1L))
.thenReturn(Optional.of(task));

assertThatThrownBy(() -> taskService.complete(1L))
.isInstanceOf(IllegalStateException.class)
.hasMessage("Task is already done");
}

Memory sentence:

Test business rule violations explicitly.


26. Testing Not Found on Complete

@Test
void throwsWhenCompletingMissingTask() {
when(taskRepository.findById(99L))
.thenReturn(Optional.empty());

assertThatThrownBy(() -> taskService.complete(99L))
.isInstanceOf(ResourceNotFoundException.class)
.hasMessage("Task not found: 99");
}

This is a different failure path from “already done”.

Memory sentence:

Different failure reasons need separate tests.


27. Testing Void Methods

Service:

public void delete(Long id) {
TaskEntity task = taskRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Task", id));

taskRepository.delete(task);
}

Repository:

void delete(TaskEntity task);

Test:

@Test
void deletesExistingTask() {
TaskEntity task = new TaskEntity(1L, "Task A", "OPEN");

when(taskRepository.findById(1L))
.thenReturn(Optional.of(task));

taskService.delete(1L);

verify(taskRepository).delete(task);
}

For void methods, verification is often important.

Memory sentence:

For void methods, verify the important side effect.


28. Testing Void Exception

@Test
void deleteThrowsWhenTaskMissing() {
when(taskRepository.findById(99L))
.thenReturn(Optional.empty());

assertThatThrownBy(() -> taskService.delete(99L))
.isInstanceOf(ResourceNotFoundException.class);

verify(taskRepository, never()).delete(any());
}

This checks:

exception thrown
delete not called

Memory sentence:

When delete fails, verify that nothing was deleted.


29. Testing Time with Clock

Bad service:

public class TaskDeadlineService {

public boolean isOverdue(TaskEntity task) {
return task.getDueDate().isBefore(LocalDate.now());
}
}

Better service:

public class TaskDeadlineService {

private final Clock clock;

public TaskDeadlineService(Clock clock) {
this.clock = clock;
}

public boolean isOverdue(TaskEntity task) {
LocalDate today = LocalDate.now(clock);
return task.getDueDate().isBefore(today);
}
}

Test:

class TaskDeadlineServiceTest {

private final Clock fixedClock = Clock.fixed(
Instant.parse("2026-07-07T10:00:00Z"),
ZoneOffset.UTC
);

private final TaskDeadlineService service =
new TaskDeadlineService(fixedClock);

@Test
void taskBeforeTodayIsOverdue() {
TaskEntity task = new TaskEntity(
1L,
"Old task",
"OPEN",
LocalDate.of(2026, 7, 6)
);

boolean result = service.isOverdue(task);

assertThat(result).isTrue();
}

@Test
void taskTodayIsNotOverdue() {
TaskEntity task = new TaskEntity(
1L,
"Today task",
"OPEN",
LocalDate.of(2026, 7, 7)
);

boolean result = service.isOverdue(task);

assertThat(result).isFalse();
}
}

Memory sentence:

Inject Clock to make time-based tests deterministic.


30. Testing Permission Logic

Permission service:

public class PermissionService {

public boolean canAccessTask(User user, TaskEntity task) {
if (user.hasRole("ADMIN")) {
return true;
}

return task.getTenantId().equals(user.getTenantId());
}
}

Test:

class PermissionServiceTest {

private final PermissionService permissionService = new PermissionService();

@Test
void adminCanAccessAnyTask() {
User admin = new User(1L, 100L, "ADMIN");
TaskEntity task = new TaskEntity(10L, 999L, "Task A");

boolean result = permissionService.canAccessTask(admin, task);

assertThat(result).isTrue();
}

@Test
void userCanAccessTaskFromSameTenant() {
User user = new User(1L, 100L, "USER");
TaskEntity task = new TaskEntity(10L, 100L, "Task A");

boolean result = permissionService.canAccessTask(user, task);

assertThat(result).isTrue();
}

@Test
void userCannotAccessTaskFromDifferentTenant() {
User user = new User(1L, 100L, "USER");
TaskEntity task = new TaskEntity(10L, 200L, "Task A");

boolean result = permissionService.canAccessTask(user, task);

assertThat(result).isFalse();
}
}

This is a perfect unit test target.

No Spring needed.

Memory sentence:

Permission rules are excellent unit test candidates.


31. Testing Mapping Logic

Mapper:

public class TaskMapper {

public TaskDto toDto(TaskEntity task) {
return new TaskDto(
task.getId(),
task.getTitle(),
task.getStatus()
);
}
}

Test:

class TaskMapperTest {

private final TaskMapper mapper = new TaskMapper();

@Test
void mapsEntityToDto() {
TaskEntity task = new TaskEntity(1L, "Task A", "OPEN");

TaskDto result = mapper.toDto(task);

assertThat(result).isEqualTo(
new TaskDto(1L, "Task A", "OPEN")
);
}
}

This is simple but useful if mapping has real logic.

If mapper only copies fields, do not over-test too much.

Memory sentence:

Test mapping when mapping contains decisions or important fields.


32. Testing External Service Interaction

Service:

public class NotificationService {

private final EmailClient emailClient;

public NotificationService(EmailClient emailClient) {
this.emailClient = emailClient;
}

public void sendTaskAssignedEmail(String email, String taskTitle) {
EmailMessage message = new EmailMessage(
email,
"New task assigned",
"You have a new task: " + taskTitle
);

emailClient.send(message);
}
}

Test:

class NotificationServiceTest {

private final EmailClient emailClient = mock(EmailClient.class);

private final NotificationService notificationService =
new NotificationService(emailClient);

@Test
void sendsTaskAssignedEmail() {
notificationService.sendTaskAssignedEmail(
"user@example.com",
"Prepare documents"
);

ArgumentCaptor<EmailMessage> captor =
ArgumentCaptor.forClass(EmailMessage.class);

verify(emailClient).send(captor.capture());

EmailMessage message = captor.getValue();

assertThat(message.to()).isEqualTo("user@example.com");
assertThat(message.subject()).isEqualTo("New task assigned");
assertThat(message.body()).contains("Prepare documents");
}
}

Memory sentence:

Mock external clients and capture the outgoing message.


33. Testing No Email Sent

Service rule:

Do not send email if user disabled notifications.

Test:

@Test
void doesNotSendEmailWhenNotificationsDisabled() {
User user = new User("user@example.com", false);

notificationService.notifyTaskAssigned(user, "Task A");

verify(emailClient, never()).send(any());
}

Memory sentence:

Negative interaction tests are useful for side effects.


34. @ExtendWith(MockitoExtension.class)

Instead of manually calling mock(), I can use Mockito extension.

Example:

@ExtendWith(MockitoExtension.class)
class TaskServiceTest {

@Mock
private TaskRepository taskRepository;

@InjectMocks
private TaskService taskService;

@Test
void returnsTaskById() {
TaskEntity task = new TaskEntity(1L, "Task A", "OPEN");

when(taskRepository.findById(1L))
.thenReturn(Optional.of(task));

TaskDto result = taskService.findById(1L);

assertThat(result.title()).isEqualTo("Task A");
}
}

Meaning:

@Mock creates mock dependency.
@InjectMocks creates class under test and injects mocks.

Memory sentence:

MockitoExtension can create mocks automatically.


35. Manual Constructor vs @InjectMocks

Manual constructor:

private final TaskRepository taskRepository = mock(TaskRepository.class);
private final TaskService taskService = new TaskService(taskRepository);

Mockito annotations:

@Mock
private TaskRepository taskRepository;

@InjectMocks
private TaskService taskService;

Both are fine.

For learning, manual constructor is very clear.

For larger tests, Mockito annotations reduce boilerplate.

Memory sentence:

Manual construction is explicit; @InjectMocks is convenient.


36. Do Not Use @MockBean in Unit Tests

Bad unit test:

@MockBean
private TaskRepository taskRepository;

Why?

@MockBean is for Spring test context.
A unit test does not start Spring.

Use:

private final TaskRepository taskRepository = mock(TaskRepository.class);

or:

@Mock
private TaskRepository taskRepository;

Memory sentence:

Use mock() or @Mock in unit tests; use @MockBean in Spring tests.


37. Testing Exceptions with AssertJ

Example:

assertThatThrownBy(() -> taskService.findById(99L))
.isInstanceOf(ResourceNotFoundException.class)
.hasMessage("Task not found: 99");

Alternative:

ResourceNotFoundException exception =
catchThrowableOfType(
() -> taskService.findById(99L),
ResourceNotFoundException.class
);

assertThat(exception.getMessage()).isEqualTo("Task not found: 99");

Most common:

assertThatThrownBy(...)

Memory sentence:

Use assertThatThrownBy to test exceptions clearly.


38. Testing Multiple Items

Service:

public List<TaskDto> listOpenTasks() {
return taskRepository.findByStatus("OPEN")
.stream()
.map(this::toDto)
.toList();
}

Test:

@Test
void returnsOpenTasks() {
when(taskRepository.findByStatus("OPEN"))
.thenReturn(List.of(
new TaskEntity(1L, "Task A", "OPEN"),
new TaskEntity(2L, "Task B", "OPEN")
));

List<TaskDto> result = taskService.listOpenTasks();

assertThat(result)
.extracting(TaskDto::title)
.containsExactly("Task A", "Task B");
}

Memory sentence:

AssertJ extracting is great for lists of objects.


39. Testing Sorting Logic

If service sorts data:

public List<TaskDto> listByTitle() {
return taskRepository.findAll()
.stream()
.sorted(Comparator.comparing(TaskEntity::getTitle))
.map(this::toDto)
.toList();
}

Test:

@Test
void returnsTasksSortedByTitle() {
when(taskRepository.findAll())
.thenReturn(List.of(
new TaskEntity(1L, "Zoo", "OPEN"),
new TaskEntity(2L, "Alpha", "OPEN"),
new TaskEntity(3L, "Beta", "OPEN")
));

List<TaskDto> result = taskService.listByTitle();

assertThat(result)
.extracting(TaskDto::title)
.containsExactly("Alpha", "Beta", "Zoo");
}

Memory sentence:

If service owns sorting logic, unit test the order.


40. Testing Repository Calls with Correct Parameters

Service:

public List<TaskDto> listForClient(Long clientId) {
return taskRepository.findByClientIdAndStatus(clientId, "OPEN")
.stream()
.map(this::toDto)
.toList();
}

Test:

@Test
void findsOpenTasksForClient() {
when(taskRepository.findByClientIdAndStatus(10L, "OPEN"))
.thenReturn(List.of(new TaskEntity(1L, "Task A", "OPEN")));

List<TaskDto> result = taskService.listForClient(10L);

assertThat(result).hasSize(1);

verify(taskRepository).findByClientIdAndStatus(10L, "OPEN");
}

This verification is okay because the parameter matters.

Memory sentence:

Verify repository parameters when they are part of business behavior.


41. Testing Transactional Behavior?

Question:

Can a unit test prove @Transactional works?

Answer:

No.

A unit test does not start Spring.

So it does not apply:

Spring AOP proxy
transaction manager
persistence context
dirty checking
rollback behavior

To test transaction behavior, use integration tests.

Memory sentence:

Unit tests do not prove Spring transactions.


42. Testing JPA Dirty Checking?

Question:

Can this unit test prove dirty checking?

task.complete();
assertThat(task.getStatus()).isEqualTo("DONE");

Answer:

No.

It proves Java object state changed.

It does not prove Hibernate will flush the change to database.

To prove dirty checking, use:

@DataJpaTest or @SpringBootTest with database

Memory sentence:

Unit tests test Java logic, not JPA persistence behavior.


43. Testing Private Methods

Do not test private methods directly.

Bad mindset:

I need to test private method calculateSomething().

Better:

Test public behavior that uses the private method.

Private methods are implementation details.

If private logic is complex, extract it into a separate class and test that class.

Memory sentence:

Test public behavior, not private implementation.


44. Avoid Over-Mocking

Bad test:

when(mapper.toDto(task)).thenReturn(dto);
when(policy.canComplete(task)).thenReturn(true);
when(clock.instant()).thenReturn(...);
when(repository.findById(1L)).thenReturn(Optional.of(task));

If everything is mocked, the test may test only mocks.

Better:

mock external dependencies
use real simple domain objects
use real mapper if it is simple
use real policy if it is the logic under test

Memory sentence:

Do not mock the thing you actually want to test.


45. Avoid Testing Implementation Details

Bad:

verify(repository, times(1)).findById(1L);
verify(mapper, times(1)).toDto(task);
verifyNoMoreInteractions(repository, mapper);

when the real behavior is:

returns correct DTO
throws correct exception
saves correct entity
sends correct email

Implementation can change while behavior stays correct.

Memory sentence:

Tests should be stable when implementation changes but behavior stays the same.


46. Good Test Names

Bad:

@Test
void test1() {
}

Better:

@Test
void createsOpenTask() {
}

Better:

@Test
void throwsWhenTitleIsBlank() {
}

Better:

@Test
void doesNotSaveTaskWhenTitleIsBlank() {
}

Memory sentence:

Test names should describe expected behavior.


47. One Behavior per Test

Bad:

@Test
void createTaskTest() {
// tests valid create
// tests blank title
// tests null title
// tests repository exception
}

Better:

@Test
void createsTaskWithOpenStatus() {
}

@Test
void rejectsBlankTitle() {
}

@Test
void rejectsNullTitle() {
}

Memory sentence:

One test should usually prove one behavior.


48. Parameterized Tests

If many inputs should produce same result, use parameterized tests.

Example:

@ParameterizedTest
@ValueSource(strings = {"", " ", " "})
void rejectsBlankTitles(String title) {
assertThatThrownBy(() -> taskService.create(title))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Title must not be blank");

verify(taskRepository, never()).save(any());
}

Useful for:

invalid strings
boundary numbers
different roles
different statuses

Memory sentence:

Parameterized tests reduce repeated test code for similar cases.


49. Boundary Tests

Business rule:

Title length must be between 1 and 100.

Important tests:

0 characters -> invalid
1 character -> valid
100 characters -> valid
101 characters -> invalid

Example:

@Test
void acceptsTitleWith100Characters() {
String title = "a".repeat(100);

when(taskRepository.save(any(TaskEntity.class)))
.thenReturn(new TaskEntity(1L, title, "OPEN"));

TaskDto result = taskService.create(title);

assertThat(result.title()).hasSize(100);
}

@Test
void rejectsTitleWith101Characters() {
String title = "a".repeat(101);

assertThatThrownBy(() -> taskService.create(title))
.isInstanceOf(IllegalArgumentException.class);

verify(taskRepository, never()).save(any());
}

Memory sentence:

Boundary values catch many bugs.


50. Test Data Builders

If creating test objects becomes repetitive, use helper methods.

Example:

private TaskEntity openTask(Long id, String title) {
return new TaskEntity(id, title, "OPEN");
}

private TaskEntity doneTask(Long id, String title) {
return new TaskEntity(id, title, "DONE");
}

Use:

TaskEntity task = openTask(1L, "Task A");

Memory sentence:

Test builders make test data readable.


51. Full Example Test Class

class TaskServiceTest {

private final TaskRepository taskRepository = mock(TaskRepository.class);

private final TaskService taskService = new TaskService(taskRepository);

@Test
void returnsTaskById() {
TaskEntity task = new TaskEntity(1L, "Task A", "OPEN");

when(taskRepository.findById(1L))
.thenReturn(Optional.of(task));

TaskDto result = taskService.findById(1L);

assertThat(result).isEqualTo(
new TaskDto(1L, "Task A", "OPEN")
);
}

@Test
void throwsWhenTaskNotFound() {
when(taskRepository.findById(99L))
.thenReturn(Optional.empty());

assertThatThrownBy(() -> taskService.findById(99L))
.isInstanceOf(ResourceNotFoundException.class)
.hasMessage("Task not found: 99");
}

@Test
void createsOpenTask() {
when(taskRepository.save(any(TaskEntity.class)))
.thenAnswer(invocation -> {
TaskEntity task = invocation.getArgument(0);
return new TaskEntity(1L, task.getTitle(), task.getStatus());
});

TaskDto result = taskService.create("Task A");

ArgumentCaptor<TaskEntity> captor =
ArgumentCaptor.forClass(TaskEntity.class);

verify(taskRepository).save(captor.capture());

TaskEntity savedArgument = captor.getValue();

assertThat(savedArgument.getTitle()).isEqualTo("Task A");
assertThat(savedArgument.getStatus()).isEqualTo("OPEN");

assertThat(result).isEqualTo(
new TaskDto(1L, "Task A", "OPEN")
);
}

@Test
void rejectsBlankTitle() {
assertThatThrownBy(() -> taskService.create(" "))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Title must not be blank");

verify(taskRepository, never()).save(any());
}

@Test
void completesOpenTask() {
TaskEntity task = new TaskEntity(1L, "Task A", "OPEN");

when(taskRepository.findById(1L))
.thenReturn(Optional.of(task));

TaskDto result = taskService.complete(1L);

assertThat(result.status()).isEqualTo("DONE");
}

@Test
void throwsWhenCompletingAlreadyDoneTask() {
TaskEntity task = new TaskEntity(1L, "Task A", "DONE");

when(taskRepository.findById(1L))
.thenReturn(Optional.of(task));

assertThatThrownBy(() -> taskService.complete(1L))
.isInstanceOf(IllegalStateException.class)
.hasMessage("Task is already done");
}
}

52. Common Exam Traps

Trap 1

Unit tests do not need Spring context.


Trap 2

Do not use @SpringBootTest for simple service unit tests.


Trap 3

Mockito mocks dependencies, not the class under test.


Trap 4

@MockBean is for Spring tests, not plain unit tests.


Trap 5

Use mock() or @Mock in unit tests.


Trap 6

when(...).thenReturn(...) stubs fake return values.


Trap 7

verify(...) checks interactions.


Trap 8

ArgumentCaptor captures arguments passed to mocks.


Trap 9

Do not verify every internal call.


Trap 10

Test public behavior, not private methods.


Trap 11

Do not mock the logic you want to test.


Trap 12

Unit tests do not prove JPA, transactions, or Spring Security filters.


Trap 13

Use fixed Clock for time-based logic.


Trap 14

Test both success and failure paths.


Trap 15

Boundary values are important.


53. Real Exam Question: Unit Test

Question:

What is a unit test?

Answer:

A unit test tests a small piece of code, usually one class or method, in isolation without starting Spring or real infrastructure.


54. Real Exam Question: Mockito

Question:

What is Mockito used for?

Answer:

Mockito is used to create mocks, stub dependency behavior, and verify interactions in tests.


55. Real Exam Question: Stub

Question:

What does stubbing mean?

Answer:

Stubbing means telling a mock what to return when a method is called.

Example:

when(repository.findById(1L)).thenReturn(Optional.of(task));

56. Real Exam Question: Verify

Question:

What does verify do in Mockito?

Answer:

verify checks whether a mock method was called with expected arguments.


57. Real Exam Question: ArgumentCaptor

Question:

What is ArgumentCaptor used for?

Answer:

ArgumentCaptor captures an argument passed to a mock so the test can inspect it with assertions.


58. Real Exam Question: @MockBean

Question:

Should I use @MockBean in a plain unit test?

Answer:

No. @MockBean is for Spring tests. In plain unit tests, use Mockito mock() or @Mock.


59. Interview Answer

Question:

How do you unit test a Spring service?

Good answer:

I usually create the service directly with its constructor and provide mocked dependencies using Mockito. I do not start the Spring context unless the test needs Spring. I arrange test data, stub repository or client methods, call the service method, and assert the result or exception. If the service should save or send something, I use Mockito verify or ArgumentCaptor to check the important side effect.


60. Interview Answer

Question:

When do you use ArgumentCaptor?

Good answer:

I use ArgumentCaptor when I need to inspect an object passed to a dependency. For example, if a service creates a new entity and calls repository.save(entity), I can capture the entity and assert that it has the correct title, status, tenant ID, or other fields.


61. Interview Answer

Question:

What should not be tested with a unit test?

Good answer:

A unit test should not try to prove Spring framework behavior, JPA mappings, real SQL queries, transactions, security filters, or real external API calls. Those need slice or integration tests. Unit tests are best for business logic, branching, validation, mapping, and side-effect decisions.


62. Interview Answer

Question:

How do you avoid flaky unit tests?

Good answer:

I avoid real time, randomness, external services, shared mutable state, and test order dependencies. For time-based logic, I inject Clock. For external services, I mock clients. Each test creates its own data and can run independently.


63. Tiny Code Practice

Service method:

public TaskDto create(String title) {
if (title == null || title.isBlank()) {
throw new IllegalArgumentException("Title must not be blank");
}

TaskEntity task = new TaskEntity(title, "OPEN");
TaskEntity saved = taskRepository.save(task);

return toDto(saved);
}

Write three tests:

@Test
void createsTaskWithOpenStatus() {
}

@Test
void rejectsBlankTitle() {
}

@Test
void doesNotSaveWhenTitleIsBlank() {
}

Possible answer:

@Test
void createsTaskWithOpenStatus() {
when(taskRepository.save(any(TaskEntity.class)))
.thenAnswer(invocation -> {
TaskEntity task = invocation.getArgument(0);
return new TaskEntity(1L, task.getTitle(), task.getStatus());
});

TaskDto result = taskService.create("Task A");

assertThat(result).isEqualTo(
new TaskDto(1L, "Task A", "OPEN")
);
}

@Test
void rejectsBlankTitle() {
assertThatThrownBy(() -> taskService.create(" "))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Title must not be blank");
}

@Test
void doesNotSaveWhenTitleIsBlank() {
assertThatThrownBy(() -> taskService.create(" "))
.isInstanceOf(IllegalArgumentException.class);

verify(taskRepository, never()).save(any());
}

64. Tiny Bug Practice 1

Problem:

@SpringBootTest
class TaskTitlePolicyTest {
}

Question:

What is wrong if TaskTitlePolicy is a simple pure Java class?

Answer:

Starting Spring is unnecessary. Use a plain unit test.


65. Tiny Bug Practice 2

Problem:

@MockBean
private TaskRepository taskRepository;

inside a plain unit test.

Question:

What is wrong?

Answer:

@MockBean is for Spring test context. In a plain unit test, use mock() or @Mock.


66. Tiny Bug Practice 3

Problem:

verify(taskRepository).findById(1L);
verify(taskRepository).save(task);
verifyNoMoreInteractions(taskRepository);

Question:

Why can this be bad?

Answer:

It may over-test implementation details. If the behavior is already proven by the result, verifying every internal interaction can make tests fragile.


67. Tiny Bug Practice 4

Problem:

public boolean isOverdue(Task task) {
return task.getDueDate().isBefore(LocalDate.now());
}

Question:

Why is this harder to test?

Answer:

It depends on the real current date, so tests can become time-dependent. Inject Clock to make tests deterministic.


Practice Questions and Answers

Question 1

What is a service unit test?

Answer:

A service unit test tests a service class in isolation, usually with mocked dependencies and without starting Spring.


Question 2

Should a simple service unit test start Spring?

Answer:

No. If the test does not need Spring, do not start Spring.


Question 3

What does JUnit 5 do?

Answer:

JUnit 5 defines and runs test methods.


Question 4

What does AssertJ do?

Answer:

AssertJ provides fluent, readable assertions.


Question 5

What does Mockito do?

Answer:

Mockito creates mocks, stubs dependency behavior, and verifies interactions.


Question 6

What is a mock?

Answer:

A mock is a fake dependency used in a test. It can return configured values and record interactions.


Question 7

What is stubbing?

Answer:

Stubbing means telling a mock what to return when a method is called.


Question 8

What does verify do?

Answer:

verify checks whether a mock method was called with expected arguments.


Question 9

What does ArgumentCaptor do?

Answer:

ArgumentCaptor captures an argument passed to a mock so the test can inspect it.


Question 10

What is the difference between thenReturn and thenAnswer?

Answer:

thenReturn returns a fixed value. thenAnswer calculates the return value dynamically from the invocation.


Question 11

When should I use verify(..., never())?

Answer:

Use it when I want to prove a side effect did not happen, such as repository save not being called after validation fails.


Question 12

Why should I not test private methods directly?

Answer:

Private methods are implementation details. Test public behavior that uses them.


Question 13

Why should I avoid over-mocking?

Answer:

Because if everything is mocked, the test may only test mocks and become fragile. Mock dependencies, not the logic under test.


Question 14

Can a unit test prove @Transactional works?

Answer:

No. Unit tests do not start Spring proxies or transaction management.


Question 15

Can a unit test prove JPA dirty checking works?

Answer:

No. Unit tests can prove Java object state changes, but not Hibernate persistence behavior.


Question 16

How do I test exceptions with AssertJ?

Answer:

Use assertThatThrownBy.

Example:

assertThatThrownBy(() -> service.findById(99L))
.isInstanceOf(ResourceNotFoundException.class);

Question 17

Why inject Clock?

Answer:

Injecting Clock makes time-based logic deterministic and easy to test.


Question 18

What are boundary tests?

Answer:

Boundary tests check values at the edge of allowed and invalid ranges, such as 0, 1, 100, and 101 characters.


Question 19

What is a good test name?

Answer:

A good test name describes expected behavior, such as throwsWhenTitleIsBlank or createsOpenTask.


Question 20

What should a service unit test focus on?

Answer:

It should focus on business logic, branching, validation, mapping, side effects, and error behavior.

Final Memory Sentences

  • Unit tests do not start Spring.
  • If the test does not need Spring, do not start Spring.
  • JUnit runs tests.
  • AssertJ checks results fluently.
  • Mockito mocks dependencies.
  • Stub means fake return.
  • Verify means check interaction.
  • ArgumentCaptor captures arguments passed to mocks.
  • thenReturn is fixed.
  • thenAnswer is dynamic.
  • Use verify(..., never()) to prove something did not happen.
  • Test success and failure paths.
  • Test public behavior, not private methods.
  • Do not mock the logic you want to test.
  • Do not over-verify implementation details.
  • Unit tests do not prove transactions.
  • Unit tests do not prove JPA dirty checking.
  • Use fixed Clock for time-based tests.
  • Boundary values are important.
  • Good test names describe behavior.
  • Choose simple unit tests for service business logic.