728x90

👀전체 코드

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 없이도 테스트하려고 ItemMapperMock 객체로 만든 것
  • 진짜 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 {
    ...
}

이 테스트는 MyBatisItemRepositorysave() 메소드가 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도 일치하는지 확인 (정확한 객체 매핑 검증)

 

  1. repository를 통해 유효한 코드로 조회
  2. 결과가 존재하는지 (isPresent)
  3. 그 결과가 validItem과 같은 객체인지 (isEqualTo)
  4. 아이템의 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번 호출되었는지 검증

 

 


✨ 정리해 보면...

  1. 정상적인 itemCode → 값이 잘 나오는지
  2. 잘못된 itemCode → 빈 값이 오는지
  3. 빈 Optional에서 get()하면 예외 터지는지
  4. 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 조회 시 실패 여부 예외 처리 로직 확인

 

 


🔎 이 테스트가 중요한 이유

  • 실제 서비스에서는 가격 변경 요청이 매우 많음 → 정확한 업데이트가 중요
  • 존재하지 않는 상품을 수정하려는 요청도 있음 → 예외 처리도 중요
728x90

'프로그래밍 > 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