테스트 코드 7 - 일관적인 테스트 코드를 위한 설정들
1. 테스트 컨테이너로 일관된 환경 구축하기
repository 테스트 환경을 위한 인터페이스를 마련하였다. 이렇게 하면…
- 테스트 환경을 일관되게 만들 수 있다.
- 매번 컨테이너를 실행시켜 테스트를 진행하기 때문에 데이터베이스를 지워야 하는 등 db 관리를 따로 하지 않아도 된다.
- 개발용 db와 테스트용 db가 분리되어 서로 영향을 미치지 않는다.
- 우리가 사용하는 실제 환경과 매우 유사한 환경에서 테스트를 실행할 수 있다. 실제 db를 사용하기 때문! → 테스트용 db를 따로 만들거나 하지 않는다. 테스트하는 동안 진짜 db를 잠시 띄웠다가 내리는 방법이다.
public interface InitializeTestContainers {
@Container
@ServiceConnection
MySQLContainer<?> MY_SQL_CONTAINER = new MySQLContainer<>("mysql:8.0.37");
}
- @Container : 테스트가 시작할 때 컨테이너를 생성하고, 모든 테스트가 완료되면 컨테이너를 종료시킨다.
- @ServiceConnection: 어노테이션 아래에 정의한 MySQL 컨테이너가 실행될 때, 해당 정보를 스프링에 전달해 준다.
사용할 때는 해당 테스트 클래스위에 @ImportTestcontainers(InitializeTestContainers.class)라고 달아준다.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ImportTestcontainers(InitializeTestContainers.class)
@Import(JpaAuditingConfig.class)
@SuppressWarnings("NonAsciiCharacters")
public class CartRepositoryTest {
@Autowired
private CartRepository cartRepository;
@Nested
class create {
@Test
void 성공_장바구니_저장() {
// given
Member member = Member.builder()
.email("test@naver.com")
.password(Mockito.mock(Password.class))
.name("이름")
.address(Mockito.mock(Address.class))
.build();
Cart cart = new Cart();
member.setCart(cart);
// when
Cart savedCart = cartRepository.save(cart);
// then
Assertions.assertAll(
() -> assertThat(savedCart).isNotNull(),
() -> assertThat(savedCart.getId()).isEqualTo(cart.getId()),
() -> assertThat(savedCart.getMember()).isEqualTo(member)
);
}
}
}
2. @Sql로 db에 실제 데이터 삽입하기
ItemCart, 장바구니의 jpa test를 진행하려니 실제 데이터가 필요했다. 왜냐하면 아래와 같은 상황에 봉착하기 때문이다. 코드를 읽어보자.
@Test
void 임의테스트_성공_ItemCart_저장_WithMock() {
// Mock 객체 생성
Item item = mock(Item.class);
ItemOption itemOption = mock(ItemOption.class);
Cart cart = mock(Cart.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())
);
}
위의 코드로 테스트를 돌려보면, 실패가 뜬다.
오류 메시지는 다음과 같다.
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.itemOption -> com.d129cm.backendapi.item.domain.ItemOption
TransientPropertyValueException은 엔티티를 영속화할 때 그 엔티티가 참조하는 다른 엔티티가 아직 영속화되지 않은(transient 상태인) 경우에 발생한다. 다시 말해, itemCart 객체를 영속화하려는데, item, itemOption, cart가 mock 객체이기 때문에 영속화되지 않아서 문제가 발생한다.
- 🧸 잠깐! 이게 뭐더라? (Persistent / Transient / Detached)
- Persistent(영속) 상태: 엔티티가 데이터베이스에 저장되어 있으며, 영속성 컨텍스트에 포함.
- Transient(비영속) 상태: 엔티티가 아직 데이터베이스에 저장되지 않았거나, 영속성 컨텍스트에 포함되지 않은 상태.
- Detached(분리) 상태: 엔티티가 영속 상태였지만, 영속성 컨텍스트에서 분리된 상태.
이를 해결하기 위해서 item, itemOption, cart 객체를 영속화해 주어야 하는데, 이들을 영속화하기 위해서는 그에 걸려 있는 다른 객체들까지 모두 영속화해 주어야 한다.
그런데 생각해 보니, @DataJpaTest는 실제 데이터베이스와 트랜잭션을 진행하여 적절한 데이터를 가져오는 테스트를 진행해야 하는데 이 때, mock 객체를 써서 테스트하는게 맞지 않다는 판단을 했다. 따라서 Mock 객체가 아니라, 실제 DB에 임시로 레코드를 삽입하여 테스트를 진행하기로 했다.
테스트에는 다음과 같은 sql 파일을 사용하였다.
@test-item-cart.sql (초기 버전)
-- Brand 데이터 삽입
INSERT INTO brand (name, image, description)
VALUES ('Brand1', 'image_url1', 'description1');
SET @brand_id1 = LAST_INSERT_ID();
-- Item 데이터 삽입
INSERT INTO item (name, price, image, description, brand_id, created_at, modified_at)
VALUES ('Item1', 1000, 'item_image_url1', 'item_description1', @brand_id1, NOW(), NOW());
SET @item_id1 = LAST_INSERT_ID();
-- ItemOption 데이터 삽입
INSERT INTO item_option (name, option_price, quantity, item_id)
VALUES ('Item Option1', 100, 10, @item_id1),
('Item Option2', 200, 20, @item_id2),
('Item Option3', 300, 30, @item_id3);
-- Address 데이터 삽입
INSERT INTO address (zip_code, road_name_address, address_details)
VALUES ('67890', '서울시 서초구', '상세주소1');
SET @address_id1 = LAST_INSERT_ID();
-- Member 데이터 삽입
INSERT INTO member (email, password, name, address_id)
VALUES ('test1@naver.com', 'Asdf1234', '이름1', @address_id1);
SET @member_id1 = LAST_INSERT_ID();
-- Cart 데이터 삽입
INSERT INTO cart (member_id)
VALUES (@member_id1);
3. db를 말끔하게 지우려면? truncate 실시하기
위의 test-item-cart.sql은 문제가 있었다. 문제 상황은 다음과 같다.
- 각각의 테스트는 순서를 보장하지 않는다.
- findAllbyCartId() 테스트가 먼저 실행된다면, db에는 이미 카트가 들어가 있는 상태며, 이는 1번 카트다.
- 이후 create()를 하며 sql이 한 번 더 수행된다.
- db에 카트가 하나 더 들어가며, 이는 2번 카트다.
- setup에서 1번 카트를 조회해 놓은 상태에서, assertThat의 .isEqualTo를 수행하며 2번 카트를 조회하려 한다.
- 따라서 같은 카트를 조회하지 않아서 테스트가 실패한다.
public class ItemCartRepositoryTest {
@Autowired
private ItemCartRepository itemCartRepository;
@PersistenceContext
private EntityManager entityManager;
private Cart cart;
private Item item;
private Item item2;
private ItemOption itemOption;
private ItemOption itemOption2;
@BeforeEach
void setup() {
cart = entityManager.find(Cart.class, 1L);
item = entityManager.find(Item.class, 1L);
item2 = entityManager.find(Item.class, 2L);
itemOption = entityManager.find(ItemOption.class, 1L);
itemOption2 = entityManager.find(ItemOption.class, 2L);
}
@Nested
class Create {
@Test
@Sql("/test-item-cart.sql")
void 성공_ItemCart_저장() {
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())
);
}
}
@Nested
class findAllByCartId {
@Test
@Sql("/test-item-cart.sql")
void 성공_cartId로_모든ItemCart조회() {
ItemCart itemCart1 = ItemCart.builder()
.count(1)
.item(item)
.itemOption(itemOption)
.cart(cart)
.build();
ItemCart itemCart2 = ItemCart.builder()
.count(2)
.item(item)
.itemOption(itemOption2)
.cart(cart)
.build();
itemCartRepository.save(itemCart1);
itemCartRepository.save(itemCart2);
// when
List<ItemCart> itemCarts = itemCartRepository.findAllByCartId(cart.getId());
// then
assertThat(itemCarts).hasSize(2);
assertThat(itemCarts).containsExactlyInAnyOrder(itemCart1, itemCart2);
}
}
}
이 문제를 해결하기 위해서 여러 조치를 생각해 보았다.
- delete 수행:
현재 해당 테이블의 PK는 AI 속성이 부여되어 있는데, MySql에서 Auto increment 속성은 따로 Pk값을 채번하기 위한 테이블을 만들어 레코드 삽입 시 Pk를 1씩 증가시켜 삽입하는데 단순 레코드 삭제는 이 테이블을 초기화시켜주지 못해 해결하지 못했다. - @TestMethodOrder:
이 방법은 문제를 해결해 주지만 임시 방책에 불과하다. 이 경우, 모든 테스트가 종속성이 생기기 때문에 완전한 해결책이 되지 못한다.
따라서 다음과 같이 sql 문에 truncate하는 부분을 넣어 주었다.
더불어 데이터도 각각 3개 정도로 추가하여 삽입한다.
SET FOREIGN_KEY_CHECKS = 0;
TRUNCATE TABLE item_cart;
TRUNCATE TABLE item_option;
TRUNCATE TABLE item;
TRUNCATE TABLE cart;
TRUNCATE TABLE member;
TRUNCATE TABLE address;
TRUNCATE TABLE brand;
SET FOREIGN_KEY_CHECKS = 1;
-- Brand 데이터 삽입
INSERT INTO brand (name, image, description)
VALUES ('Brand1', 'image_url1', 'description1');
SET @brand_id1 = LAST_INSERT_ID();
-- Item 데이터 삽입
INSERT INTO item (name, price, image, description, brand_id, created_at, modified_at)
VALUES ('Item1', 1000, 'item_image_url1', 'item_description1', @brand_id1, NOW(), NOW());
SET @item_id1 = LAST_INSERT_ID();
INSERT INTO item (name, price, image, description, brand_id, created_at, modified_at)
VALUES ('Item2', 2000, 'item_image_url2', 'item_description2', @brand_id1, NOW(), NOW());
SET @item_id2 = LAST_INSERT_ID();
INSERT INTO item (name, price, image, description, brand_id, created_at, modified_at)
VALUES ('Item3', 3000, 'item_image_url3', 'item_description3', @brand_id1, NOW(), NOW());
SET @item_id3 = LAST_INSERT_ID();
-- ItemOption 데이터 삽입
INSERT INTO item_option (name, option_price, quantity, item_id)
VALUES ('Item Option1', 100, 10, @item_id1),
('Item Option2', 200, 20, @item_id2),
('Item Option3', 300, 30, @item_id3);
-- Address 데이터 삽입
INSERT INTO address (zip_code, road_name_address, address_details)
VALUES ('67890', '서울시 서초구', '상세주소1');
SET @address_id1 = LAST_INSERT_ID();
INSERT INTO address (zip_code, road_name_address, address_details)
VALUES ('67891', '서울시 강남구', '상세주소2');
SET @address_id2 = LAST_INSERT_ID();
INSERT INTO address (zip_code, road_name_address, address_details)
VALUES ('67892', '서울시 송파구', '상세주소3');
SET @address_id3 = LAST_INSERT_ID();
-- Member 데이터 삽입
INSERT INTO member (email, password, name, address_id)
VALUES ('test1@naver.com', 'Asdf1234', '이름1', @address_id1);
SET @member_id1 = LAST_INSERT_ID();
INSERT INTO member (email, password, name, address_id)
VALUES ('test2@naver.com', 'Qwer5678', '이름2', @address_id2);
SET @member_id2 = LAST_INSERT_ID();
INSERT INTO member (email, password, name, address_id)
VALUES ('test3@naver.com', 'Zxcv9012', '이름3', @address_id3);
SET @member_id3 = LAST_INSERT_ID();
-- Cart 데이터 삽입
INSERT INTO cart (member_id)
VALUES (@member_id1),
(@member_id2),
(@member_id3);
이 방법을 취할 때 주의할 점은 @sql을 @beforeEach에 달지 않는 것. 아래처럼 달아서는 안 된다.
@BeforeEach
@sql
void setup() {
cart = entityManager.find(Cart.class, 1L);
item = entityManager.find(Item.class, 1L);
item2 = entityManager.find(Item.class, 2L);
itemOption = entityManager.find(ItemOption.class, 1L);
itemOption2 = entityManager.find(ItemOption.class, 2L);
}
처음에 각 테스트마다 sql문이 수행되길 바라며 이쪽에 sql문을 달았지만 제대로 실행되지 않아 애를 먹었다.
@sql은 클래스 혹은 메서드 단위로 다는 걸 잊지 말 것!
4. 테스트 Fixture로 필요한 객체를 쉽고, 일관성 있게 생성하기
서비스가 커질수록 연관된 엔티티는 늘어난다. itemCart를 생성하기 위해서는 item, cart, itemOption, brand, member 가 있어야 하는 것처럼.
이때 fixture를 사용하면…
- 테스트에 필요한 객체들을 쉽게 생성할 수 있다.
- 각 테스트 케이스가 동일한 방법으로 데이터를 설정하므로 일관성을 유지할 수 있다.
- 여러 테스트에서 재사용할 수 있다는 것도 큰 장점. 보일러 플레이트가 많이 줄어든다.
fixture 클래스의 코드는 다음과 같다.
public class Fixture {
public Member createMember() {
Address address = spy(new Address("서울시", "주소구", "03000"));
Password password = spy(createPassword());
Member member = spy(
Member.builder()
.email("user@example.com")
.password(password)
.name("이름")
.address(address)
.build());
return member;
}
public Cart createCart(Member member) {
Cart cart = spy(new Cart());
member.setCart(cart);
return cart;
}
public ItemCart createItemCart(Item item, ItemOption itemOption) {
Cart cart = createCart(createMember());
ItemCart itemCart = spy(ItemCart.builder()
.count(1)
.item(item)
.itemOption(itemOption)
.cart(cart)
.build());
return itemCart;
}
public Item createItem(Brand brand) {
Item item = spy(Item.builder()
.name("상품 이름")
.price(1000)
.image("상품 이미지")
.description("상품 설명")
.build());
brand.addItem(item);
when(item.getId()).thenReturn(4L);
return item;
}
public ItemOption createItemOption(Item item) {
ItemOption itemOption = spy(ItemOption.builder()
.name("상품 옵션 이름")
.quantity(100)
.optionPrice(200)
.build());
item.addItemOption(itemOption);
when(itemOption.getId()).thenReturn(5L);
when(itemOption.getOptionPrice()).thenReturn(200);
return itemOption;
}
public Brand createBrand() {
Partners partners = createPartners();
Brand brand = spy(Brand.builder()
.name("브랜드 이름")
.description("브랜드 설명")
.image("브랜드 이미지")
.partners(partners)
.build());
when(brand.getId()).thenReturn(6L);
return brand;
}
public Partners createPartners() {
Password password = createPassword();
Partners partners = spy(Partners.builder()
.email("partners@example.com")
.businessNumber("123-12-12345")
.password(password)
.build());
return partners;
}
public Password createPassword() {
PasswordEncoder passwordEncoder = mock(PasswordEncoder.class);
String rawPassword = "password123";
String encodedPassword = "encodedPassword123";
when(passwordEncoder.encode(rawPassword)).thenReturn(encodedPassword);
return Password.of(rawPassword, passwordEncoder);
}
}
아래와 같이 사용한다.
@Nested
class getCart {
@Test
void CartForMemberResponse리스트반환_카트_조회() {
// given
Member member = fixture.createMember();
Cart cart = fixture.createCart(member);
Brand brand = fixture.createBrand();
Item item = fixture.createItem(brand);
ItemOption itemOption = fixture.createItemOption(item);
ItemCart itemCart1 = fixture.createItemCart(item, itemOption);
List<ItemCart> itemCarts = new ArrayList<>();
itemCarts.add(itemCart1);
when(itemCartManager.getItemCart(cart.getId())).thenReturn(itemCarts);
List<CartForMemberResponse> expectedResponses = itemCarts.stream()
.map(CartForMemberResponse::of)
.collect(Collectors.toList());
// when
List<CartForMemberResponse> responses = memberCartService.getCart(member);
// then
verify(itemCartManager).getItemCart(cart.getId());
assertThat(responses).isEqualTo(expectedResponses);
}
}
처음에는 위와 같이 구성하였으나, 이후 엔티티마다 Fixture 클래스를 분리하여 생성하였다.
또한 email처럼 unique한 컬럼은 파라미터로 받아 코드의 재사용성을 높이는 방향으로 리팩토링을 진행하였다.
그중 리팩토링 후의 MemberFixture 클래스를 소개한다.
아래의 코드를 보면, 직접 객체를 생성하던 Address 객체를 AddressFixture.createAddress()로 생성하게끔 분리한 것을 볼 수 있다. PasswordFixture도 마찬가지다. 더불어, email을 파라미터로 받아 설정할 수 있게끔 하였다.
public class MemberFixture {
private AddressFixture addressFixture = new AddressFixture();
private PasswordFixture passwordFixture = new PasswordFixture();
public Member createMember(String email) {
Password password = spy(passwordFixture.createPassword());
Address address = spy(addressFixture.createAddress());
Member member = spy(
Member.builder()
.email(email)
.password(password)
.name("이름")
.address(address)
.build());
return member;
}
}
이렇게 각 도메인 객체에 대한 fixture 클래스를 분리하여, 유지보수가 쉬운 fixture를 만들 수 있다.