🌱 들어가기 전
이번 포스팅에서는 테스트 코드를 작성하며 마주친 데이터 충돌 및 트랜잭션에 관한 문제를 분석하고 해결하는 과정을 살펴보자.
포스팅 내용에 대한 자세한 코드는 Github에 올려두었습니다.
https://github.com/benjaminuj/test-code-with-architecture/tree/main
GitHub - benjaminuj/test-code-with-architecture: 아키텍처에 진화를 주는 테스트 코드에 대해 공부 및 실습
아키텍처에 진화를 주는 테스트 코드에 대해 공부 및 실습합니다. Contribute to benjaminuj/test-code-with-architecture development by creating an account on GitHub.
github.com
🌱 문제
💭 요약
UserRepositoryTest와 UserServiceTest를 작성하고 실행시켰다. 두 클래스는 클래스 레벨에서 @Sql을 사용하여 테스트 실행 전에 필요한 데이터를 세팅하고 있다.
그런데 두 테스트 클래스의 실행 결과가 달랐다. UserRepositoryTest는 정상적으로 동작하며 성공하는데, UserServiceTest는 예상과 달리 예외를 뱉으며 일부 테스트가 실패했다. 에러 로그를 살펴보니 데이터 기본 키 충돌이 발생했다.
💭 문제 상황
<UserRepositoryTest>
@DataJpaTest(showSql = true)
@TestPropertySource("classpath:test-application.properties")
@Sql("/sql/user-repository-test-data.sql")
public class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
void findByIdAndStatus_로_유저_데이터를_찾아올_수_있다() {
//given
//when
Optional<UserEntity> result = userRepository.findByIdAndStatus(1, UserStatus.ACTIVE);
//then
assertThat(result.isPresent()).isTrue();
}
@Test
void findByIdAndStatus_는_데이터가_없으면_Optional_empty_를_내려준다() {
//given
//when
Optional<UserEntity> result = userRepository.findByIdAndStatus(1, UserStatus.PENDING);
//then
assertThat(result.isEmpty()).isTrue();
}
@Test
void findByEmailAndStatus_로_유저_데이터를_찾아올_수_있다() {
//given
//when
Optional<UserEntity> result = userRepository.findByEmailAndStatus("meme@naver.com", UserStatus.ACTIVE);
//then
assertThat(result.isPresent()).isTrue();
}
@Test
void findByEmailAndStatus_는_데이터가_없으면_Optional_empty_를_내려준다() {
//given
//when
Optional<UserEntity> result = userRepository.findByEmailAndStatus("meme@naver.com", UserStatus.PENDING);
//then
assertThat(result.isEmpty()).isTrue();
}
}
<user-repository-test-data.sql>
UserRepositoryTest에 @Sql로 설정한 sql파일
insert into `users` (`id`, `email`, `nickname`, `address`, `certification_code`, `status`, `last_login_at`)
values ('1', 'meme@naver.com', 'meme', 'Seoul', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'ACTIVE', 0);
테스트 실행 결과는 모두 정상적으로 동작하며 성공한다.
<UserServiceTest>
(해당 클래스의 테스트 메소드를 전부 작성하기에는 길기 때문에, 현재 문제를 설명하기 충분한 정도만 코드를 작성해두었습니다. 전체 코드는 상단에 첨부해둔 Github 링크에서 확인하실 수 있습니다.)
@SpringBootTest
@TestPropertySource("classpath:test-application.properties")
@Sql(value = "/sql/user-service-test-data.sql")
public class UserServiceTest {
@Autowired
private UserService userService;
@MockBean
private JavaMailSender mailSender;
@Test
void getByEmail은_ACTIVE_상태의_유저를_찾아올_수_있다() {
//given
String email = "meme@naver.com";
//when
UserEntity result = userService.getByEmail(email);
//then
assertThat(result.getNickname()).isEqualTo("meme");
}
@Test
void getByEmail은_PENDING_상태의_유저는_찾아올_수_없다() {
//given
String email = "mymy@naver.com";
//when
//then
assertThatThrownBy(() -> {
UserEntity result = userService.getByEmail(email);
}).isInstanceOf(ResourceNotFoundException.class);
}
@Test
void getById는_ACTIVE_상태의_유저를_찾아올_수_있다() {
//given
//when
UserEntity result = userService.getById(1);
//then
assertThat(result.getNickname()).isEqualTo("meme");
}
@Test
void getById는_PENDING_상태의_유저는_찾아올_수_없다() {
//given
//when
//then
assertThatThrownBy(() -> {
UserEntity result = userService.getById(2);
}).isInstanceOf(ResourceNotFoundException.class);
}
}
<user-service-test-data.sql>
UserServiceTest에 @Sql로 설정한 sql파일
insert into `users` (`id`, `email`, `nickname`, `address`, `certification_code`, `status`, `last_login_at`)
values (1, 'meme@naver.com', 'meme', 'Seoul', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'ACTIVE', 0);
insert into `users` (`id`, `email`, `nickname`, `address`, `certification_code`, `status`, `last_login_at`)
values (2, 'mymy@naver.com', 'mymy', 'Seoul', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaab', 'PENDING', 0);
테스트 실행 결과가 예상과 달리 일부 실패한다.
🌱 문제 원인 분석
왜 테스트에 실패했을까? 로그를 살펴보자.
에러 로그
'user-service-test-data.sql'의 첫 번째 쿼리를 실행하는 과정에서, 데이터의 기본키 중복이 발생한 것으로 확인된다.
💭 의문점
왜 UserServiceTest에서만 기본키 중복 예외가 발생할까?
정상 동작한 UserRepositoryTest도 UserServiceTest와 동일하게 @Sql로 데이터 세팅 쿼리를 실행한다. 그러니까 UserServiceTest에서 id가 '1'인 데이터를 insert하는 과정에서 id값 중복이 발생한거라면, UserRepositoryTest도 똑같이 id가 '1'인 데이터를 insert할 때 기본키 중복이 발생해야 하지않나?
💭 문제 분석
1. @Sql 어노테이션으로 설정해 둔 sql파일이 각 메소드 전에 실행되는게 맞을까?
가장 먼저, 쿼리에 관련된 문제이기 때문에 sql파일이 메소드 실행 전에 실행되는게 맞는지 확인해야겠다.
코드에서 @Sql의 구현부를 확인한다.
기본 실행이 설정이 'BEFORE_TEST_METHOD'로, 메소드 실행 전에 실행된다.
Spring 공식문서도 확인해본다.
참고) https://docs.spring.io/spring-framework/reference/testing/testcontext-framework/executing-sql.html
공식문서에서도, 기본적으로 메소드 실행 전에 SQL 스크립트가 실행된다고 한다.
결론
@Sql은 다른 설정을 하지 않는 이상, 기본적으로 각 메소드 실행 전에 실행되는게 맞다.
2. @DataJpaTest와 @SpringBootTest의 차이
그럼 무엇이 문제일까?
UserRepositoryTest와 UserServiceTest의 차이점이 무엇인지 생각해본다. 둘은 코드단에서 클래스 설정에 다른 어노테이션을 사용하고있다.
@DataJpaTest(showSql = true) // 이 부분!
@TestPropertySource("classpath:test-application.properties")
@Sql("/sql/user-repository-test-data.sql")
public class UserRepositoryTest {
// ...
}
@SpringBootTest // 이 부분!
@TestPropertySource("classpath:test-application.properties")
@Sql(value = "/sql/user-service-test-data.sql")
public class UserServiceTest {
// ...
}
둘의 차이를 분석해보자.
@DataJpaTest
우선 테스트가 정상적으로 진행된 클래스에서 사용하는 @DataJpaTest를 살펴보자.
공식문서를 찾아본다.
@DataJpaTest는 오직 JPA 컴포넌트에 초점맞춰진 JPA 테스트를 위한 어노테이션이다.
이 어노테이션을 사용하면 각 테스트 메서드를 트랜잭션으로 실행한다. 즉, 각 테스트 메서드가 시작될 때 트랜잭션이 시작되고, 테스트가 끝날 때 트랜잭션이 롤백되기 때문에 데이터베이스에 변경된 내용이 저장되지 않고 초기화된다.
코드를 살펴보자.
공식문서에 나온대로, @DataJpaTest는 자체적으로 @Transactional 어노테이션을 사용하고 있는 것을 확인할 수 있다.
- @DataJpaTest를 사용했을 때 기본키 충돌이 발생하지 않은 이유는, @Transactional로 인해 모든 테스트가 격리되기 때문임을 알 수 있다.
@SpringBootTest
반면에 @SpringBootTest는 어떨까?
공식문서를 확인해보니, 공식문서에는 따로 트랜잭션에 관한 이야기는 없다.
@SpringBootTest의 코드 구현에도 @Transactional은 없다.
- @SpringBootTest는 자동으로 트랜잭션을 관리를 하지 않는다.
결론
@DataJpaTest는 자동으로 테스트를 트랜잭션으로 관리한다. 반면에 @SpringBootTest는 트랜잭션 관리가 자동으로 적용되지 않는다.
💭 문제 원인
@SpringBootTest는 트랜잭션 관리를 하지 않는 것이 문제의 원인이다.
실행되는 첫 번째 테스트 함수에서 @Sql로 미리 데이터를 세팅한다. 이후 실행되는 두 번째 함수에서도 @Sql로 필요한 데이터를 세팅하려는데, 첫 번째 함수에서 테스트 실행 후 DB를 롤백하지 않기때문에, 두 번째 함수가 실행하는 @Sql 쿼리는 데이터를 중복으로 넣으려는 것이다. 그래서 기본키 충돌 문제가 발생한 것이다.
🌱 문제 해결
💭 해결 방법 모색
각 테스트 사이 데이터 간섭 문제를 해결하기 위해, @SpringBootTest를 사용하는 클래스에서도 트랜잭션 관리를 할 수 있게 해야한다.
이를 위해, 먼저 트랜잭션 관리에 대해 Spring 공식 문서를 찾아 볼 필요가 있다.
트랜잭션 관리를 위해 @Transactional을 사용하면 된다고 한다. 이를 사용하면, 트랜잭션으로 테스트가 실행되고 테스트가 끝나면 자동으로 롤백된다고 한다.
따로 설정을 하지 않아도 된다. 트랜잭션은 테스트가 끝나면 자동으로 롤백되는게 기본이다.
예시 코드를 보면, class 레벨에서 @Transactional을 사용하고 있다. createUser()에서 save()로 데이터를 DB에 저장하지만 트랜잭션 관리를 하기 때문에 해당 테스트가 끝나면 자동으로 DB가 롤백된다. 따라서 createUser()에서는 clean up 기능을 하는 코드를 필요로하지 않는다.
참고) https://docs.spring.io/spring-framework/reference/testing/testcontext-framework/tx.html
💭 해결
UserServiceTest에 @Transactional 어노테이션을 작성하여 모든 테스트가 각 트랜잭션 내에서 실행될 수 있도록 한다.
<UserServiceTest>
(이번에는 UserServiceTest 구현부 전체를 포스팅에도 작성하여, 위의 간소화한 UserServiceTest 코드와 다를 수 있다.)
@SpringBootTest
@TestPropertySource("classpath:test-application.properties")
@Sql(value = "/sql/user-service-test-data.sql")
@Transactional // ***추가***
public class UserServiceTest {
@Autowired
private UserService userService;
@MockBean
private JavaMailSender mailSender;
@Test
void getByEmail은_ACTIVE_상태의_유저를_찾아올_수_있다() {
//given
String email = "meme@naver.com";
//when
UserEntity result = userService.getByEmail(email);
//then
assertThat(result.getNickname()).isEqualTo("meme");
}
@Test
void getByEmail은_PENDING_상태의_유저는_찾아올_수_없다() {
//given
String email = "mymy@naver.com";
//when
//then
assertThatThrownBy(() -> {
UserEntity result = userService.getByEmail(email);
}).isInstanceOf(ResourceNotFoundException.class);
}
@Test
void getById는_ACTIVE_상태의_유저를_찾아올_수_있다() {
//given
//when
UserEntity result = userService.getById(1);
//then
assertThat(result.getNickname()).isEqualTo("meme");
}
@Test
void getById는_PENDING_상태의_유저는_찾아올_수_없다() {
//given
//when
//then
assertThatThrownBy(() -> {
UserEntity result = userService.getById(2);
}).isInstanceOf(ResourceNotFoundException.class);
}
@Test
void userCreateDto를_이용하여_유저를_생성할_수_있다() {
//given
UserCreateDto userCreateDto = UserCreateDto.builder()
.email("meme@kakao.com")
.address("Gyeongi")
.nickname("meme-k")
.build();
BDDMockito.doNothing().when(mailSender).send(any(SimpleMailMessage.class));
//when
UserEntity result = userService.create(userCreateDto);
//then
assertThat(result.getId()).isNotNull();
assertThat(result.getStatus()).isEqualTo(UserStatus.PENDING);
}
@Test
void userUpdateDto를_이용하여_유저를_수정할_수_있다() {
//given
UserUpdateDto userUpdateDto = UserUpdateDto.builder()
.address("Incheon")
.nickname("meme-update")
.build();
//when
userService.update(1, userUpdateDto);
//then
UserEntity userEntity = userService.getById(1);
assertThat(userEntity.getId()).isNotNull();
assertThat(userEntity.getAddress()).isEqualTo("Incheon");
assertThat(userEntity.getNickname()).isEqualTo("meme-update");
}
@Test
void user를_로그인_시키면_마지막_로그인_시간이_변경된다() {
//given
//when
userService.login(1);
//then
UserEntity userEntity = userService.getById(1);
assertThat(userEntity.getLastLoginAt()).isGreaterThan(0L);
}
@Test
void PENDING_상태의_사용자는_인증_코드로_ACTIVE_시킬_수_있다() {
//given
//when
userService.verifyEmail(2, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaab");
//then
UserEntity userEntity = userService.getById(2);
assertThat(userEntity.getStatus()).isEqualTo(UserStatus.ACTIVE);
}
@Test
void PENDING_상태의_사용자는_잘못된_인증_코드를_받으면_에러를_던진다() {
//given
//when
//then
assertThatThrownBy(() -> {
userService.verifyEmail(2, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaac");
}).isInstanceOf(CertificationCodeNotMatchedException.class);
}
}
이렇게 코드에 @Transactional을 추가하고 테스트를 실행하면, 문제가 해결된다! 👍🏻
+ 'userCreateDto를 이용하여 유저를 생성할 수 있다()' 의 실패는, 또 다른 문제이기 때문에 다음 포스팅에서 다룹니다!
https://deeper-dev.tistory.com/17
[트러블 슈팅 - 테스트 코드] 테스트 데이터 삽입 시 발생하는 auto_increment 컬럼값 충돌 문제 해결
🌱 들어가기 전이번 포스팅에서는 테스트 코드를 작성하며 마주친 auto_increment 컬럼값 충돌 문제를 해결하는 과정을 살펴보자. 포스팅 내용에 대한 자세한 코드는 Github에 올려두었습니다.https:/
deeper-dev.tistory.com
🌱 마무리
테스트 코드를 작업하다가 마주친 데이터 기본키 충돌 문제를 성공적으로 해결하였다.
문제 해결을 통해, 먼저 로그를 통해 문제 상황을 파악했다. 그리고 문제 원인 파악을 위해 위해 @Sql, @SpringBootTest, @DataJpaTest, Transaction에 대한 Spring 공식문서와 각 코드를 살펴보았다.
@SpringBootTest의 테스트에서도 트랜잭션을 관리하도록하여 데이터 중복 문제를 해결했다.
'Back-End > Test Code' 카테고리의 다른 글
[테스트 코드와 설계] 레이어 아키텍처 개선하기 - SOLID를 준수하고 Testability 높이기 (0) | 2024.09.24 |
---|---|
[트러블 슈팅 - 테스트 코드] 테스트 데이터 삽입 시 발생하는 auto_increment 컬럼값 충돌 문제 해결 (0) | 2024.09.09 |
[테스트 코드와 설계] 프로젝트 Test Code 작성과 문제 해결 (0) | 2024.09.04 |
[테스트 코드와 설계] 빌더 패턴과 엔티티, 그리고 테스트에 대한 조언 (0) | 2024.09.04 |
[테스트 코드와 설계] 의존성 주입과 의존성 역전을 활용하여 테스트 가능성 높이기 (0) | 2024.09.03 |