👀전체 코드
package io.shi.dao.dao.mybatis;
import io.shi.dao.global.entity.Items;
import io.shi.dao.util.TestUtils;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@Slf4j
@ExtendWith(MockitoExtension.class)
class MyBatisItemRepositoryTests {
@Mock
ItemMapper mapper;
MyBatisItemRepository repository;
@BeforeEach
void init(){
repository = new MyBatisItemRepository(mapper);
}
@Test
@DisplayName("상품등록서비스")
void item_save_test() throws Exception {
Items item = Items.builder()
.name(TestUtils.genRandomItemCode())
.itemCode(TestUtils.genRandomItemCode())
.price(TestUtils.genRandomPrice())
.build();
//가정을 해야함 리포지터리에서 save라는 메소드를 호출했을때, 어떻게 반응할껀지?
doNothing().when(mapper).save(item);
Items saved = repository.save(item);
assertThat(saved.getItemCode()).isEqualTo(item.getItemCode());
verify(mapper, times(1)).save(item);
}
}
📦 전체 구조 다시 보기
@Mock
ItemMapper mapper; // 👉 가짜 Mapper 객체 (DB 안 씀)
MyBatisItemRepository repository;
@BeforeEach
void init(){
repository = new MyBatisItemRepository(mapper); // 👉 Mapper 주입
}
🧠 여기서 의도는?
- 실제 DB 없이도 테스트하려고 ItemMapper를 Mock 객체로 만든 것
- 진짜 insert 없이도, repository.save()가 내부적으로 mapper.save()를 정확히 호출하는지를 검증하고 싶음
🔍 핵심 테스트 메서드 분석
@Test
@DisplayName("상품등록서비스")
void item_save_test() throws Exception {
1. 테스트 데이터 생성
Items item = Items.builder()
.name(TestUtils.genRandomItemCode())
.itemCode(TestUtils.genRandomItemCode())
.price(TestUtils.genRandomPrice())
.build();
- 테스트용 Items 객체 하나 생성 (랜덤한 itemCode, name, price)
- TestUtils에서 임의의 값 만들어줘서 겹치지 않도록
2. Mock 객체 행동 정의
doNothing().when(mapper).save(item);
- "이 테스트에서는 mapper.save(item) 호출이 있을 때, 아무 일도 일어나지 않도록 한다"는 의미
- 즉, 진짜 insert를 하지 않고, 그냥 넘어가게 함
- 🤝 Mock의 핵심 개념: “이렇게 호출되면, 이렇게 행동해줘!” 하고 미리 시나리오를 설정
3. 테스트 대상 메서드 호출
Items saved = repository.save(item);
- 실제 테스트의 목적: repository.save(item) 호출 시 내부에서 mapper.save(item)가 정상적으로 호출되는가?
- 만약 mapper.save()가 제대로 안 호출되면, verify에서 실패할 것
4. 검증(assert)
assertThat(saved.getItemCode()).isEqualTo(item.getItemCode());
- repository.save(item) 메서드가 반환한 saved 객체가
- 입력으로 넘긴 item 객체와 같은 값(itemCode)을 가지고 있는지 확인
5. mapper가 한 번만 호출되었는지 확인
verify(mapper, times(1)).save(item);
- mapper.save(item)이 딱 한 번만 호출되었는지 확인
- 혹시 중복 호출되거나 호출이 안 되었으면 테스트 실패
✅ 이 테스트는 무엇을 검증하는가?
목적 | 설명 |
💾 repository.save() 호출 시 | 내부적으로 mapper.save()가 잘 호출되는지 확인 |
🎯 데이터 전달 확인 | 입력으로 준 item 객체가 그대로 전달되었는지 확인 |
🧪 Mocking만 사용 | 실제 DB 연동 없이, 로직만 테스트 (단위 테스트) |
🧾 호출 횟수 검증 | mapper.save()가 정확히 1번만 호출되었는지 검증 |
🔄 즉, 전체 시나리오 흐름
1. Item 객체 하나 만듦 (랜덤 생성)
2. repository.save(item) 호출
3. 내부에서 mapper.save(item) 호출 → doNothing() 설정된 Mock
4. 반환값 체크 (itemCode 같나?)
5. mapper.save()가 1번 호출됐는지 검증
❓ 만약 이 테스트가 실패한다면?
- verify(mapper, times(1))에서 실패 → 내부에서 mapper.save()가 안 불렸거나, 여러 번 불림
- assertThat(saved.getItemCode())에서 실패 → 반환된 객체가 다름
- 또는 NullPointerException → 내부에서 반환값이 null일 수도 있음
🤖 보너스 질문
❓근데 repository.save(item)에서 item을 그대로 반환하는 코드가 없어도 테스트가 통과할까?
→ 아니야. repository.save()가 item을 그대로 리턴해야 이 테스트는 성공해!
혹시 MyBatisItemRepository 안에 이런 코드 있어?
public Items save(Items item) {
mapper.save(item); // DB insert
return item; // <-- 이거 꼭 있어야 테스트 통과함!
}
🔍 코드 목적
@Test
@DisplayName("상품등록서비스")
void item_save_test() throws Exception {
...
}
이 테스트는 MyBatisItemRepository의 save() 메소드가 ItemMapper.save()를 제대로 호출하는지 검증하는 단위 테스트야.
즉, 실제 DB 저장이 잘 되는지를 보는 게 아니라, "정해진 흐름대로 save 로직이 호출되었는지"를 검증하는 게 핵심이야.
✔ 주요 흐름 설명
1. ItemMapper를 @Mock으로 생성
@Mock
ItemMapper mapper;
- 실제 DB와 연결하지 않고, ItemMapper를 가짜(Mock) 객체로 만들어 사용해.
- 왜? 진짜 DB에 저장되는 걸 보는 게 아니라, 이 mapper.save()가 정확히 한 번 호출되었는지 확인하고 싶기 때문이야.
2. 테스트 전 초기화
@BeforeEach
void init(){
repository = new MyBatisItemRepository(mapper);
}
- 테스트마다 새로운 repository를 만들어줌.
- 이때 진짜 mapper 대신, 위에서 만든 Mock 객체가 들어가.
3. 가짜 객체의 동작 설정
doNothing().when(mapper).save(item);
- "만약 mapper.save(item) 이 호출되면 아무 일도 일어나지 않도록 해라" 라고 지정.
- 즉, 진짜 DB에 저장하는 대신 그냥 넘어가도록 설정한 거야.
- 중요한 건 이 줄이 실제로 호출되느냐를 검증할 수 있게 만든다는 점.
4. 저장 테스트 실행
Items saved = repository.save(item);
- 이제 테스트 대상인 MyBatisItemRepository.save()를 호출해.
- 이 내부에서 mapper.save(item)이 호출되게 되어 있어.
5. 결과 확인
assertThat(saved.getItemCode()).isEqualTo(item.getItemCode());
- repository.save()가 반환한 객체가 제대로 itemCode를 갖고 있는지 확인해.
6. mapper.save(item)가 1번 호출되었는지 검증
verify(mapper, times(1)).save(item);
- 정말로 mapper.save(item)이 딱 1번 호출됐는지 체크해.
- 이게 핵심 포인트야. "리포지터리에서 진짜 매퍼를 제대로 호출하고 있구나"를 확인하는 거지.
정리하면…
이 테스트는 실제 DB 저장을 테스트하는 게 아니라,
"repository.save(item) → mapper.save(item)" 호출 흐름이 올바르게 흘러가는지를 확인하려는 테스트야.
👉 그래서 Mock을 써서 DB 없이도 로직 검증이 가능하게 만든 거고,
👉 verify()를 써서 실제로 mapper가 호출되었는지 확인하는 거야.
🗣️ 테스트 추가
@Test
@DisplayName("itemcode가 없는 item을 save하면 오류가 발생할 것이다.")
void raise_exception_test_1() throws Exception {
Items item = Items.builder()
.name(TestUtils.genRandomItemCode())
.itemCode(TestUtils.genRandomItemCode())
.price(TestUtils.genRandomPrice())
.build();
//코드가 안들어 있는 코드가 들어오면 예외를 터지게 해서 알려줘
doThrow(RuntimeException.class).when(mapper).save(item);
assertThatThrownBy(
() -> {
repository.save(item);
}
).isInstanceOf(RuntimeException.class);
verify(mapper, times(1)).save(item);
}
🧪 테스트 목적
@Test
@DisplayName("itemcode가 없는 item을 save하면 오류가 발생할 것이다.")
이 테스트는 이렇게 말해:
“itemCode가 없는 상품을 저장하려고 하면 예외가 발생해야 해.”
즉, 올바르지 않은 데이터를 저장하려고 할 때 repository가 잘못된 것을 감지하고 예외를 던지는지 확인하려는 테스트야.
🔍 테스트 코드 흐름 분석
1. 테스트 데이터 생성
Items item = Items.builder()
.name(TestUtils.genRandomItemCode())
.itemCode(TestUtils.genRandomItemCode()) // 실제로는 없는 걸로 만들어야겠지만 예시니까 이렇게 되어 있음
.price(TestUtils.genRandomPrice())
.build();
- itemCode가 있다고는 되어 있지만, 실제 로직에서는 "없는 itemCode"로 간주되게끔 테스트할 수 있어.
- 중요한 건 이 item 객체를 저장하면 예외가 발생해야 한다는 걸 테스트하는 거야.
만약 진짜로 itemCode를 null로 하고 싶으면 builder().itemCode(null) 로 만들어도 돼.
2. 예외를 강제로 발생시키기
doThrow(RuntimeException.class).when(mapper).save(item);
- 이 부분이 핵심이자 mock의 강점이야!
- 의미는:
- “만약 mapper.save(item) 이 호출되면 RuntimeException을 던져라.”
- 즉, mapper가 실제로 DB에서 오류를 내지 않더라도 테스트 목적상 의도적으로 예외가 발생하게 한 것이야.
3. 예외가 잘 발생하는지 검증
assertThatThrownBy(() -> repository.save(item))
.isInstanceOf(RuntimeException.class);
- repository.save(item)을 호출했을 때 진짜로 예외가 터졌는지를 확인해.
- 예외의 종류도 RuntimeException인지 체크해줌.
- 이게 예외 상황을 정상적으로 감지하고 처리하고 있는지 검증하는 핵심 코드야.
4. mapper가 정말 호출되었는지 확인
verify(mapper, times(1)).save(item);
- save()가 호출되어야 예외가 발생하니까,
- mapper.save()가 정말 호출됐는지도 함께 확인하는 거야.
✅ 이 테스트의 의미와 목적 정리
항목 | 설명 |
목적 | 유효하지 않은 item을 저장하려고 할 때 예외가 발생하는지 검증 |
사용 기술 | Mockito의 doThrow(), AssertJ의 assertThatThrownBy() |
테스트 범위 | MyBatisItemRepository.save() 내부에서 예외가 처리되는 흐름 |
중요한 포인트 | 예외가 발생해야 하며, mapper.save()가 한 번 호출되어야 함 |
💡 개선 팁
- 만약 정말로 itemCode == null일 때 예외가 발생해야 한다는 걸 테스트하고 싶으면, item.builder().itemCode(null)로 만들고 테스트해도 좋아.
- 혹은 IllegalArgumentException, CustomException 같은 의미 있는 예외를 만들어서 처리하면 테스트가 더 명확해질 수 있어.
🗣️ 테스트 추가
@Test
@DisplayName("유효한 itemcode는 item을 조회할 수 있고 그렇지 않으면 조회가 불가능하다.")
void find_by_item_code_test() throws Exception {
String VALID_ITEM_CODE = TestUtils.genRandomItemCode();
String INVALID_ITEM_CODE = "INVALID_ITEM_CODE";
Items validItem = Items.builder()
.name(TestUtils.genRandomItemCode())
.itemCode(TestUtils.genRandomItemCode())
.price(TestUtils.genRandomPrice())
.build();
when(mapper.findByItemCode(VALID_ITEM_CODE)).thenReturn(Optional.of(validItem));
when(mapper.findByItemCode(INVALID_ITEM_CODE)).thenReturn(Optional.empty());
Optional<Items> validItemOptional = repository.findByItemCode(VALID_ITEM_CODE);
assertThat(validItemOptional.isPresent()).isTrue();
assertThat(validItemOptional.get()).isEqualTo(validItem);
assertThat(validItemOptional.get().getItemCode()).isEqualTo(VALID_ITEM_CODE);
Optional<Items> invalidItemOptional = repository.findByItemCode(INVALID_ITEM_CODE);
assertThat(invalidItemOptional.isPresent()).isFalse();
assertThatThrownBy(
() -> {
invalidItemOptional.get();
}
).isInstanceOf(NoSuchElementException.class );
verify(mapper, times(1)).findByItemCode(VALID_ITEM_CODE);
}
🧱 테스트 준비 단계
String VALID_ITEM_CODE = TestUtils.genRandomItemCode();
String INVALID_ITEM_CODE = "INVALID_ITEM_CODE";
- 실제 존재할 것처럼 보이는 유효한 itemCode
- 존재하지 않는다고 가정할 잘못된 itemCode
Items validItem = Items.builder()
.name(TestUtils.genRandomItemCode())
.itemCode(TestUtils.genRandomItemCode())
.price(TestUtils.genRandomPrice())
.build();
- 이건 유효한 itemCode에 해당하는 진짜 Items 객체야.
이 객체가 조회 결과로 나와야 해.
여기서 TestUtils.genRandomItemCode() 같은 유틸은 랜덤 문자열을 생성하는 메서드일 가능성이 높아.
🧪 Mock 설정 (Mockito)
when(mapper.findByItemCode(VALID_ITEM_CODE)).thenReturn(Optional.of(validItem));
- mapper가 유효한 itemCode로 조회되었을 때는 validItem을 감싼 Optional을 리턴하도록 설정.
- 즉, 유효한 코드로 검색하면 진짜 데이터가 나온다고 가정한 거야.
when(mapper.findByItemCode(INVALID_ITEM_CODE)).thenReturn(Optional.empty());
- 반대로 잘못된 itemCode로 조회하면 빈 Optional이 리턴되게끔 설정.
👉 여기까지가 Stub 설정이야. mapper가 이렇게 반응하라고 가짜로 정의해놓은 거지.
when(mapper.findByItemCode(VALID_ITEM_CODE)).thenReturn(Optional.of(validItem));
when(mapper.findByItemCode(INVALID_ITEM_CODE)).thenReturn(Optional.empty());
- mapper는 DB에 직접 접근하는 MyBatis 인터페이스라고 보면 됨.
- 우리가 진짜 DB 연결하지 않고도 테스트할 수 있게 mapper를 Mock 처리하고, 원하는 값이 나오는 것처럼 가짜로 응답을 설정한 거야.
즉,
- 유효한 코드 → Optional.of(validItem) 반환
- 잘못된 코드 → Optional.empty() 반환
✅ 검증: 유효한 코드
Optional<Items> validItemOptional = repository.findByItemCode(VALID_ITEM_CODE);
- 실제로 repository.findByItemCode()를 호출해서 유효한 코드로 조회함.
assertThat(validItemOptional.isPresent()).isTrue();
- Optional에 값이 있어야 하니까, 존재한다고 검사.
assertThat(validItemOptional.get()).isEqualTo(validItem);
- 조회된 아이템이 우리가 미리 정의해둔 validItem과 같아야 함.
assertThat(validItemOptional.get().getItemCode()).isEqualTo(VALID_ITEM_CODE);
- item 내부의 itemCode도 일치하는지 확인 (정확한 객체 매핑 검증)
- repository를 통해 유효한 코드로 조회
- 결과가 존재하는지 (isPresent)
- 그 결과가 validItem과 같은 객체인지 (isEqualTo)
- 아이템의 itemCode가 요청한 코드와 동일한지 검증
❌ 검증: 잘못된 코드
Optional<Items> invalidItemOptional = repository.findByItemCode(INVALID_ITEM_CODE);
- 잘못된 코드로 조회했을 때
assertThat(invalidItemOptional.isPresent()).isFalse();
- 결과가 없어야 해! Optional.empty()가 리턴돼야 하니까.
- 잘못된 코드로 조회했을 때 아무것도 없다고 확인 (Optional.empty() 반환 예상)
⚠️ 예외 발생 테스트
assertThatThrownBy(
() -> {
invalidItemOptional.get();
}
).isInstanceOf(NoSuchElementException.class );
- Optional.empty() 에서 get()을 하면 NoSuchElementException이 터지는데
- 이 예외가 정확히 터지는지도 함께 테스트하고 있음
- 즉, 진짜 값이 없는데 꺼내려고 하면 예외가 나니까, 이게 잘 동작하는지도 보너스로 확인해보는 것!
이건 예외처리까지 꼼꼼하게 테스트하려고 한 코드야.
✅ verify
mapper가 정말 호출됐는지 검증
verify(mapper, times(1)).findByItemCode(VALID_ITEM_CODE);
- 실제로 유효한 itemCode로 mapper를 한 번만 호출했는지 확인해.
- 즉, mapper가 예상대로 동작했는지도 검증!
❗ INVALID_ITEM_CODE에 대해서는 호출 횟수를 따로 확인하고 있지 않지만, 필요하면 추가할 수도 있어:
verify(mapper, times(1)).findByItemCode(INVALID_ITEM_CODE);
이건 테스트 대상(repository)이 mapper에 위임한 게 맞는지 확인하는 부분이야.
즉, 진짜 mapper에게 일을 시켰는지 체크하는 거지.
📦 정리하면
검증 항목 | 검증 내용 |
유효한 itemCode 조회 | Optional 값 존재 확인, item 내용 확인 |
잘못된 itemCode 조회 | Optional이 비어 있는지 확인 |
Optional.get() 예외 발생 | NoSuchElementException 발생 여부 확인 |
mapper 호출 여부 | 실제로 findByItemCode가 1번 호출되었는지 검증 |
✨ 정리해 보면...
- 정상적인 itemCode → 값이 잘 나오는지
- 잘못된 itemCode → 빈 값이 오는지
- 빈 Optional에서 get()하면 예외 터지는지
- mapper 호출이 제대로 되었는지
🗣️ 테스트 추가
@Test
@DisplayName("item code와 변경하고자 하는 가격이 주어지면 변경된다.")
void it_will_change() throws Exception {
final String VALID_ITEM_CODE = TestUtils.genRandomItemCode();
final String INVALID_ITEM_CODE = "INVALID_ITEM_CODE";
final int TARGET_PRICE = 10_000;
Items updated = Items.builder()
.name(VALID_ITEM_CODE)
.itemCode(VALID_ITEM_CODE) // 요기!
.price(TARGET_PRICE)
.build();
when(mapper.update(any(String.class), any(Integer.class))).thenReturn(1);
when(mapper.findByItemCode(VALID_ITEM_CODE)).thenReturn(Optional.of(updated));
repository.updatePrice(VALID_ITEM_CODE, TARGET_PRICE);
Optional<Items> itemOptional = repository.findByItemCode(VALID_ITEM_CODE);
assertThat(itemOptional.isPresent()).isTrue();
assertThat(itemOptional.get()).isEqualTo(updated);
assertThat(itemOptional.get().getPrice()).isEqualTo(TARGET_PRICE);
when(mapper.findByItemCode(INVALID_ITEM_CODE)).thenReturn(Optional.empty());
Optional<Items> invaildItemOptional = repository.findByItemCode(INVALID_ITEM_CODE);
assertThat(invaildItemOptional.isPresent()).isFalse();
assertThatThrownBy(
() -> {
invaildItemOptional.get();
}
).isInstanceOf(NoSuchElementException.class);
}
✅ 전체적인 목적
- itemCode와 변경할 가격이 주어졌을 때,
👉 해당 item의 가격이 정상적으로 변경되는지 확인하는 테스트야.
🔍 코드 설명
📌 변수 준비
final String VALID_ITEM_CODE = TestUtils.genRandomItemCode();
final String INVALID_ITEM_CODE = "INVALID_ITEM_CODE";
final int TARGET_PRICE = 10_000;
- VALID_ITEM_CODE: 실제 존재한다고 가정한 아이템의 코드.
- INVALID_ITEM_CODE: 존재하지 않는 가짜 itemCode (예외 검증용).
- TARGET_PRICE: 바꾸고 싶은 가격.
📌 변경된 상태의 아이템 만들기
Items updated = Items.builder()
.name(VALID_ITEM_CODE)
.itemCode(VALID_ITEM_CODE)
.price(TARGET_PRICE)
.build();
- 테스트용으로 만든 "변경된 후 상태"의 Items 객체.
- 나중에 findByItemCode()가 이 객체를 반환한다고 가정할 거야.
📌 mock 객체 설정 (Mockito)
when(mapper.update(any(String.class), any(Integer.class))).thenReturn(1);
when(mapper.findByItemCode(VALID_ITEM_CODE)).thenReturn(Optional.of(updated));
- mapper.update(...) 호출 시 항상 성공(1 반환)한다고 가정.
- mapper.findByItemCode(...) 호출 시 우리가 만든 updated 객체를 반환하게 설정.
📌 가격 업데이트 실행
repository.updatePrice(VALID_ITEM_CODE, TARGET_PRICE);
- 실제로 가격 변경 메서드를 호출. 내부적으로 mapper.update(...)가 실행되겠지.
📌 변경된 결과 검증
Optional<Items> itemOptional = repository.findByItemCode(VALID_ITEM_CODE);
assertThat(itemOptional.isPresent()).isTrue(); // 실제 객체가 있음
assertThat(itemOptional.get()).isEqualTo(updated); // 우리가 예상한 객체랑 동일한지
assertThat(itemOptional.get().getPrice()).isEqualTo(TARGET_PRICE); // 가격이 진짜 바뀌었는지
📌 존재하지 않는 itemCode에 대한 처리
when(mapper.findByItemCode(INVALID_ITEM_CODE)).thenReturn(Optional.empty());
Optional<Items> invaildItemOptional = repository.findByItemCode(INVALID_ITEM_CODE);
assertThat(invaildItemOptional.isPresent()).isFalse();
assertThatThrownBy(() -> invaildItemOptional.get())
.isInstanceOf(NoSuchElementException.class);
- INVALID_ITEM_CODE로 조회하면 빈 Optional이 리턴되도록 설정.
- 실제 조회 결과가 없는지 확인 (isPresent() == false)
- 그리고 .get()을 하면 예외 터져야 함 (정상 동작)
✅ 요약: 이 테스트가 하는 일
순서 | 검증 내용 | 의미 |
1 | 유효한 itemCode로 가격 업데이트 요청 | 가격 변경 API 실행 가능 여부 |
2 | 업데이트 후 결과 조회 | 실제로 반영됐는지 확인 |
3 | 가격이 우리가 기대한 값인지 확인 | 내부 로직 정확성 |
4 | 존재하지 않는 itemCode 조회 시 실패 여부 | 예외 처리 로직 확인 |
🔎 이 테스트가 중요한 이유
- 실제 서비스에서는 가격 변경 요청이 매우 많음 → 정확한 업데이트가 중요
- 존재하지 않는 상품을 수정하려는 요청도 있음 → 예외 처리도 중요
'프로그래밍 > Spring' 카테고리의 다른 글
Hibernate란 - 4월 11일 (0) | 2025.04.11 |
---|---|
JPA- 4월 10일 (0) | 2025.04.10 |
Mock이란? - 4월9일 (0) | 2025.04.10 |
MyBatis란? (0) | 2025.04.09 |
🌟 트랜잭션(Transaction) 이란? (0) | 2025.04.09 |