
@Builder
public Items(String name, String itemCode, Integer price) {
this.name = name;
this.itemCode = itemCode;
this.price = price;
}
@Builder 어노테이션을 붙여주면 알아서 빌더 패턴으로 생성자를 만들어준다.id는 DB에서 auto_increment, createdAt은 now() 함수를 통해 알아서 담기도록 하고 싶기 때문에 해당 필드들을 제외한 생성자에 붙여준 것이다.@Slf4j
@ExtendWith(MockitoExtension.class)
class MyBatisItemRepositoryTests {
@Mock // SpringBootTest + AutoWired (빈 등록 + 의존성 주입)
ItemMapper mapper;
MyBatisItemRepository repository;
@BeforeEach
void init() {
repository = new MyBatisItemRepository(mapper);
}
MyBatis는 실제 실행 시점에 동적으로 ItemMapper 인터페이스의 구현체를 자동으로 만들어준다. 하지만, 단위 테스트에서는 Spring을 완전히 띄우지 않기 때문에 MyBatis가 자동으로 구현체를 만들어주지 않는다.
지금 테스트에서 관심있는 건 ItemMapper가 실제로 어떻게 동작했는지 알고 싶지 않고, 메서드가 어떻게 동작했다고 가정하고 테스트하고 싶은 것이 관심사이다.
=> 그래서 @Mock 로 가짜 객체를 만들어서, when(...).thenReturn(...)으로 예상 동작을 시뮬레이션하는 것이다.
repository가 사용할 mapper로 mock 객체를 넘겨줬기 때문에, 실제 DB를 접근하는 쿼리를 수행하는 것이 아니라 시뮬레이션만! 하는 것이다. @Mock은 ItemMapper의 가짜 구현체를 만들어준다.when(mapper.findByItemCode(...)).thenReturn(...) 이런 식으로, 메서드가 호출됐을 때 특정 값을 리턴해달라고 정의하는 것이다.@Test
@DisplayName("상품 등록 서비스")
void item_save_test() throws Exception {
Items item = Items.builder()
.name(TestUtils.genRandomItemCode())
.itemCode(TestUtils.genRandomItemCode())
.price(TestUtils.genRandomPrice())
.build();
doNothing().when(mapper).save(item);
Items saved = repository.save(item);
assertThat(saved.getItemCode()).isEqualTo(item.getItemCode());
verify(mapper, times(1)).save(item);
}
doNothing().when(mapper).save(item);로 상황을 가정해놓는다.mapper.save()을 호출해도, 진짜 저장하는 것이 아니라, doNothing()을 수행해라! (아무것도 일어나지 않는다!)repository.save(item) 을 하면 repository.save() 내부에서 mapper.save(item)을 호출한다고 가정하는 것이다. 하지만, mapper가 mock이기 때문에 실제 DB에 저장되진 않는다.repository.save 은 인자로 들어온 item을 그대로 반환한다. 그게 Items saved 객체에 담긴 것이다. verify 함수로 mapper의 save함수가 진짜 한 번만 호출되었는지 검사한다. @Test
@DisplayName("itemcode가 없는 item을 save하면 오류가 발생할 것이다.")
void raise_exception_test_1() throws Exception {
Items item = Items.builder()
.name(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);
}
itemCode 가 포함되어있지 않는 item 객체를 만들었다. -> 에러가 나야함.
doThrow(예외종류) 로 mapper.save(item) 을 했을 때 RuntimeException이 난다고 상황을 가정해두었다.
잘못된 객체를 저장하려고 하기 때문에 repository.save(item)을 했을 때 예외가 발생한다.
verify로 mapper.save(item)가 한 번 호출되었는지 검사한다.
@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(VALID_ITEM_CODE)
.itemCode(VALID_ITEM_CODE)
.price(TestUtils.genRandomPrice())
.build();
// 1. 유효한 itemCode로 조회한 Item 객체는 잘 조회된다는 가정
when(mapper.findByItemCode(VALID_ITEM_CODE)).thenReturn(Optional.of(validItem));
// 2. 유효하지 않은 itemCode로 조회한 Item은 조회가 안된다는 가정
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);
}
when으로 VALID_ITEM_CODE로 조회했을 때, 반환값을 설정해주었다. validItem의 Optional을 반납해라 ! INVALID_ITEM_CODE로 조회했을 때는, Optional.empty()로 반환값을 미리 설정해주었다. => 그렇기 때문에 직접 DB에 접근하지 않았지만 이후 validItemOptional을 얻어올 수 있는 것이다.
@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();
// 1. update에 어떠한 String, Integer를 파라미터로 보냈을 때, 1을 반환할 것이다는 가정
when(mapper.update(any(String.class), any(Integer.class))).thenReturn(1);
// 반환값을 설정해주지 않고 다음과 같이 가정해줄 수 있다.
// doNothing().when(mapper).update(anyString(), anyInt());
// 2. 유효한 itemCode로 조회했을 때 updated의 옵셔널을 반환할 것이라는 가정
when(mapper.findByItemCode(VALID_ITEM_CODE)).thenReturn(Optional.of(updated));
repository.updatePrice(VALID_ITEM_CODE, TARGET_PRICE);
verify(mapper, times(1)).update(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);
// 3. 유효하지 않은 itemCode로 조회했을 때 Optional.empty가 반환될 것이라는 가정
when(mapper.findByItemCode(INVALID_ITEM_CODE)).thenReturn(Optional.empty());
Optional<Items> invalidItemOptional = repository.findByItemCode(INVALID_ITEM_CODE);
assertThat(invalidItemOptional.isPresent()).isFalse();
assertThatThrownBy(
() -> {
invalidItemOptional.get();
}
).isInstanceOf(NoSuchElementException.class);
}
when만 사용할 수 있다. 그래서 임의로 반환값 1을 넣어주었다. 사실상
repository.updatePrice(VALID_ITEM_CODE, TARGET_PRICE);를 하지 않아도 아래의 테스트는 다 통과한다. 그렇다면 저건 왜 해준 거지...? 하는 의문이 들었다.이건 단순히 결과가 바뀐 것보다도 변경을 일으키는 행위를 했는지, 그 행위가 예상대로 작동했는지를 검증하고 싶은 것이다.
즉, 나중에 이 테스트를 다시 봤을 때도 어떤 행위를 했을 때 어떤 결과를 기대하는지 보기 위해 가독성을 위해 한 것이라고 볼 수 있다.
좀 더 의미있는 검증을 만들기 위해verify(mapper, times(1)).update(VALID_ITEM_CODE, TARGET_PRICE);로 내부에서mapper.update가 호출되었는지 검증해주었다.
//@Setter
@Getter
@Entity
@Table(name="items")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Items {
@Id
@Column(name="item_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String itemCode;
@Setter
private Integer price;
@Setter 어노테이션은 클래스 레벨에서 지양해야 한다.@Entity 객체에는 꼭 기본 생성자가 필요하다. -> @NoArgsConstructor 필수@NoArgsConstructor의 접근 제어자 기본 설정은 public이다. 하지만, 생성자를 외부에서 막 쓰이게 하고 싶지는 않아서 protected로 제한해두는 것이 안전하다.@Entity에는 꼭 @Id가 있어야 한다. @Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Orders {
@Id
@Column(name = "order_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String orderCode;
private LocalDateTime orderedAt = LocalDateTime.now();
}
이 클래스를 보고 스키마를 짤 수 있을 것 같나요? -> YES
그렇다면 똑똑한 JPA도 가능하다!
spring:
jpa :
hibernate:
ddl-auto: create
이 설정을 해두면, JPA가 @Entity 붙은 클래스들을 스캔해서 테이블을 실행할 때마다 알아서 생성해준다. (이미 있을 경우, 지우고 생성해준다.)
@Repository
@RequiredArgsConstructor
public class HibernateItemRepository {
private final EntityManager entityManager;
repository에 entityManager을 private final로 선언해둔다. spring-boot-starter-data-jpa 의존성을 추가하면EntityManagerFactory 빈이 자동 생성된다. (내부적으로 Hibernate 사용)EntityManager를 꺼내서 의존성을 주입해준다.public Items save(Items items) {
entityManager.persist(items); // 이 때는 아직 DB에 저장된 것 X
return items;
}
.persist()로 transient-> managed 상태로 영속화시켜준다.return items하면 commit이 되어서 DB에 반영된다. entityManager.flush(); 로 커밋 전에 action queue에 있는 쿼리를 DB로 보내 반영할 수도 있다.public Optional<Items> findById(Long id) {
Items items = entityManager.find(Items.class, id); // 명시해준 클래스 타입으로 반환이된다.
return Optional.ofNullable(items);
}
.find() 메서드를 이용해서 객체를 가져온다. .find() 파라미터 내에 명시해준 클래스 타입으로 값이 반환되고, 파라미터에 넣은 id로 객체를 찾는다.public Optional<Items> findByItemCode(String itemCode) {
// 간단히 아래의 방법으로도 얻어올 수 있다.
// Items items = entityManager.find(Items.class, itemCode);
// return Optional.ofNullable(items);
// SQL
String sql = """
SELECT
i.item_id as id,
i.code as itemCode,
i.name
FROM
items i
WHERE
i.code = ?
""";
try{
//JPQL : SELECT 엔티티_별칭 FROM 엔티티명 엔티티_별칭 WHERE 조건
String jpql = "select i from Items i where i.itemCode = :itemCode";
Items findItem = entityManager.createQuery(jpql, Items.class)
.setParameter("itemCode", itemCode)
.getSingleResult();
return Optional.of(findItem);
} catch(NoResultException e) {
return Optional.empty();
}
}
JPQL (Java Persistence Query Language)
: JPA에서 사용하는 쿼리 언어 (객체지향적으로 만들어짐)
SQL (Structured Query Language)
: DB에 특화된 언어
:{필드명}으로 바인딩 변수를 사용한다.entityManager.createQuery를 통해 jqpl 쿼리를 보내고, Items.Class로 타입을 설정한다. .setParameter로 바인딩 변수에 어떤 값을 넣어줄지 정한다. 쿼리 내의 변수 이름은 이 .setParameter의 파라미터 이름과 동일하게 설정해주면 된다..getSingleResult()로 결과값을 한 개의 row만 받겠다고 설정한 것이다.NoResultException 예외가 발생한다. 그럴 때는 Optional.empty()를 반환해준다.public void updatePrice(String itemCode, Integer price){
Optional<Items> itemOptional = findByItemCode(itemCode);
Items item = itemOptional.orElseThrow();
item.setPrice(price);
}
public void delete(Items items) {
entityManager.remove(items);
}
setter를 이용해 값을 변경해주면 된다. <persistence-unit name="grepp-hibernate-exp2">
<properties>
<property name="jakarta.persistence.jdbc.driver" value="org.h2.Driver"/>
<property name="jakarta.persistence.jdbc.url" value="jdbc:h2:./test" />
<property name="jakarta.persistence.jdbc.user" value="sa"/>
<property name="jakarta.persistence.jdbc.password" value=""/>
</properties>
</persistence-unit>
<persistence-unit name="grepp-hibernate-exp1">
<class>io.silver.domain.eg1.Member</class>
<properties>
<!-- url, username, password -->
<property name="jakarta.persistence.jdbc.driver" value="com.mysql.cj.jdbc.Driver"/>
<property name="jakarta.persistence.jdbc.url" value="jdbc:mysql://localhost:3306/grepp_hibernate_test" />
<property name="jakarta.persistence.jdbc.user" value="happy"/>
<property name="jakarta.persistence.jdbc.password" value="day"/>
</properties>
</persistence-unit>
<class>를 통해 명시해줘야 한다. static EntityManagerFactory entityManagerFactory;
EntityManager entityManager;
@BeforeAll
static void init() {
entityManagerFactory =
Persistence.createEntityManagerFactory("grepp-hibernate-exp1");
}
@BeforeEach
void setUp() {
entityManager = entityManagerFactory.createEntityManager();
}
@AfterEach
void close() {
entityManager.close();
}
@AfterAll
static void tearDown() {
entityManagerFactory.close();
}
=> 그렇기 때문에 EntityManagerFactory는 테스트 전체 전/후에만 만들고 닫아주면 된다.
@BeforeAll, @AfterAll : static으로 선언해줘야 한다.반면에, 이전 테스트에서 남은 컨텍스트가 다음 테스트에 영향을 주면 안 되기 때문에 EntityManager은 테스트마다 만들어주고 닫아줘야한다.
@BeforeEach , @AfterEach와.. 오늘 와이래 어렵냐며.. 흑흑 복습할 것도 너무 많고, 새로운 것들 투성이 ~ 오전에 스무스하게 잘 듣다가 Mock에서 완전히 어질어질했다. 아니 실제로 안되는데 여기서 이걸 왜 하는 거지? 이게 진짜 DB에 반영된 건가? 아닌데 왜 해? 이런 너낌스.. 나만 어지러운 것 같아서 점심시간을 이용해서 좀 이해했다.. 그랬는데~ 점심 먹고도 쉽진 않드라고예.. 후
근데 오랜만에 날 응? 응? 응? 하게 만든 것 같아서 또 흥미로웠어. 막이래 호호호 그치만 적당히하라구. 그만 어려워. 귀엽게 봐주는 데에는 한계가 있어~~ .. 아무튼 재밌지만 어렵다.
이제 확실히 진도도 좀 나갔고, 여러가지 보다 보니 머리에서 섞이고 섞이고 하는 것도 있는 것 같다. 확실히 복습의 필요성을 더더욱 느낀다..! 주말에 또 달려야겠다 ㅎ.. 이번주도 무사히 끝내게 해주세요