스프링
테스트 코드 6 - ItemCartRepositoryTest: TransientPropertyValueException
쿠마냥
2024. 7. 16. 09:06
[문제]
org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.TransientPropertyValueException: Not-null property references a transient value - transient instance must be saved before current operation : com.d129cm.backendapi.cart.domain.ItemCart.item -> com.d129cm.backendapi.item.domain.Item
ItemCartRepositoryTest를 만들던 중, TransientPropertyValueException이 터졌다. 이 예외는 영속화되지 않은, 즉 transient 엔티티가 다른 엔티티의 속성으로 사용될 때 발생한다.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ImportTestcontainers(InitializeTestContainers.class)
@Import(JpaAuditingConfig.class)
@SuppressWarnings("NonAsciiCharacters")
public class ItemCartRepositoryTest {
@Autowired
private ItemCartRepository itemCartRepository;
@Nested
class Create {
@Test
void 성공_ItemCart_저장() {
// given
Cart cart = Mockito.mock(Cart.class);
Item item = Mockito.mock(Item.class);
ItemOption itemOption = Mockito.mock(ItemOption.class);
ItemCart itemCart = ItemCart.builder()
.count(1)
.item(item)
.itemOption(itemOption)
.cart(cart)
.build();
// when
ItemCart savedItemCart = itemCartRepository.save(itemCart);
// then
Assertions.assertAll(
() -> assertThat(savedItemCart).isNotNull(),
() -> assertThat(savedItemCart.getId()).isNotNull(),
() -> assertThat(savedItemCart.getCount()).isEqualTo(itemCart.getCount()),
() -> assertThat(savedItemCart.getItem()).isEqualTo(itemCart.getItem()),
() -> assertThat(savedItemCart.getItemOption()).isEqualTo(itemCart.getItemOption()),
() -> assertThat(savedItemCart.getCart()).isEqualTo(itemCart.getCart())
);
}
}
}
ItemCart는 item, itemOption, cart와 연관관계를 맺으며 총 세 개의 fk를 가진다.
- item_id
- itemOption_id
- cart_id
이들을 모두 mock객체로 ItemCart에 넣으려고 하자 오류가 발생한 것. 즉, ItemCart 객체를 저장하려고 할 때 연관된 item, itemOption, cart 객체가 영속화되지 않은 상태가 문제의 원인이다. 따라서 ItemCart를 저장하기 전, item, itemOption, cart, 그리고 item에 걸려 있는 brand까지 모두 먼저 영속화해 주어야 한다.
문제 해결을 위해 구글링한 결과, 대부분의 블로그들이 모든 연관관계에 cascade = CASCADE TYPE.ALL을 써서 itemCart가 생성될 때 나머지 엔티티들도 줄줄이 생성되도록 하는 방법을 쓰고 있었다. 그러나 ItemCart는 사용자가 장바구니에 아이템을 추가할 때 생성되는 레코드로, Cart가 생성될 때 함께 생성되어서는 안 되기 때문에 이 방법은 사용할 수 없었다.
[해결 방법 1 : 하나하나 엔티티들을 영속화하기]
public class ItemCartRepositoryTest {
@Autowired
private ItemCartRepository itemCartRepository;
@PersistenceContext
private EntityManager entityManager;
private Cart cart;
private Brand brand;
private Item item;
private ItemOption itemOption;
@BeforeEach
void setup() {
// Cart 객체를 영속화
cart = new Cart();
entityManager.persist(cart);
// Brand 객체를 영속화
brand = Brand.builder()
.name("testBrand")
.description("testDescription")
.image("testImage")
.build();
entityManager.persist(brand);
// Item 객체를 영속화
item = Item.builder()
.name("아이템")
.image("이미지")
.price(10000)
.description("설명")
.build();
brand.addItem(item);
entityManager.persist(item);
// ItemOption 객체를 영속화
itemOption = ItemOption.builder()
.name("아이템옵션")
.quantity(100)
.optionPrice(1000)
.build();
item.addItemOption(itemOption);
entityManager.persist(itemOption);
entityManager.flush();
entityManager.clear();
}
@Nested
class Create {
@Test
void 성공_ItemCart_저장() {
// given
ItemCart itemCart = ItemCart.builder()
.count(1)
.item(item)
.itemOption(itemOption)
.cart(cart)
.build();
// when
ItemCart savedItemCart = itemCartRepository.save(itemCart);
// then
Assertions.assertAll(
() -> assertThat(savedItemCart).isNotNull(),
() -> assertThat(savedItemCart.getId()).isNotNull(),
() -> assertThat(savedItemCart.getCount()).isEqualTo(itemCart.getCount()),
() -> assertThat(savedItemCart.getItem()).isEqualTo(itemCart.getItem()),
() -> assertThat(savedItemCart.getItemOption()).isEqualTo(itemCart.getItemOption()),
() -> assertThat(savedItemCart.getCart()).isEqualTo(itemCart.getCart())
);
}
}
}
- @PersistenceContext로 entity manager를 주입받는다.
- em.persist() 메서드를 호출하여 걸려 있는 모든 엔티티들을 명시적으로 영속화한다.
- 테스트 설정이 장황하고 복잡하다.
[해결 방법 2: sql로 db에 실제 데이터를 주입하기]
public class ItemCartRepositoryTest {
@Autowired
private ItemCartRepository itemCartRepository;
@PersistenceContext
private EntityManager entityManager;
private Cart cart;
private Item item;
private ItemOption itemOption;
@BeforeEach
void setup() {
cart = entityManager.find(Cart.class, 1L);
item = entityManager.find(Item.class, 1L);
itemOption = entityManager.find(ItemOption.class, 1L);
}
@Nested
class Create {
@Test
@Sql("/test-item-cart.sql")
void 성공_ItemCart_저장() {
// given
ItemCart itemCart = ItemCart.builder()
.count(1)
.item(item)
.itemOption(itemOption)
.cart(cart)
.build();
// when
ItemCart savedItemCart = itemCartRepository.save(itemCart);
// then
Assertions.assertAll(
() -> assertThat(savedItemCart).isNotNull(),
() -> assertThat(savedItemCart.getId()).isNotNull(),
() -> assertThat(savedItemCart.getCount()).isEqualTo(itemCart.getCount()),
() -> assertThat(savedItemCart.getItem()).isEqualTo(itemCart.getItem()),
() -> assertThat(savedItemCart.getItemOption()).isEqualTo(itemCart.getItemOption()),
() -> assertThat(savedItemCart.getCart()).isEqualTo(itemCart.getCart())
);
}
}
}
INSERT INTO brand (name, image, description)
VALUES ('Brand', 'image_url', 'description');
SET @brand_id = LAST_INSERT_ID();
INSERT INTO item (name, price, image, description, brand_id, created_at, modified_at)
VALUES ('Item', 1000, 'item_image_url', 'item_description', @brand_id, NOW(), NOW());
SET @item_id = LAST_INSERT_ID();
INSERT INTO item_option (name, option_price, quantity, item_id)
VALUES ('Item Option', 100, 10, @item_id);
SET @itemOption_id = LAST_INSERT_ID();
INSERT INTO address (zip_code, road_name_address, address_details)
VALUES ('12345', '서울시 강남구', '상세주소');
-- 마지막으로 삽입된 address_id 값을 변수에 저장
SET @address_id = LAST_INSERT_ID();
INSERT INTO member (email, password, name, address_id)
VALUES ('test@naver.com', 'Asdf1234', '이름', @address_id);
-- 마지막으로 삽입된 member_id 값을 변수에 저장
SET @member_id = LAST_INSERT_ID();
INSERT INTO cart (member_id)
VALUES (@member_id);
SET @card_id = LAST_INSERT_ID();
- 테스트를 실행하기 전, 특정 SQL 명령을 실행하도록 하기 위해 @Sql 어노테이션을 사용한다.
- test의 resource 폴더에 sql 파일을 생성하고 위와 같이 설정한다.
- 간단한 sql 명령은 인라인으로도 작성이 가능하다.
@Sql(statements = {
"INSERT INTO my_table (id, name) VALUES (1, 'John Doe')",
"INSERT INTO my_table (id, name) VALUES (2, 'Jane Doe')"
})
- 언제 sql문을 수행할지 설정도 가능하다.
@Test
@Sql(scripts = "/test-data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "/clean-up.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
public void testUpdate() {
// 테스트 코드 작성
}
- 다중 sql문을 실행시키는 것도 가능하며, 클래스 레벨에서도 사용이 가능하다.
- 이렇게 정리하자 테스트 코드가 읽기 쉬워졌다.
- 현재는 sql문을 한 곳에 몰아넣은 상태지만, 다중 sql문 실행이 가능하다고 하니, 분할하여 필요한 곳에 쓸 수 있도록 수정하는 방향을 고려 중이다.