🌱 들어가기 전
이번 포스팅에서는 프로젝트 코드에 대해 테스트 코드를 작성하는 과정을 살펴보자.
- 김우근님의 'Java/Spring 테스트를 추가하고 싶은 개발자들의 오답노트'를 공부하고 정리한 글입니다. ✏️
지난 포스팅과 이어집니다 !
https://deeper-dev.tistory.com/13
[테스트 코드와 설계] 빌더 패턴과 엔티티, 그리고 테스트에 대한 조언
🌱 들어가기 전이번 포스팅에서는 빌더 패턴과 엔티티, 그리고 테스트에 대한 여러가지 조언의 내용을 살펴보자.- 김우근님의 'Java/Spring 테스트를 추가하고 싶은 개발자들의 오답노트'를 공부
deeper-dev.tistory.com
포스팅 내용에 대한 자세한 코드는 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
우선 repository부터 테스트한다.
🌱 H2를 이용한 repository 테스트
<UserRepository>
public interface UserRepository extends JpaRepository<UserEntity, Long> {
Optional<UserEntity> findByIdAndStatus(long id, UserStatus userStatus);
Optional<UserEntity> findByEmailAndStatus(String email, UserStatus userStatus);
}
위 UserRepository를 테스트하는 코드를 짜보겠다.
💭 초기 테스트 코드
<UserRepositoryTest>
@DataJpaTest(showSql = true)
@TestPropertySource("classpath:test-application.properties")
public class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
void findByIdAndStatus_로_유저_데이터를_찾아올_수_있다() {
//given
UserEntity userEntity = new UserEntity();
userEntity.setId(1L);
userEntity.setEmail("meme@naver.com");
userEntity.setAddress("Seoul");
userEntity.setNickname("meme");
userEntity.setStatus(UserStatus.ACTIVE);
userEntity.setCertificationCode("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
//when
userRepository.save(userEntity);
Optional<UserEntity> result = userRepository.findByIdAndStatus(1, UserStatus.ACTIVE);
//then
assertThat(result.isPresent()).isTrue();
}
@Test
void findByIdAndStatus_는_데이터가_없으면_Optional_empty_를_내려준다() {
//given
UserEntity userEntity = new UserEntity();
userEntity.setId(1L);
userEntity.setEmail("meme@naver.com");
userEntity.setAddress("Seoul");
userEntity.setNickname("meme");
userEntity.setStatus(UserStatus.ACTIVE);
userEntity.setCertificationCode("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
//when
userRepository.save(userEntity);
Optional<UserEntity> result = userRepository.findByIdAndStatus(1, UserStatus.PENDING);
//then
assertThat(result.isEmpty()).isTrue();
}
@Test
void findByEmailAndStatus_로_유저_데이터를_찾아올_수_있다() {
//given
UserEntity userEntity = new UserEntity();
userEntity.setId(1L);
userEntity.setEmail("meme@naver.com");
userEntity.setAddress("Seoul");
userEntity.setNickname("meme");
userEntity.setStatus(UserStatus.ACTIVE);
userEntity.setCertificationCode("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
//when
userRepository.save(userEntity);
Optional<UserEntity> result = userRepository.findByEmailAndStatus("meme@naver.com", UserStatus.ACTIVE);
//then
assertThat(result.isPresent()).isTrue();
}
@Test
void findByEmailAndStatus_는_데이터가_없으면_Optional_empty_를_내려준다() {
//given
UserEntity userEntity = new UserEntity();
userEntity.setId(1L);
userEntity.setEmail("meme@naver.com");
userEntity.setAddress("Seoul");
userEntity.setNickname("meme");
userEntity.setStatus(UserStatus.ACTIVE);
userEntity.setCertificationCode("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
//when
userRepository.save(userEntity);
Optional<UserEntity> result = userRepository.findByEmailAndStatus("meme@naver.com", UserStatus.PENDING);
//then
assertThat(result.isEmpty()).isTrue();
}
}
그런데 여기서 문제가 있다.
💭 문제
테스트 클래스 전체를 실행시키면 일부 메소드가 실패하는데, 개별 메소드로 테스트 돌리면 모든 메소드에 대해 개별적으로 성공한다.
테스트가 비결정적이다.
예외가 발생하지는 않는다.
해당 메소드는 true의 결과를 기대하는데, 실행시 false를 반환해서 테스트에 실패한다.
개별 실행은 성공한다.
💭 원인과 개선하고 싶은 부분
원인
테스트 메서드가 병렬로 처리되는데 동시성 제어가 안되는 것 같다.
개선하고 싶은 부분
userEntity를 저장하는 코드가 많이 중복된다. 이 부분 때문에 실제로 테스트하고싶은게 뭔지 눈에 잘 안들어온다.
//given
UserEntity userEntity = new UserEntity();
userEntity.setId(1L);
userEntity.setEmail("meme@naver.com");
userEntity.setAddress("Seoul");
userEntity.setNickname("meme");
userEntity.setStatus(UserStatus.ACTIVE);
userEntity.setCertificationCode("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
//when
userRepository.save(userEntity);
🧐 'findByIdAndStatus_로_유저_데이터를_찾아올_수_있다()' 는 왜 false를 반환할까?
동시성 제어가 안되더라도 어차피 각 메소드는 모두 실행 초반에 데이터를 생성하니까 데이터가 있을텐데 말이다. save()에서 막힌건 아니고 결과가 다른 이슈이기 때문에 중복 데이터 관련된 예외도 아니다.
💭 해결
userEntity를 저장하는 코드를 다 날리고, 테스트 할 때는 미리 준비된 값을 사용하도록 수정하자. @Sql을 활용하면 된다.
구조는 아래와 같다. resources - sql에 해당 쿼리를 실행하는 sql파일을 만든다.
<user-repository-test-data.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);
💭 문제 해결 후 테스트 코드
<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();
}
}
필요한 데이터를 미리 준비하고나니, 클래스 전체를 테스트해도 동시성 문제가 발생하지 않아 테스트가 성공적으로 동작한다.
커버리지도 100%이다.
🌱 H2를 이용한 service 테스트
참고) method 네임 컨벤션 (get, find 구분)
- get: 해당 데이터가 없으면 에러를 던진다는 의미가 내포되어있다. Entity를 반환한다.
→ getByIdOrElseThrow ( x) / getById(O) - find: Optional을 반환한다.
→ Optional<Entity> findById
public Optional<UserEntity> findById(long id) {
return userRepository.findByIdAndStatus(id, UserStatus.ACTIVE);
}
public UserEntity getById(long id) {
return userRepository.findByIdAndStatus(id, UserStatus.ACTIVE)
.orElseThrow(() -> new ResourceNotFoundException("Users", id));
}
<UserService>
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final JavaMailSender mailSender;
public UserEntity getByEmail(String email) {
return userRepository.findByEmailAndStatus(email, UserStatus.ACTIVE)
.orElseThrow(() -> new ResourceNotFoundException("Users", email));
}
public UserEntity getById(long id) {
return userRepository.findByIdAndStatus(id, UserStatus.ACTIVE)
.orElseThrow(() -> new ResourceNotFoundException("Users", id));
}
@Transactional
public UserEntity create(UserCreateDto userCreateDto) {
UserEntity userEntity = new UserEntity();
userEntity.setEmail(userCreateDto.getEmail());
userEntity.setNickname(userCreateDto.getNickname());
userEntity.setAddress(userCreateDto.getAddress());
userEntity.setStatus(UserStatus.PENDING);
userEntity.setCertificationCode(UUID.randomUUID().toString());
userEntity = userRepository.save(userEntity);
String certificationUrl = generateCertificationUrl(userEntity);
sendCertificationEmail(userCreateDto.getEmail(), certificationUrl);
return userEntity;
}
@Transactional
public UserEntity update(long id, UserUpdateDto userUpdateDto) {
UserEntity userEntity = getById(id);
userEntity.setNickname(userUpdateDto.getNickname());
userEntity.setAddress(userUpdateDto.getAddress());
userEntity = userRepository.save(userEntity);
return userEntity;
}
@Transactional
public void login(long id) {
UserEntity userEntity = userRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException("Users", id));
userEntity.setLastLoginAt(Clock.systemUTC().millis());
}
@Transactional
public void verifyEmail(long id, String certificationCode) {
UserEntity userEntity = userRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException("Users", id));
if (!certificationCode.equals(userEntity.getCertificationCode())) {
throw new CertificationCodeNotMatchedException();
}
userEntity.setStatus(UserStatus.ACTIVE);
}
private void sendCertificationEmail(String email, String certificationUrl) {
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(email);
message.setSubject("Please certify your email address");
message.setText("Please click the following link to certify your email address: " + certificationUrl);
mailSender.send(message);
}
private String generateCertificationUrl(UserEntity userEntity) {
return "http://localhost:8080/api/users/" + userEntity.getId() + "/verify?certificationCode=" + userEntity.getCertificationCode();
}
}
우선 private 메서드는 테스트 하지 않습니다.
이 코드들에도 테스트를 작성하고 싶다는 생각이 드는데, 지금 일단 무작정 테스트를 넣는 것이기 때문에, private 메서드를 테스트하고 싶다는 신호를 무시하겠다. (테스트가 보내는 신호이므로 실제 작업시에는 무시하지 않는게 좋다.)
💭 초기 테스트 코드
<user-service-test-data.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);
<UserServiceTest>
repository test에서 했듯이 sql 파일 사용을 설정한다.
(해당 방식으로 문제가 발생하기때문에, 코드 구현부에 설명을 위한 메소드 두 개만 작성하고 다른 메소드는 모두 생략한다. 아래 문제 해결 코드에 모든 테스트 구현부를 작성해두었다.)
@SpringBootTest
@TestPropertySource("classpath:test-application.properties")
@Sql(value = "/sql/user-service-test-data.sql") // sql 설정
public class UserServiceTest {
@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);
}
}
이렇게하고 테스트 코드를 구현해서 실행시키면 문제가 발생한다.
💭 문제 1
메소드별로 실행시에는 테스트에 성공하지만, 클래스 전체를 실행시킬 경우 아래와 같은 예외가 발생한다.
SQL문 실행에 실패했다.
💭 문제 1의 원인
insert하려는 데이터가 이미 존재하는 데이터이기 때문에, 기본키 값 충돌이 나는 것으로 보인다.
🧐 @Sql은 메소드마다 메소드 실행전에 실행되나? 언제 실행되지?
🧐 repository에서는 이렇게 했을 때 왜 기본키 값 충돌 예외가 안발생했지?
💭 문제 1 해결
데이터 충돌이 나지 않도록, 모든 데이터를 삭제하는 SQL문을 실행시킨다.
<delete-all-data.sql>
데이터 삭제하는 쿼리를 담은 sql파일을 생성한다.
delete from `users` where 1;
<UserServiceTest>
@SpringBootTest
@TestPropertySource("classpath:test-application.properties")
@SqlGroup({
@Sql(value = "/sql/user-service-test-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD),
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
})
public class UserServiceTest {
// ...
}
@SqlGroup을 사용하여 sql파일 여러개 실행하도록 한다.
테스트 실행전 데이터 넣는 코드와 테스트 종료후 데이터 정리하는 코드를 분리해서 실행시킨다.
💭 문제 2
create와 관련한 테스트를 짜는 중 예상하지 못한 에러를 만났다.
@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);
// assertThat(result.getCertificationCode()).isEqualTo("ㅜㅜ"); // 현재는 테스트 할 방법이 없다
}
💭 문제 2의 원인
또 테스트 데이터의 기본키 값이 충돌한 것으로 보인다.
테스트에서 @SqlGroup 어노테이션을 사용하여 클래스 수준에서 SQL 스크립트를 적용하면, 모든 테스트가 동일한 설정을 공유한다. 그러나 각 테스트 메소드의 실행 순서는 보장되지 않기 때문에, 특정 테스트 메소드가 실행될 때 데이터베이스에 이미 데이터가 존재하거나 예상과 다른 상태일 수 있다.
💭 문제 2 해결
각 테스트 메소드가 실행되기 전에 데이터베이스를 적절히 초기화하여 테스트 간의 데이터베이스 상태를 독립적으로 유지해야 한다. 이를 위해 각 테스트 메소드에 대해 @Sql 어노테이션을 개별적으로 적용한다.
@SpringBootTest
@TestPropertySource("classpath:test-application.properties")
public class UserServiceTest {
// ...
@Test
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
void userCreateDto를_이용하여_유저를_생성할_수_있다() {
// ...
}
}
💭 문제 해결 후 테스트 코드
@SpringBootTest
@TestPropertySource("classpath:test-application.properties")
public class UserServiceTest {
@Autowired
private UserService userService;
@MockBean
private JavaMailSender mailSender;
@Test
@Sql(value = "/sql/user-service-test-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
void getByEmail은_ACTIVE_상태의_유저를_찾아올_수_있다() {
//given
String email = "meme@naver.com";
//when
UserEntity result = userService.getByEmail(email);
//then
assertThat(result.getNickname()).isEqualTo("meme");
}
@Test
@Sql(value = "/sql/user-service-test-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
void getByEmail은_PENDING_상태의_유저는_찾아올_수_없다() {
//given
String email = "mymy@naver.com";
//when
//then
assertThatThrownBy(() -> {
UserEntity result = userService.getByEmail(email);
}).isInstanceOf(ResourceNotFoundException.class);
}
@Test
@Sql(value = "/sql/user-service-test-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
void getById는_ACTIVE_상태의_유저를_찾아올_수_있다() {
//given
//when
UserEntity result = userService.getById(1);
//then
assertThat(result.getNickname()).isEqualTo("meme");
}
@Test
@Sql(value = "/sql/user-service-test-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
void getById는_PENDING_상태의_유저는_찾아올_수_없다() {
//given
//when
//then
assertThatThrownBy(() -> {
UserEntity result = userService.getById(2);
}).isInstanceOf(ResourceNotFoundException.class);
}
@Test
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
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);
// assertThat(result.getCertificationCode()).isEqualTo("ㅜㅜ"); // 현재는 테스트 할 방법이 없다 FIXME
}
@Test
@Sql(value = "/sql/user-service-test-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
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
@Sql(value = "/sql/user-service-test-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
void user를_로그인_시키면_마지막_로그인_시간이_변경된다() {
//given
//when
userService.login(1);
//then
UserEntity userEntity = userService.getById(1);
assertThat(userEntity.getLastLoginAt()).isGreaterThan(0L);
// assertThat(result.getLastLoginAt()).isEqualTo("ㅜㅜ"); // 현재는 테스트 할 방법이 없다 FIXME
}
@Test
@Sql(value = "/sql/user-service-test-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
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
@Sql(value = "/sql/user-service-test-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
void PENDING_상태의_사용자는_잘못된_인증_코드를_받으면_에러를_던진다() {
//given
//when
//then
assertThatThrownBy(() -> {
userService.verifyEmail(2, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaac");
}).isInstanceOf(CertificationCodeNotMatchedException.class);
}
}
클래스 전체로 실행시켜도 테스트가 정상 동작한다.
커버리도 100%다.
(PostService도 동일하게 테스트 코드를 작성한다. 비슷한 내용이기 때문에 따로 포스팅하지는 않는다.)
🌱 MockMvc를 이용한 Controller 테스트
Controller 테스트를 위해 MockMvc를 사용하는데, MockMvc는 API 테스트를 하는데 많이 사용되는 도구다.
HealthCheck
<HealthCheckController>
@Tag(name = "헬스 체크")
@RestController
public class HealthCheckController {
@GetMapping("/health_check.html")
public ResponseEntity<Void> healthCheck() {
return ResponseEntity
.ok()
.build();
}
}
<HealthCheckTest>
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureTestDatabase
public class HealthCheckTest {
@Autowired
private MockMvc mockMvc;
@Test
void 헬스_체크_응답이_200으로_내려온다() throws Exception {
mockMvc.perform(get("/health_check.html")) // mockMvc를 이용해 우리 서버에 get방식으로 api를 호출한다.
.andExpect(status().isOk());
}
}
🧐 MockMvc를 사용하니, intelliJ가 메소드에 'throws Exception'을 작성하게 유도한다. 왜일까?
💡 MockMvc와 같이 예외가 예상되지 않는 경우에는 throws Exception을 사용하여 테스트 메소드의 예외를 상위로 던지는 것이 일반적이다. 예외가 발생하면 테스트 프레임워크가 이를 포착하고 해당 테스트는 실패로 기록된다.
UserController
<UserController>
@Tag(name = "유저(users)")
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@ResponseStatus
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUserById(@PathVariable long id) {
return ResponseEntity
.ok()
.body(toResponse(userService.getById(id)));
}
@GetMapping("/{id}/verify")
public ResponseEntity<Void> verifyEmail(
@PathVariable long id,
@RequestParam String certificationCode) {
userService.verifyEmail(id, certificationCode);
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create("http://localhost:3000"))
.build();
}
@GetMapping("/me")
public ResponseEntity<MyProfileResponse> getMyInfo(
@Parameter(name = "EMAIL", in = ParameterIn.HEADER)
@RequestHeader("EMAIL") String email // 일반적으로 스프링 시큐리티를 사용한다면 UserPrincipal 에서 가져옵니다.
) {
UserEntity userEntity = userService.getByEmail(email);
userService.login(userEntity.getId());
return ResponseEntity
.ok()
.body(toMyProfileResponse(userEntity));
}
@PutMapping("/me")
@Parameter(in = ParameterIn.HEADER, name = "EMAIL")
public ResponseEntity<MyProfileResponse> updateMyInfo(
@Parameter(name = "EMAIL", in = ParameterIn.HEADER)
@RequestHeader("EMAIL") String email, // 일반적으로 스프링 시큐리티를 사용한다면 UserPrincipal 에서 가져옵니다.
@RequestBody UserUpdateDto userUpdateDto
) {
UserEntity userEntity = userService.getByEmail(email);
userEntity = userService.update(userEntity.getId(), userUpdateDto);
return ResponseEntity
.ok()
.body(toMyProfileResponse(userEntity));
}
}
<UserControllerTest>
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureTestDatabase
public class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
@Sql(value = "/sql/user-controller-test-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
void 사용자는_특정_유저의_정보를_개인정보는_소거된채_전달_받을_수_있다() throws Exception { // 테스트 함수명 디테일하게 ('개인정보 소거')
//given
//when
//then
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1)) // 응답이 json일 경우, 특정 경로의 데이터가 어때야 한다
.andExpect(jsonPath("$.email").value("meme@naver.com"))
.andExpect(jsonPath("$.nickname").value("meme"))
.andExpect(jsonPath("$.address").doesNotExist()) // address 필드는 받지않는다는 점 명시!
.andExpect(jsonPath("$.status").value("ACTIVE"));
}
@Test
@Sql(value = "/sql/user-controller-test-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
void 사용자는_존재하지_않는_유저의_아이디로_api를_호출할_경우_404_응답을_받는다() throws Exception {
//given
//when
//then
mockMvc.perform(get("/api/users/123456789"))
.andExpect(status().isNotFound())
.andExpect(content().string("Users에서 ID 123456789를 찾을 수 없습니다.")); // 응답이 문자열일 경우 content() 사용
}
@Test
@Sql(value = "/sql/user-controller-test-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
void 사용자는_인증_코드로_계정을_활성화_시킬_수_있다() throws Exception {
//given
//when
//then
mockMvc.perform(get("/api/users/2/verify")
.queryParam("certificationCode", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaab")) // 요청시 쿼리 파라미터 전송
.andExpect(status().isFound());
UserEntity userEntity = userRepository.findById(2L).get();
assertThat(userEntity.getStatus()).isEqualTo(UserStatus.ACTIVE);
}
@Test
@Sql(value = "/sql/user-controller-test-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
void 사용자는_인증_코드가_일치하지_않을_경우_권한_없음_에러를_내려준다() throws Exception {
//given
//when
//then
mockMvc.perform(get("/api/users/2/verify")
.queryParam("certificationCode", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaac"))
.andExpect(status().isForbidden());
}
@Test
@Sql(value = "/sql/user-controller-test-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
void 사용자는_내_정보를_불러올_때_개인정보인_주소도_갖고_올_수_있다() throws Exception {
//given
//when
//then
mockMvc.perform(get("/api/users/me")
.header("EMAIL", "meme@naver.com")) // 요청 Header 설정
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.email").value("meme@naver.com"))
.andExpect(jsonPath("$.nickname").value("meme"))
.andExpect(jsonPath("$.address").value("Seoul"))
.andExpect(jsonPath("$.status").value("ACTIVE"));
}
@Test
@Sql(value = "/sql/user-controller-test-data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(value = "/sql/delete-all-data.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
void 사용자는_내_정보를_수정할_수_있다() throws Exception {
//given
UserUpdateDto userUpdateDto = UserUpdateDto.builder()
.nickname("meme-update")
.address("Pangyo")
.build();
//when
//then
mockMvc.perform(put("/api/users/me")
.header("EMAIL", "meme@naver.com")
.contentType(MediaType.APPLICATION_JSON) // 필요한 값 설정
.content(objectMapper.writeValueAsString(userUpdateDto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.email").value("meme@naver.com"))
.andExpect(jsonPath("$.nickname").value("meme-update"))
.andExpect(jsonPath("$.address").value("Pangyo"))
.andExpect(jsonPath("$.status").value("ACTIVE"));
}
}
이 테스트 코드의 가장 첫 번째 메소드인 '사용자는_특정_유저의_정보를_개인정보는_소거된채_전달_받을_수_있다()'를 ' 보자.
테스트 코드를 짤 때 챙겨야 할 부분을 알 수 있다.
'사용자는_내_정보를_불러올_때_개인정보인_주소도_갖고_올_수_있다()'와 비교해서, '사용자는_특정_유저의_정보를_개인정보는_소거된채_전달_받을_수_있다()'는 주소 정보를 반환하지 않는다.
따라서 아래 두 가지를 신경써서 테스트 메소드를 작성한다.
- 받지않는 필드에 대해, 받지 않는다는 점을 명시한다.
- 개인정보 소거된 채 받는다는 점을 포함해, 디테일한 함수명을 작성한다.
이런 것들이 하나하나 모여 서비스 정책이 생성되는 것이다.
UserCreateController
<UserCreateController>
@Tag(name = "유저(users)")
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserCreateController {
private final UserController userController;
private final UserService userService;
@PostMapping
public ResponseEntity<UserResponse> createUser(@RequestBody UserCreateDto userCreateDto) {
UserEntity userEntity = userService.create(userCreateDto);
return ResponseEntity
.status(HttpStatus.CREATED)
.body(userController.toResponse(userEntity));
}
}
<UserCreateControllerTest>
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureTestDatabase
public class UserCreateControllerTest {
@Autowired
private MockMvc mockMvc;
private final ObjectMapper objectMapper = new ObjectMapper();
@MockBean
private JavaMailSender mailSender;
@Test
void 사용자는_회원가입을_할_수_있고_회원가입된_사용자는_PENDING_상태이다() throws Exception{
//given
UserCreateDto userCreateDto = UserCreateDto.builder()
.email("meme@naver.com")
.nickname("meme")
.address("Pangyo")
.build();
BDDMockito.doNothing().when(mailSender).send(any(SimpleMailMessage.class)); // Mockito 이용해서 이멩리 발송할 때 아무런 동작도 하지않는다.
//when
//then
mockMvc.perform(post("/api/users")
.header("EMAIL", "meme@naver.com")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(userCreateDto))) // userUpdateDto 객체를 JSON 문자열로 변환
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNumber())
.andExpect(jsonPath("$.email").value("meme@naver.com"))
.andExpect(jsonPath("$.nickname").value("meme"))
.andExpect(jsonPath("$.status").value("PENDING"));
}
}
🧐 Dto 객체를 Josn으로 넘겨줄 때, 'writeValueAsString()' 메소드를 사용한다. Json을 보내려는건데 왜 String으로 변환해주는 것 같지?
💡 Json은 텍스트로 이루어진 문자열이다. 사람이 읽을 수 있는 형태로 데이터를 표현하기 위해 '{}', '[]', ':', ',' 등의 기호를 사용하지만, 이는 단지 데이터를 구조화하는 방식일 뿐이다. HTTP 요청 본문에 JSON 데이터를 포함하려면, 객체를 문자열로 변환한 후 전송해야 한다.
String json = objectMapper.writeValueAsString(dto);
(PostController는 동일한 과정이라 생략한다.)
최종 커버리지
DemoApplication을 제외하고 100%를 달성했다.
DemoApplication은 run만 실행하고있기때문에, 테스트 코드를 따로 작성하지 않는다.
🌱 프로젝트의 문제점
- UUID 의존이 함수 내에 숨겨진 것
- Clock 의존이 함수 내에 숨겨진 것 : 마지막 로그인 시간 기록하는 부분
- private 메소드인데, 테스트하고 싶은 느낌이 드는 메소드
- 테스트가 느리다: 거의 모든 테스트가 h2를 사용하고있다. → 중형테스트
- h2 이용한 테스트 할 때에는 동시성 문제도 발생했다.
이 문제점들을 해결하는 과정을 다음 포스팅에서 살펴보자.
'Back-End > Test Code' 카테고리의 다른 글
[트러블 슈팅 - 테스트 코드] 테스트 데이터 삽입 시 발생하는 auto_increment 컬럼값 충돌 문제 해결 (0) | 2024.09.09 |
---|---|
[트러블 슈팅 - 테스트 코드] 테스트 간 데이터 간섭과 트랜잭션 문제 해결 - @SpringBootTest 테스트 격리시키기 (2) | 2024.09.08 |
[테스트 코드와 설계] 빌더 패턴과 엔티티, 그리고 테스트에 대한 조언 (0) | 2024.09.04 |
[테스트 코드와 설계] 의존성 주입과 의존성 역전을 활용하여 테스트 가능성 높이기 (0) | 2024.09.03 |
[테스트 코드와 설계] 테스트에 대한 개발자의 고민과 이론 및 개념 (1) | 2024.09.03 |