
이번 글에서는 실제 프로젝트 서비스 중
Create()로직을 Mockito를 활용하여 Unit Test를 진행하는 과정을 보여드리겠습니다. Mockito를 사용한 유닛 테스트는 저에게도 낯설었기 때문에 직접 코드를 작성하면서 어려웠던 부분과 헷갈렸던 부분을 공유하며 자세히 설명하고자 합니다.
참고) 이 글은 테스트 코드 작성 경험이 한 번이라도 있으신 분들을 위한 내용입니다.
일단 테스트 코드를 작성하기 전에 왜 Mockito를 활용하여 UnitTest를 진행하는지에 대한 필요성을 알고 들어갑시다.
먼저 유닛 테스트는 소프트웨어의 가장 작은 단위, 즉 하나의 메서드나 클래스가 예상대로 동작하는지 검증하는 과정입니다. 핵심은 테스트 대상이 외부 환경이나 다른 코드의 영향을 받지 않고 완전히 독립적인 상태에서 테스트 되어야 합니다.
Isolated : Unit tests are standalone, can run in isolation, and have no dependencies on any outside factors, such as a file system or database.
MS Test 공식 문서
하지만 Service 계층의 메서드는 보통 Repository나 다른 Service 등 외부 의존성을 가집니다. 예를 들어 저의 프로젝트 Service 계층에서, ContractService의 createContract() 메서드는 ContractRepository를 사용하여 데이터를 조회하거나 저장합니다.
ContractService.java
private final ContractRepository contractRepository; // 외부 의존성
@Transactional
public Contract createContract(ContractCreateRequest request) {
Optional<Contract> checkDuplicateContract = contractRepository.findByBorrowerPhoneNumberAndPrincipalAndRepaymentDateAndStatusIn(
request.getBorrowerPhoneNumber(),
request.getPrincipal(),
request.getRepaymentDate(),
ContractStatus.getActiveStatuses()
);
if (checkDuplicateContract.isPresent()) {
throw new IllegalArgumentException("중복된 계약이 존재합니다.");
}
Contract contract = Contract.create(request);
// 추후 DTO 변환해서 반환, 현재까지는 엔티티 반환 유지
return contractRepository.save(contract);
}
만약 contractRepository가 실제 DB에 연결되어 있다면, 테스트는 느려질 수 밖에 없을 것입니다.
그래서 Mockito가 등장하는데 Mockito는 이러한 외부 의존성 객체들을(여기서는 contractRepository) Mock Object로 만들어 줍니다. 이 Mock Object들에게 어떠한 메소드가 호출되면 이런 값을 반환해달라고 설정할 수 있고, 예상대로 호출되었는지 확인도 할 수 있습니다.
이제 ContractService의 createContract() 메서드가 정상적으로 동작할 때를 가정한 성공 케이스 테스트 코드를 살펴보겠습니다,
Service CreateContract().test
@ExtendWith(MockitoExtension.class)
public class ContractServiceTest {
@Mock
private ContractRepository contractRepository;
@InjectMocks
private ContractService contractService;
@Test
@DisplayName("계약 생성 성공 - 유효한 요청")
void createContract_success() {
//Given
ContractCreateRequest request = ContractCreateRequest.builder()
.borrowerPhoneNumber("010-9665-1195")
.principal(BigDecimal.valueOf(100000))
.repaymentDate(LocalDate.of(2025, 8, 30))
.build();
List<ContractStatus> activeStatuses = ContractStatus.getActiveStatuses();
when(contractRepository.findByBorrowerPhoneNumberAndPrincipalAndRepaymentDateAndStatusIn(
anyString(),
any(BigDecimal.class),
any(LocalDate.class),
eq(activeStatuses)
)).thenReturn(Optional.empty());
//쉽게 이해하자면 createContract()가 내부에서 사용하는 DB 접근 로직(findBy.., save)은 실제로 동작하지 않기 때문에 그 동작을 이렇게 반응해라 고 미리 설정해줘야 정상 흐름으로 테스트가 진행된다.
when(contractRepository.save(any(Contract.class)))
.thenAnswer(invocation -> {
Contract contract = invocation.getArgument(0);
return Contract.builder()
.id("test-contract-id-" + contract.getBorrowerPhoneNumber())
.borrowerPhoneNumber(contract.getBorrowerPhoneNumber())
.principal(contract.getPrincipal())
.repaymentDate(contract.getRepaymentDate())
.status(ContractStatus.PENDING_AGREEMENT)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
});
// When
Contract createContract = contractService.createContract(request);
// then
assertNotNull(createContract, "생성된 계약은 null이 아니어야 합니다.");
assertNotNull(createContract.getId(), "생성된 계약의 ID는 null이 아니어야 합니다.");
assertEquals(request.getBorrowerPhoneNumber(), createContract.getBorrowerPhoneNumber(), "휴대폰 번호가 일치해야 합니다.");
assertEquals(request.getPrincipal(), createContract.getPrincipal(), "원금이 일치해야 합니다.");
assertEquals(request.getRepaymentDate(), createContract.getRepaymentDate(), "상환일이 일치해야 합니다.");
assertEquals(ContractStatus.PENDING_AGREEMENT, createContract.getStatus(), "초기 상태는 PENDING_AGREEMENT여야 합니다.");
verify(contractRepository, times(1)).findByBorrowerPhoneNumberAndPrincipalAndRepaymentDateAndStatusIn(
eq(request.getBorrowerPhoneNumber()),
eq(request.getPrincipal()),
eq(request.getRepaymentDate()),
eq(activeStatuses)
);
verify(contractRepository, times(1)).save(any(Contract.class));
}
}
@ExtendWith(MockitoExtension.class) : Junit5에게 이 테스트 클래스에서 Mockito 어노테이션을 사용하겠다고 선언을 합니다.@Mock : 이 어노테이션이 붙은 필드에 Mockito가 해당 타입의 가짜 객체를 만들어줍니다. 이 가짜 객체는 contractRepository는 실제 로직을 수행하지 않고, 저희가 설정한 대로만 응답합니다.@InjectMocks : 이 어노테이션이 붙은 필드에 Mockito가 인스턴스를 생성하고, @Mock으로 생성된 모든 Mock 객체들을 자동으로 주입해줍니다. 쉽게 말해서 위 실제 service 계층에서 실제contractRepository를 주입 받고 있는데 테스트 과정에서는 우리가 만든 가짜 contractRepository를 사용하게 됩니다.ContractCreateRequest request = ContractCreateRequest.builder()
.borrowerPhoneNumber("010-9665-1195")
.principal(BigDecimal.valueOf(100000))
.repaymentDate(LocalDate.of(2025, 8, 30))
.build();
List<ContractStatus> activeStatuses = ContractStatus.getActiveStatuses();
createContract()에 전달할 request DTO를 생성해줍니다.when(contractRepository.findByBorrowerPhoneNumberAndPrincipalAndRepaymentDateAndStatusIn(
anyString(),
any(BigDecimal.class),
any(LocalDate.class),
eq(activeStatuses)
)).thenReturn(Optional.empty());
"만약 contractRepository의 findBy... 메서드가 어떤 문자열, 어떤 BigDecimal, 어떤 LocalDate, activeStatuse 리스트를 인자로 받아 호출되면, (Then)그때는 'Optional.empty()를 반환해라. 라는 의미입니다.중복 계약이 없음을 의미하는 뜻으로 작성했습니다.when(contractRepository.save(any(Contract.class)))
.thenAnswer(invocation -> {
Contract argContract = invocation.getArgument(0);
return Contract.builder()
.id("test-contract-id-" + argContract.getBorrowerPhoneNumber())
.borrowerPhoneNumber(argContract.getBorrowerPhoneNumber())
.principal(argContract.getPrincipal())
.repaymentDate(argContract.getRepaymentDate())
.status(ContractStatus.PENDING_AGREEMENT)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
});
만약 ContractRepository의 save 메서드가 어떤 Contract 객체를 인자로 받아 호출되면,(Then)해당 Contract 객체에 가상의 Id와 시간 정보를 추가한 Contract 객체를 만들어서 반환해라 라는 의미입니다. 이 부분도 어쨋든 성공 케이스를 작성하는 것이기 때문에 저장이 성공하여 Id가 부여된 것처럼 시뮬레이션 한 것입니다.이렇게 하면 Given 부분이 완료되었습니다.
Mock contractRepository의 메소드 들이 호출됩니다.Contract createdContract = contractService.createContract(request);;
공식문서, 블로그에 작성된 Test 코드, Gemini 등 활용하면서 공부하고 TestCode를 작성했는데 쓰고나서도 제가 왜 쓴지 몰랐습니다...'그래서 when.then..()은 왜 쓰는건데?'라고 계속 생각했습니다. 혹시 저와 같은 상황에 있으신 분들을 위해 일단 제가 이해한대로 아래 그림과 같이 설명드리겠습니다.

createContract() 메서드 로직을 봅시다. contractRepository를 활용해 처음부터 중복 계약이 있는지 확인 하는 로직이 있습니다. 또한 마지막 return 반환 부분에서 contractRepository를 이용해서 save 메서드를 호출하고 있습니다. 이 두 부분은 모두 외부 의존성 즉, 실제 DB와 연동에 해당합니다.Unit Test의 목적은 createContract() 메서드 자체의 로직 검증입니다. createContract()가 중복이 없으면 저장하고 성공적으로 반환한다는 비지니스 로직을 제대로 수행하는지 보고싶지, Repository가 실제 DB에 데이터를 어떻게 저장하는지는 이 테스트에서 관심사가 아니라는겁니다."그래, 그건 알겠는데 그래서 when.then..()을 왜 쓰냐고?"
ContractRepository는 Mock 객체입ㅂ니다. 이 Mock객체는 스스로 아무것도 할 수 없는 바보입니다. 그래서 우리는 when().then..() 구문을 통해 Mockito에게 Mock Repository의 특정 메서드가 호출되면 이런 값을 반환해 줘와 같이 미리 시나리오를 정해주는 것입니다.중복이 없다고 판단해야 합니다. 그래서 when(contractRepository.findBy...).thenReturn(Optional.empty()); 라고 설정하여, 실제 findBy... 메서드가 호출될 때 Optional.empty()를 즉시 반환하게끔 강제하는 것입니다. 이렇게 되면 service는 중복이 없다고 판단하고 다음 로직으로 넘어갈 수 있게 됩니다.createContract()는 바로 IllegalArgumentException을 던지며 오류가 발생했을 것입니다.<- 이 부분은 실패케이스에서 활용createContract()가 return 까지 성공적으로 도달하려면 save 메서드 호출도 성공해야 합니다. when(contractRepository.save(...)).thenAnswer(...) 라고 설정하여 save 메서드가 호출될 때 마치 DB에 정상적으로 저장되고 ID가 부여된 Contract 객체가 반환된 것처럼 가상의 객체를 만들어 돌려주도록 강제한 것입니다.createContract() 메서드의 반환타입은 Contract이니깐 당연히 Contract 객체를 반환해줘야죠.이제는 느낌이 와야합니다..
이제 마지막 부분은 검증을 하는 구간입니다.
when().then..()이 Mock 객체가 무엇을 반환할지를 설정하는 것이라면, verify()는 service 로직이 Mock 객체(외부 의존성)를 예상한 대로 호출했는지를 확인하는 역할입니다.
테스트 대상 메서드가 실행되는 동안 외부 의존성(Mock객체(여기서는 repository))과 어떻게 상호작용했는지를 검증합니다. 예를 들어 findBy... 메서드가 정확히 1번 호출되었는지, save 메서드도 정확히 1번 호출되어서 어떤 Contract 객체를 반환했는지 등을 확인하여 Service의 비지니스 로직이 의존성을 올바르게 사용했는지 검증합니다.

이제 코드를 실행한 결과 성공적으로 테스트를 통과한 것을 볼 수가 있습니다.
실패 케이스도 이와 비슷하게 작성하시면 될 것 같습니다.
이 글이 Mockito를 활용하여 UnitTest를 작성하시는 분들에게 도움이 되기를 바랍니다.