테스트에서는 @Transactional
을 붙이면 각 테스트에 대해서 롤백이 실행된다.
@Transactional
class ProductServiceImplTest extends IntegrationTestSupport {
}
그냥 평범하게 서비스에서 사용하는 것 처럼 @Transactional
어노테이션을 이용하면 rollback이 진행되어서 각 테스트마다 독립된 환경을 이용 할 수 있다.
class ProductServiceImplTest extends IntegrationTestSupport {
@Autowired
private ProductServiceImpl productService;
@Autowired
private ProductRepository productRepository;
@AfterEach
void tearDown() {
productRepository.deleteAllInBatch();
}
}
@Transactional
이 아니어도 각 테스트 실행 후 레포지토리를 비워서 매 테스트마다 rollback이 되도록 할 수 있다.
deleteAll() 대신 deleteAllInBatch() 를 사용하는 이유
실제 테스트를 통해 쿼리가 날라가는걸 확인해보자
@AfterEach
void tearDown() {
productRepository.deleteAll();
}
@DisplayName("deleteAll test")
@Test
void test() {
//given
Product product1 = Product.builder()
.name("스타벅스 원두")
.category("원두")
.price(50000L)
.description("에티오피아산")
.build();
Product product2 = Product.builder()
.name("스타벅스 원두")
.category("원두")
.price(30000L)
.description("에티오피아산")
.build();
Product product3 = Product.builder()
.name("스타벅스 원두")
.category("원두")
.price(10000L)
.description("에티오피아산")
.build();
productRepository.saveAll(List.of(product1,product2,product3));
}
지금은 어차피 하나만 확인할거긴 하지만 @AfterEach
를 통해 각 테스트가 종료 될 때 repository를 @deleteAll()
을 통해 비우도록 하였다.
이 때 날라간 쿼리를 살펴보자 예상하기로는 Insert 문과 테이블을 비우기 위한 delete가 한번씩만 날라가면 될 것 같다.
하지만 예상과 다르게 insert문 다음 select문이 오고 있었다. save와 delete만 하고 있는데 왜 select가 있을까 ? 그리고 왜 삭제가 하나씩 진행이 될까 ?
deleteAll()
을 살펴보자
deleteAll()
을 자세히 살펴보면 deleteAll()
은 일단 먼저 존재하는 엔티티를 찾고 반복문을 통해서 삭제하도록 하고 있다.
과거에 했던 N+1 문제가 생각나게 하는 부분이다. 지금 테스트하는 환경에서는 당연히 delete할 데이터가 많지 않을 것은 사실이나 그래도 할 수 있다면 최적화를 하는게 좋을 것 같다. 그리고 프로덕션 코드에서는 지양해야할 것 같다.
jpa 문법 중 deleteAll과 관련된 문법에는 deleteAllInBatch()
가 존재한다.
deleteAllInBatch()로 한번 실행해보자
@AfterEach
void tearDown() {
productRepository.deleteAllInBatch();
}
@DisplayName("deleteAll test")
@Test
void test() {
//given
Product product1 = Product.builder()
.name("스타벅스 원두")
.category("원두")
.price(50000L)
.description("에티오피아산")
.build();
Product product2 = Product.builder()
.name("스타벅스 원두")
.category("원두")
.price(30000L)
.description("에티오피아산")
.build();
Product product3 = Product.builder()
.name("스타벅스 원두")
.category("원두")
.price(10000L)
.description("에티오피아산")
.build();
productRepository.saveAll(List.of(product1,product2,product3));
}
각 insert는 아까와 동일하게 진행된다. (deleteAll() 때도 마찬가지로 insert가 3번 나갔다. 이건 saveAll()
을 이용하면 한번씩 나가게 되어있다.)
deleteAllInBatch()
도 내부를 살펴보자
쿼리를 만들도록 되어있다.
그리고 getDeleteAllQueryString()이 뭐냐면
이렇게 구현이 되어있다.
실제 테이블의 모든 데이터를 삭제할 때 처럼 쿼리가 작성되어있는 것을 알 수 있다.
그러면 deleteAll()
이 더 안좋은건데 왜 있을까?
deleteAll()
의 패키지는 package org.springframework.data.repository;
이다
반면 deleteAllInBatch()
의 패키지는 package org.springframework.data.jpa.repository.support;
이다.
그렇다는 말은 deleteAllInBatch()
는 Spring Data JPA에 종속적이라는 뜻이다. MyBatis, JdbcTemplate과 같은 곳에서는 사용 할 수 없기 때문에 deleteAll()
을 사용해야 한다.
쿼리의 갯수 차이를 확인 한 것 처럼 deleteAllInBatch()
가 효율적이다 deleteAllInBatch()
를 사용 할 수 있는 환경이라면 deleteAll()
보단 deleteAllInBatch()
를 사용하자
나는 여기서 문제상황이 발생했다. 분명히 내 생각엔 @Transactional
이나 deleteAllInBatch()
를 하면 다음 테스트에서는 완전 DB 가 빈 깡통일줄 알았다.
그래서 아래와 같이 코드를 짰다.
@Transactional
class ProductServiceImplTest extends IntegrationTestSupport {
@Autowired
private ProductServiceImpl productService;
@Autowired
private ProductRepository productRepository;
@DisplayName("신규 상품을 등록한다.")
@Test
void createProduct() {
//given
ProductCreateServiceRequest request = ProductCreateServiceRequest.builder()
.name("스타벅스 원두")
.category("원두")
.price(50000L)
.description("에티오피아산")
.build();
//when
ProductResponse productResponse = productService.createProduct(request);
List<Product> products = productRepository.findAll();
//then
assertThat(products).hasSize(1)
.extracting("id", "name", "category", "price", "description")
.containsExactlyInAnyOrder(
tuple(1L, "스타벅스 원두", "원두", 50000L, "에티오피아산")
);
assertThat(productResponse)
.extracting("id", "name", "category", "price", "description")
.containsExactlyInAnyOrder(1L, "스타벅스 원두", "원두", 50000L, "에티오피아산");
}
@DisplayName("상품 ID를 통해 상품에 대한 상세정보를 조회한다.")
@Test
void getProductByProductId() {
//given
Long productId = 1L;
Product product = Product.builder()
.name("스타벅스 원두")
.category("원두")
.price(50000L)
.description("에티오피아산")
.build();
productRepository.save(product);
//when
ProductResponse productResponse = productService.getProduct(productId);
//then
assertThat(productResponse)
.extracting("id", "name", "category", "price", "description")
.containsExactlyInAnyOrder(productId, "스타벅스 원두", "원두", 50000L, "에티오피아산");
}
@DisplayName("상품 ID를 통해 상품에 대한 상세정보를 조회 할 때 해당 ID의 상품이 존재하지 않을 때 상품을 조회 할 수 없다.")
@Test
void getProductByProductIdWhenProductIsNull() {
//given
Long productId = 1L;
//when
//then
assertThatThrownBy(() -> productService.getProduct(productId))
.isInstanceOf(NoSuchElementException.class)
.hasMessage("해당 id : " + productId + "를 가진 상품을 찾을 수 없습니다.");
}
@DisplayName("상품 ID를 통해 해당 상품을 삭제 할 수 있다.")
@Test
void deleteProductByProductId() {
//given
Long productId = 1L;
Product product = Product.builder()
.name("스타벅스 원두")
.category("원두")
.price(50000L)
.description("에티오피아산")
.build();
productRepository.save(product);
//when
Long deletedProductId = productService.deleteProduct(productId);
List<Product> products = productRepository.findAll();
//then
assertThat(products).hasSize(0);
assertThat(deletedProductId).isEqualTo(productId);
}
@DisplayName("상품 ID를 통해 해당 상품을 삭제 할 때 해당 상품이 존재하지 않으면 상품을 삭제 할 수 없다.")
@Test
void deleteProductByProductIdWhenProductIsNull() {
//given
Long productId = 1L;
//when
//then
assertThatThrownBy(()->productService.deleteProduct(productId))
.isInstanceOf(NoSuchElementException.class)
.hasMessage("해당 id : " + productId + "를 가진 상품을 찾을 수 없습니다.");
}
@DisplayName("상품 ID를 통해 해당 상품의 정보를 수정 할 수 있다.")
@Test
void updateProductByProductId() {
//given
Long productId = 1L;
Product product = Product.builder()
.name("스타벅스 원두")
.category("원두")
.price(50000L)
.description("에티오피아산")
.build();
productRepository.save(product);
ProductUpdateServiceRequest request = ProductUpdateServiceRequest.builder()
.name("이디야 커피")
.category("커피")
.price(40000L)
.description("국산")
.build();
//when
ProductResponse response = productService.updateProduct(productId, request);
//then
assertThat(response)
.extracting("id", "name", "category", "price", "description")
.containsExactlyInAnyOrder(productId, "이디야 커피", "커피", 40000L, "국산");
}
@DisplayName("상품 ID를 통해 해당 상품의 정보를 수정 할 때 해당 상품이 존재하지 않으면 상품을 삭제 할 수 없다.")
@Test
void updateProductByProductIdWhenProductIsNull() {
//given
Long productId = 1L;
ProductUpdateServiceRequest request = ProductUpdateServiceRequest.builder()
.name("이디야 커피")
.category("커피")
.price(40000L)
.description("국산")
.build();
//when
//then
assertThatThrownBy(()->productService.updateProduct(productId,request))
.isInstanceOf(NoSuchElementException.class)
.hasMessage("해당 id : " + productId + "를 가진 상품을 찾을 수 없습니다.");
}
}
테스트가 조금 길긴한데 이걸 각각 개별 테스트를 돌렸을 때는 모두 통과했다 그러나 전체를 돌리니까 테스트가 깨졌다.
엥? 테스트가 왜 깨졌을까? 각 테스트를 돌렸을 때는 통과하고 @Transactional
을 걸어두었으니까 롤백이 되어서 각 테스트가 독립적인 환경을 보장받아야되는거 아닐까 ? 라고 생각했다. 정말 똑같은 코드로 각 테스트를 돌리면 성공한다.
흠.... 전체 테스트의 오류를 보자
엥? 분명히 클렌징이 되면 1개만 들어가야되니까 1L이 나와야되는거아닌가? 라고 생각했다. 왜냐하면 각 테스트에서 save를 하더라도 클렌징작업을 통해서 데이터베이스를 비워내니까 당연히 비워져있으리라고 생각했다.
@Transactional
대신 @AfterEach로 클렌징하기앞에서 작성한것 처럼 Transactional 대신 deleteAllInBatch()
를 해보았다.
@AfterEach
void tearDown() {
productRepository.deleteAllInBatch();
}
놀랍게도 그대로였다. 그리고 심지어 클렌징작업도 제대로 되고있는 것도 볼 수 있었다. 콘솔에 찍히는 것을 그대로 가져와보았다.
Hibernate:
select
p1_0.product_id,
p1_0.category,
p1_0.created_at,
p1_0.description,
p1_0.product_name,
p1_0.price,
p1_0.updated_at
from
products p1_0
where
p1_0.product_id=?
Hibernate:
delete
from
products p1_0
Hibernate:
select
p1_0.product_id,
p1_0.category,
p1_0.created_at,
p1_0.description,
p1_0.product_name,
p1_0.price,
p1_0.updated_at
from
products p1_0
where
p1_0.product_id=?
Hibernate:
delete
from
products p1_0
Hibernate:
select
next value for products_seq
Hibernate:
insert
into
products
(category, created_at, description, product_name, price, updated_at, product_id)
values
(?, ?, ?, ?, ?, ?, ?)
Hibernate:
select
p1_0.product_id,
p1_0.category,
p1_0.created_at,
p1_0.description,
p1_0.product_name,
p1_0.price,
p1_0.updated_at
from
products p1_0
where
p1_0.product_id=?
Hibernate:
delete
from
products
where
product_id=?
음... 클렌징도 다 잘 작동하는거같은데 뭐가문제지 ? 일단 이렇게 해도 테스트가 똑같은 부분에서 깨졌다.
implements하기 전에 @Transactional
이 있어야하나? 생각해서 한번 붙여보았다.
@ActiveProfiles("test")
@SpringBootTest
@Transactional
public abstract class IntegrationTestSupport {
}
결과 ? 당연히 똑같이 안된다
음.. 솔직하게 말하면 해결하긴 했는데 내가 테스트를 잘 이해하지 못해서 그런가? 이렇게 하는게 맞는지 모르겠다. 이거는 너무 당연히 assertThat을 하면 맞는 값이 나오는거아닌가? 라고 생각이든다.
// 기존 깨지던 테스트
@DisplayName("신규 상품을 등록한다.")
@Test
void createProduct() {
//given
ProductCreateServiceRequest request = ProductCreateServiceRequest.builder()
.name("스타벅스 원두")
.category("원두")
.price(50000L)
.description("에티오피아산")
.build();
//when
ProductResponse productResponse = productService.createProduct(request);
List<Product> products = productRepository.findAll();
//then
assertThat(products).hasSize(1)
.extracting("id", "name", "category", "price", "description")
.containsExactlyInAnyOrder(
tuple(1L, "스타벅스 원두", "원두", 50000L, "에티오피아산")
);
assertThat(productResponse)
.extracting("id", "name", "category", "price", "description")
.containsExactlyInAnyOrder(1L, "스타벅스 원두", "원두", 50000L, "에티오피아산");
}
// 해결한 방법
@DisplayName("신규 상품을 등록한다.")
@Test
void createProduct() {
//given
ProductCreateServiceRequest request = ProductCreateServiceRequest.builder()
.name("스타벅스 원두")
.category("원두")
.price(50000L)
.description("에티오피아산")
.build();
//when
ProductResponse productResponse = productService.createProduct(request);
List<Product> products = productRepository.findAll();
//then
assertThat(products).hasSize(1)
.extracting("id", "name", "category", "price", "description")
.containsExactlyInAnyOrder(
tuple(products.get(0).getId(), "스타벅스 원두", "원두", 50000L, "에티오피아산")
);
assertThat(productResponse)
.extracting("id", "name", "category", "price", "description")
.containsExactlyInAnyOrder(products.get(0).getId(), "스타벅스 원두", "원두", 50000L, "에티오피아산");
}
기존에는 당연히 클렌징 작업을 진행하기 때문에 완전히 비어있는 곳에 넣으니까 1L이 반환될거라고 생각했는데 그게 아닌거같다. 전체 테스트를 보면 save가 총 3번 그리고 지금 테스트에 포함되어있는 메서드가 가지는 save 1번 총 4번이 실행된다. 그래서 4L이 반환이 되었던걸까?
그래서 그냥 이곳에서는 전부 다 조회해서 1개가 들었는지 검증하고 List의 0번째 id를 가져와 검증하도록 바꾸었다.
진짜 미치겠는건 findAll()
로 찾은 List의 hasSize(1)은 통과하는데 도대체 왜 id가 4인것일까?
이렇게 보면 @Transactional
은 제대로 동작하는게 맞다.
나머지 테스트들도 다 위와 같은 방식으로 수정하였다.
@Transactional
class ProductServiceImplTest extends IntegrationTestSupport {
@Autowired
private ProductServiceImpl productService;
@Autowired
private ProductRepository productRepository;
@DisplayName("신규 상품을 등록한다.")
@Test
void createProduct() {
//given
ProductCreateServiceRequest request = ProductCreateServiceRequest.builder()
.name("스타벅스 원두")
.category("원두")
.price(50000L)
.description("에티오피아산")
.build();
//when
ProductResponse productResponse = productService.createProduct(request);
List<Product> products = productRepository.findAll();
//then
assertThat(products).hasSize(1)
.extracting("id", "name", "category", "price", "description")
.containsExactlyInAnyOrder(
tuple(products.get(0).getId(), "스타벅스 원두", "원두", 50000L, "에티오피아산")
);
assertThat(productResponse)
.extracting("id", "name", "category", "price", "description")
.containsExactlyInAnyOrder(products.get(0).getId(), "스타벅스 원두", "원두", 50000L, "에티오피아산");
}
@DisplayName("상품 ID를 통해 상품에 대한 상세정보를 조회한다.")
@Test
void getProductByProductId() {
//given
Product product = Product.builder()
.name("스타벅스 원두")
.category("원두")
.price(50000L)
.description("에티오피아산")
.build();
Product savedProduct = productRepository.save(product);
//when
ProductResponse productResponse = productService.getProduct(savedProduct.getId());
//then
assertThat(productResponse)
.extracting("id", "name", "category", "price", "description")
.containsExactlyInAnyOrder(savedProduct.getId(), "스타벅스 원두", "원두", 50000L, "에티오피아산");
}
@DisplayName("상품 ID를 통해 상품에 대한 상세정보를 조회 할 때 해당 ID의 상품이 존재하지 않을 때 상품을 조회 할 수 없다.")
@Test
void getProductByProductIdWhenProductIsNull() {
//given
Long productId = 1L;
//when
//then
assertThatThrownBy(() -> productService.getProduct(productId))
.isInstanceOf(NoSuchElementException.class)
.hasMessage("해당 id : " + productId + "를 가진 상품을 찾을 수 없습니다.");
}
@DisplayName("상품 ID를 통해 해당 상품을 삭제 할 수 있다.")
@Test
void deleteProductByProductId() {
//given
Product product = Product.builder()
.name("스타벅스 원두")
.category("원두")
.price(50000L)
.description("에티오피아산")
.build();
Product savedProduct = productRepository.save(product);
//when
Long deletedProductId = productService.deleteProduct(savedProduct.getId());
List<Product> products = productRepository.findAll();
//then
assertThat(products).hasSize(0);
assertThat(deletedProductId).isEqualTo(savedProduct.getId());
}
@DisplayName("상품 ID를 통해 해당 상품을 삭제 할 때 해당 상품이 존재하지 않으면 상품을 삭제 할 수 없다.")
@Test
void deleteProductByProductIdWhenProductIsNull() {
//given
Long productId = 1L;
//when
//then
assertThatThrownBy(()->productService.deleteProduct(productId))
.isInstanceOf(NoSuchElementException.class)
.hasMessage("해당 id : " + productId + "를 가진 상품을 찾을 수 없습니다.");
}
@DisplayName("상품 ID를 통해 해당 상품의 정보를 수정 할 수 있다.")
@Test
void updateProductByProductId() {
//given
Product product = Product.builder()
.name("스타벅스 원두")
.category("원두")
.price(50000L)
.description("에티오피아산")
.build();
Product savedProduct = productRepository.save(product);
ProductUpdateServiceRequest request = ProductUpdateServiceRequest.builder()
.name("이디야 커피")
.category("커피")
.price(40000L)
.description("국산")
.build();
//when
ProductResponse response = productService.updateProduct(savedProduct.getId(), request);
//then
assertThat(response)
.extracting("id", "name", "category", "price", "description")
.containsExactlyInAnyOrder(savedProduct.getId(), "이디야 커피", "커피", 40000L, "국산");
}
@DisplayName("상품 ID를 통해 해당 상품의 정보를 수정 할 때 해당 상품이 존재하지 않으면 상품을 삭제 할 수 없다.")
@Test
void updateProductByProductIdWhenProductIsNull() {
//given
Long productId = 1L;
ProductUpdateServiceRequest request = ProductUpdateServiceRequest.builder()
.name("이디야 커피")
.category("커피")
.price(40000L)
.description("국산")
.build();
//when
//then
assertThatThrownBy(()->productService.updateProduct(productId,request))
.isInstanceOf(NoSuchElementException.class)
.hasMessage("해당 id : " + productId + "를 가진 상품을 찾을 수 없습니다.");
}
}
전체 수정한 테스트코드이다.
솔직하게 말하면 모르겠다.... 그리고 테스트를 이렇게 짜는것도 맞는지 조금 의심은 든다.
일단 show-sql은 거슬리니까 좀 끄고 System.out.println
을 써서 확인해보자
음 왼쪽에 나오는 순서대로 테스트 메서드가 실행되는거 같다. 근데 내가 작성한 제일 위에서부터 실행되는건 아닌가보다 내가 작성한 순서대로라면
createProduct,getProductByProductId,getProductByProductIdWhenProductIsNull 등등으로 나아가야되는데 이게 아니다.
뭐 대충 순서를 보면 앞에 두개는 Exception을 발생시키는것이고 여기서부터 save가 3번, 그리고 createProduct테스트에서 호출하는 메서드에서 save 한번 발생한다.
뭔가 테스트도 순서가 있는걸까? 근데 제일 마지막에 또 예외를 확인하는 테스트가 일어난다.
솔직히 아직까진 모르겠다. 테스트 순서가 임의로 정해지는건지도 알아봐야될거같고 (근데 10번돌렸는데 다 10번 다 이거만 나옴) id 값이 계속 오르는거는 AutoIncrement니까 클렌징을 하더라도 테이블을 새로 만드는게 아니라 비우는거니까 그런거같고...? 그러면 이렇게 테스트하는게 맞는거같기도 하고 ? 확신이 생기지는 않는다.
솔직하게 알 것 같으면서도 모르겠다. 확신이 들진 않는다.
정확한건 아니지만 아마도 테이블을 drop 하는게 아닌 테이블의 컬럼을 delete 하는거니까 id값은 재사용하지 않도록 하여서 증가하는게 아닐까? 싶다.
그래도 몇개 알아간것들은 있다.
deleteAll()
보단 deleteAllInBatch()
를 사용하자@AfterEach
를 사용할 땐 참조 무결성 제약 조건을 신경써야한다.@Transactional
쓸거면 프로덕션 코드에도 @Transactional
붙여라 (까먹었었다)!!주의 확신 없음 !!
앞으로 autoincrement id 값에 대한 테스트는 꺼내서 확인하는 걸로 해보자
이유1. 테스트순서를 가늠할 수 없음
이유2. 클렌징은 drop table이 아닌 delete 이다.
정확한 이유를 알고 있는사람이 이 글을 본다면 알려주셨으면 좋겠다 !!
추가
그냥 공부하다가 지나가다가 발견했다 !
내가 생각한 부분이 맞나보다. get을 이용해야되는게 맞는 것 같다.