기존에는 Postman을 사용해 API 테스트를 진행했지만, 단순한 요청 및 응답 검증만 가능해 내부 비즈니스 로직을 철저히 검증하기 어려웠다.
Postman을 이용한 기존 테스트 방식
- 애플리케이션 실행
- Postman을 이용해 API 요청 후 응답 확인
- 콘솔에 출력된 로그를 직접 확인
- 예상 결과와 다르면 코드 수정 후 다시 실행
이 과정을 정상적인 결과가 나올 때까지 반복해야 했고, 테스트에 많은 시간을 소모하게 되었다.
이러한 비효율적인 방식에서 벗어나기 위해, Spring Boot 테스트 프레임워크를 활용한 단위 테스트와 통합 테스트를 도입하기로 결정했다.
테스트를 위한 어노테이션
기본 테스트 어노테이션
- @Test
- JUnit에서 제공하는 기본 테스트 메서드 어노테이션
- @DisplayName("테스트 설명")
- 테스트의 가독성을 높이기 위해 설명을 추가하는 어노테이션
Spring Boot 테스트 어노테이션
- @ExtendWith(SpringExtension.class)
- JUnit5와 Spring을 통합하여 테스트 실행
- @SpringBootTest
- 통합 테스트를 위한 어노테이션으로, Spring Context를 로드하여 애플리케이션 전체를 테스트
- 단위 테스트에서는 가급적 사용하지 않음
- @WebMvcTest(Controller.class)
- 특정 컨트롤러의 단위 테스트 수행
- Controller 관련 Bean만 로드되며, Service나 Repository는 로드되지 않음
- @DataJpaTest
- JPA 관련 컴포넌트만 로드하여 Repository 테스트 수행
Mockito 기반 어노테이션
- @Mock
- 가짜 객체(Mock 객체)를 생성하여 테스트할 때 사용
- @InjectMocks
- @Mock으로 생성한 객체를 테스트 대상 클래스에 주입
- @MockitoSpyBean
- 실제 객체를 생성하면서도 일부 메서드를 Mock으로 처리
테스트 순서는 domain → repository → service → DTO → Controller 순으로 진행하였다.
domain(entity) Test
- 회원가입 시 ROLE | 1 : N | MEMBER로 매핑되기 때문에 먼저 ROLE 도메인 테스트를 먼저 진행하였다.
- RoleName
public enum RoleName {
ROLE_USER,
ROLE_ADMIN;
}
RoleName은 ROLE_USER와 ROLE_ADMIN 값을 가지는 enum 클래스이다.
- Role
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
private RoleName roleName;
}
Role은 고유 인덱스가 있고, RoleName 타입의 값을 가진다.
RoleTest
class RoleTest {
@Test
@DisplayName("Role 객체 생성 시 ROLE_USER가 설정되어야 한다.")
void testRoleCreation() {
//given
Role role = new Role();
role.setRoleName(RoleName.ROLE_USER);
//then
Assertions.assertThat(role.getRoleName())
.isEqualTo(RoleName.ROLE_USER);
}
@Test
@DisplayName("Role 객체 생성 시 ROLE_ADMIN이 설정되어야 한다.")
void testRoleEnum() {
// Given
Role role = new Role();
role.setRoleName(RoleName.ROLE_ADMIN);
// Then
Assertions.assertThat(role.getRoleName())
.isEqualTo(RoleName.ROLE_ADMIN);
}
@Test
@DisplayName("Role 생성자를 통해 ROLE_USER 역할이 설정된 객체가 생성되어야 한다.")
void testRoleWithConstructor() {
// Given
Role role = new Role(1L, RoleName.ROLE_USER);
// Then
Assertions.assertThat(role.getRoleName())
.isEqualTo(RoleName.ROLE_USER);
// And
Assertions.assertThat(role.getId())
.isNotNull();
}
}
- 도메인 테스트는 순수 도메인 로직을 검증 하는 것이 목적이다. 엔티티 생성, 필드 값 변경, 비즈니스 로직 동작 여부 정도만 확인 하였다.
- 실제 데이터베이스와 연관 짓지 않고, 자바 자체의 객체로 받아들이기로 하였다.
- 빈 객체에 set메소드를 통한 동작 진행
- 객체 생성시 동시 설정 동작 테스트 진행
Junit보단 Assertj를 테스트 라이브러리를 사용하였다. 아무래도 가독성이 좋고 사용하기가 편리한 느낌이 들었다.
Assertion의 assertThat은 검증할 값 실제 값을 넣고, isEqualTo는 기대한 값을 넣어서 이 값과 기대한 값이 일치 하는지 검사하도록 하였다. 그 외 isNotNull(), isFalse, isTrue등의 메서드 등이 있는데 용도에 맞게 사용하면 된다.
MemberTest
public class MemberTest {
Role role = new Role(1L, RoleName.ROLE_USER);
@Test
@DisplayName("회원 엔티티 생성 테스트")
void createMember() {
// given
LocalDate birthday = LocalDate.of(1995, 5, 20);
LocalDateTime signupDate = LocalDateTime.now();
Member member = Member.builder()
.email("test@example.com")
.password("securePassword123")
.phone("010-1234-5678")
.name("홍길동")
.gender(Gender.MALE)
.nickname("길동이")
.birthday(birthday)
.signupDate(signupDate)
.role(role)
.build();// 단순 객체 주입
// when & then
assertThat(member.getEmail()).isEqualTo("test@example.com");
assertThat(member.getPhone()).isEqualTo("010-1234-5678");
assertThat(member.getNickname()).isEqualTo("길동이");
assertThat(member.getGender()).isEqualTo(Gender.MALE);
assertThat(member.getBirthday()).isEqualTo(birthday);
assertThat(member.getSignupDate()).isEqualTo(signupDate);
}
@Test
@DisplayName("회원 정보 수정 테스트")
void updateMemberInfo() {
// given
Member member = Member.builder()
.email("test@example.com")
.password("securePassword123")
.phone("010-1234-5678")
.name("홍길동")
.gender(Gender.MALE)
.nickname("길동이")
.birthday(LocalDate.of(1995, 5, 20))
.signupDate(LocalDateTime.now())
.role(role)
.build();
// when
member.setNickname("새로운닉네임");
member.setPhone("010-9876-5432");
// then
assertThat(member.getNickname()).isEqualTo("새로운닉네임");
assertThat(member.getPhone()).isEqualTo("010-9876-5432");
}
}
회원 객체가 생성되기 전에는 ROLE(유저의 권한)이 필요하여 ROLE을 따로 생성해주었다.
Role과 마찬가지로 객체 생성 및 수정 테스트를 진행하였다.
repository Test
RoleRepostiroyTest
@DataJpaTest
// @ActiveProfiles("test") // apllication-test.properties 파일의 데이터베이스를 사용하도록 설정
class RoleRepositoryTest {
@Autowired
private RoleRepository roleRepository;
@Test
@DisplayName("ROLE 저장 및 조회 테스트")
void findByRoleName() {
// given
Role roleUser = new Role();
Role roleAdmin = new Role();
roleUser.setRoleName(RoleName.ROLE_USER);
roleAdmin.setRoleName(RoleName.ROLE_ADMIN);
// when
roleRepository.saveAll(List.of(roleUser, roleAdmin));
List<Role> all = roleRepository.findAll();
// then
assertThat(all).isNotNull();
assertThat(all).containsAll(List.of(roleUser, roleAdmin));
}
@Test
@DisplayName("ROLE 잘못된 저장 - 오류 발생")
void testSaveRoleWithInvalidData_throwsError() {
// given
Role role = new Role();
assertThatThrownBy(() ->
roleRepository.save(role) // 빈객체 저장 제약조건 위반 exception 발생
).isInstanceOf(ConstraintViolationException.class);
}
}
repository 계층 테스트를 위해 @DataJpaTest 어노테이션을 사용하였다.
@DataJpaTest
- JPA 관련 컴포넌트만 로드하여 리포지토리 및 JPA 엔티티 관련 테스트를 실행할 수 있도록 설정한다.
- 이 어노테이션을 사용하면 실제 데이터베이스와 상호작용하는 인메모리 데이터베이스를 자동으로 설정하기 때문에 데이터베이스에 의존적인 테스트를 진행할 수 있다.
- 기본적으로 H2, HSQL, Derby와 같은 인메모리 데이터베이스를 사용한다.
- @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.None) 어노테이션을 선언하면 기본데이터 베이스를 사용하지 않게 할 수 있다
- 즉 실제로 내가 개발하고 있는 데이터베이스를 사용하게 해준다.
- @Transactional이 기본적으로 사용되기 때문에 각 테스트가 완료되면 트랜잭션 롤백한다.
- Service, Controller 계층의 Bean은 따로 업로드 하지 않는다.
@ActiveProfiles(”test”)
- 이 어노테이션은 application-test.properties에 있는 프로파일을 활성화하여 테스트할 수 있게 해준다.
- “prod”라고 설정하면 application-prod.properties에 있는 프로파일을 활성화하여 테스트를 할 수 있다.
- 실제 데이터베이스를 설정이 가능하고, prod는 운영환경, test는 테스트 환경이며 test와 prod는 일반적으로 사용되는 관습적인 프로파일이다.
- apllication-eee.properties는 @ActiveProfiles(”eee”)로도 가능한데 prod와 test를 사용하는 것이 관습이다.
- @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.None)
- 인메모리 데이터베이스 사용x, 내 개발환경 사용
- @ActiveProfiles(”test”)
- 프로파일에 따른 데이터베이스 사용
@Autowired private RoleRepository roleRepository;
- @DataJpaTest를 통해 JPA관련 컴포넌트를 스캔해 DI가 가능하다.
- ROLE 저장 및 조회 테스트 진행
- AssertJ의 assertThatThrownBy()를 사용해 ROLE을 빈 객체로 저장할 시 Exception 테스트 진행
MemberRepositoryTest
@DataJpaTest
class MemberRepositoryTest {
@Autowired
private MemberRepository memberRepository;
Role role = new Role(1L, RoleName.ROLE_USER);
Member member;
Member savedMember;
@BeforeEach
void setup() {
// given
member = Member.builder()
.email("email@example.com")
.password("password123")
.phone("010-1234-5678")
.name("John Doe")
.gender(Gender.MALE)
.nickname("johnny")
.birthday(LocalDate.of(1990, 1, 1))
.role(role)
.signupDate(LocalDateTime.now())
.build();
savedMember = memberRepository.save(member); // 저장
}
@Test
@DisplayName("회원 저장 및 Id로 조회")
void saveAndFindById() {
// when
Member savedMember = memberRepository.save(member);// 저장
Member findMember = memberRepository.findById(savedMember.getMemberId())
.orElseThrow(() -> new NoSuchElementException("찾기 실패"));
// then
assertThat(findMember).isNotNull();
assertThat(findMember.getEmail()).isEqualTo(savedMember.getEmail());
assertThat(findMember.getName()).isEqualTo(savedMember.getName());
assertThat(findMember.getPassword()).isEqualTo(savedMember.getPassword());
}
@Test
@DisplayName("회원 저장 및 email로 조회")
void saveAndFindByEmail() {
// when
Member findMember = memberRepository.findByEmail(savedMember.getEmail())
.orElseThrow(() -> new NoSuchElementException("찾기 실패"));
// then
assertThat(findMember).isNotNull();
assertThat(findMember.getEmail()).isEqualTo(savedMember.getEmail());
assertThat(findMember.getName()).isEqualTo(savedMember.getName());
assertThat(findMember.getPassword()).isEqualTo(savedMember.getPassword());
}
@Test
@DisplayName("회원 저장 실패 - Role이 빈 객체")
void shouldFailWhenRoleIsEmpty() {
Role emptyRole = new Role();
Member emptyRoleMember = Member.builder()
.email("email@example.com")
.password("password123")
.phone("010-1234-5678")
.name("John Doe")
.gender(Gender.MALE)
.nickname("johnny")
.birthday(LocalDate.of(1990, 1, 1))
.role(emptyRole)
.signupDate(LocalDateTime.now())
.build();
assertThatThrownBy(() ->
memberRepository.save(emptyRoleMember)
).isInstanceOf(InvalidDataAccessApiUsageException.class);
}
@Test
@DisplayName("회원 저장 실패 - 중복 저장")
void shouldFailWhenEmailIsDuplicate() {
// given
Member duplicateMember = Member.builder()
.email("email@example.com")
.password("password123")
.phone("010-1234-5678")
.name("John Doe")
.gender(Gender.MALE)
.nickname("johnny")
.birthday(LocalDate.of(1990, 1, 1))
.role(role)
.signupDate(LocalDateTime.now())
.build();
assertThatThrownBy(() ->
memberRepository.save(duplicateMember)
).isInstanceOf(DataIntegrityViolationException.class);
}
}
- 조회된 회원의 이메일, 이름, 비밀번호가 저장된 값과 일치하는지 테스트 진행
- 조회된 회원의 이메일, 이름, 비밀번호가 저장된 값과 일치 테스트 진행
- 빈 Role을 가진 회원을 저장하려 할 때 InvalidDataAccessApiUsageException 예외가 발생 테스트 진행
- 중복된 회원 정보를 저장할 때 DataIntegrityViolationException 예외가 발생 테스트 진행
Service Test
@ExtendWith(MockitoExtension.class) //Mockito의 기능을 활성화
class MemberSignupServiceImplTest {
@Mock
private MemberRepository memberRepository;
@Mock
private RoleRepository roleRepository;
@Mock // 가짜 객체를 삽입, 사용할 메서드의 반환값은 개발자가 정한다.
private PasswordEncoder passwordEncoder;
@InjectMocks // 필요한 의존성을(위 Mock 들을)자동으로 주입, 실제 우리가 테스트할 부분
private MemberSignupServiceImpl memberSignupService; // 실제객체를 주입해야한다.
MemberSignUpRequestDTO signUpRequestDTO = new MemberSignUpRequestDTO();
@BeforeEach
void setup(){
signUpRequestDTO.setEmail("email@naver.com");
signUpRequestDTO.setPassword("Password@@1");
signUpRequestDTO.setPhone("010-2222-2222");
signUpRequestDTO.setName("name");
signUpRequestDTO.setGender(Gender.MALE);
signUpRequestDTO.setNickname("nickname");
signUpRequestDTO.setBirthday("1991-01-01");
}
@Test
@DisplayName("회원가입 서비스 성공 테스트")
public void testSignup_Success() {
// given
// 비밀번호 암호화 Mock
Mockito.when(passwordEncoder.encode(signUpRequestDTO.getPassword())).thenReturn("encodedPassword");
// 역할 검증 Mock
Role mockRole = new Role(1L, RoleName.ROLE_USER);
Mockito.when(roleRepository.findByRoleName(RoleName.ROLE_USER)).thenReturn(Optional.of(mockRole));
// 이메일이 존재하지 않으면 정상 회원가입 처리
Mockito.when(memberRepository.existsByEmail(signUpRequestDTO.getEmail())).thenReturn(false);
// 닉네임이 존재하지 않으면 정상 회원가입 처리
Mockito.when(memberRepository.existsByNickname(signUpRequestDTO.getNickname())).thenReturn(false);
// when
memberSignupService.signup(signUpRequestDTO);
// then
// memberRepository.save가 한 번 호출되었는지 검증
Mockito.verify(memberRepository, Mockito.times(1)).save(any(Member.class));
}
@Test
@DisplayName("이메일이 이미 존재하는 경우")
public void testSignup_EmailAlreadyExists() {
// given
// 이미 이메일이 존재하는 경우
Mockito.when(memberRepository.existsByEmail(signUpRequestDTO.getEmail())).thenReturn(true);
// when / then
assertThatThrownBy(() ->
memberSignupService.signup(signUpRequestDTO)
).isInstanceOf(EmailAlreadyExistsException.class)
.hasMessage("이메일이 이미 존재합니다.");
}
@Test
@DisplayName("닉네임이 이미 존재하는 경우")
public void testSignup_NicknameAlreadyExists() {
// given
// 이미 이메일이 존재하는 경우
Mockito.when(memberRepository.existsByNickname(signUpRequestDTO.getNickname())).thenReturn(true);
assertThatThrownBy(() ->
memberSignupService.signup(signUpRequestDTO)
).isInstanceOf(DuplicateNicknameException.class)
.hasMessage("이미 사용 중인 닉네임입니다.");
}
@Test
@DisplayName("권한 검증 성공 테스트")
public void testValidateRole_Success() {
// given
RoleName roleName = RoleName.ROLE_USER;
Role mockRole = new Role(1L, roleName);
// when
Mockito.when(roleRepository.findByRoleName(roleName)).thenReturn(Optional.of(mockRole));
Role role = memberSignupService.validateRole(roleName);
// then
Assertions.assertNotNull(role);
Assertions.assertEquals(roleName, role.getRoleName());
}
@Test
@DisplayName("권한 검증 오류 테스트")
public void testValidateRole_RoleNotFound() {
// given
RoleName roleName = RoleName.ROLE_USER;
// when / then
Mockito.when(roleRepository.findByRoleName(roleName)).thenReturn(Optional.empty());
assertThatThrownBy(() ->
memberSignupService.signup(signUpRequestDTO)
).isInstanceOf(RoleNotFoundException.class)
.hasMessage("해당 권한은 존재하지 않습니다.");
}
}
- 회원가입 시 비밀번호 암호화, 역할 검증, 이메일·닉네임 중복 확인 후 save() 호출 여부를 검증
- 이메일이 이미 존재하는 경우 EmailAlreadyExistsException이 발생하는지 확인
- 닉네임이 이미 존재하는 경우 DuplicateNicknameException이 발생하는지 확인
- ROLE_USER가 존재할 때 validateRole()이 올바르게 동작하는지 확인
- 존재하지 않는 역할을 요청할 경우 RuntimeException이 발생하는지 확인
@ExtendWith(MokitoExtension.class)
- JUnit5 에서 Mockito를 사용하기 위한 어노테이션이다.
- Mokito 기능을 활성화하고, 테스트 클래스에서 mocking 기능을 사용할 수 있게 해준다.
@Mock
- Mockito에서 사용되는 어노테이션으로 해당 필드를 mock 객체로 생성해준다.
- mock 객체는 실제 객체 대신에 가짜 객체를 넣기 때문에 실제 동작을 개발자가 정의 해야한다.
@InjectMocks
- @Mock으로 선언된 필드를 @InjectMocks로 선언될 필드에 주입한다.
Mockito를 사용하는 이유
실제 객체 대신 Mock 객체를 생성하여 특정 메서드의 동작을 직접 지정할 수 있다.
- 즉, Repository 등의 외부 의존성을 실제로 실행하지 않고, 우리가 원하는 값이 반환되도록 설정할 수 있다.
- 이를 통해 테스트의 독립성을 유지할 수 있다.
Repository의 동작을 검증할 필요가 없다.
- Service 계층의 테스트를 수행할 때, Repository는 단순히 데이터를 가져오거나 저장하는 역할이므로, 이를 검증하는 것은 의미가 없다.
- 대신 Service 로직이 정상적으로 동작하는지 테스트하는 것이 목적이다.
JPA 컴포넌트가 로드되면 테스트가 무거워진다.
- Spring Boot에서 @DataJpaTest나 실제 @SpringBootTest를 사용하면, JPA 엔티티 매핑, DB 커넥션 등이 포함되면서 테스트 속도가 느려진다.
- 따라서, Service 계층 테스트에서는 JPA를 직접 실행하는 것이 아니라, Repository를 Mocking하여 간단하게 테스트하는 것이 좋다.
우리는 Service를 테스트하는 것이 목적이다.
- Service가 Repository를 어떻게 사용하는지는 중요하지 않으며, 서비스 로직이 예상대로 동작하는지 확인하는 것이 핵심이다.
- 따라서, Repository의 실제 구현을 몰라도 테스트할 수 있도록 Mocking을 활용하는 것이다.
Mockito 사용방법
Stub 설정, 특정 상황에 대한 동작 정의
// existsByEmail이 동작할 때 true를 반환하도록 설정
Mockito.when(memberRepository.existsByEmail("test@email.com")).thenReturn(true);
// 메서드가 void일 경우, donNothing()을 사용하여
Mockito.doNothing().when(memberRepository).deleteById(1L);
//특정 예외를 반환하도록 설정
Mockito.when(memberRepository.existsByEmail("test@email.com"))
.thenThrow(new EmailAlreadyExistsException("이메일이 이미 존재합니다."));
mock 객체의 메서드 호출 검증
// existsByEmail이 한번만 호출되었는지 확인
Mockito.verify(memberRepository, Mockito.times(1)).existsByEmail("test@email.com");
// deleteAll이 한 번도 호출되지 않았는지
Mockito.verify(memberRepository, Mockito.never()).deleteAll();
Argument Matcher 사용
// findByEmail은 인자가 String이기 때문에 어떠한 값을 입력해도 new Member()를 반환
Mockito.when(memberRepository.findByEmail(Mockito.anyString()))
.thenReturn(Optional.of(new Member()));
// eq인자와 같을 때만 new Member()를 반환
Mockito.when(memberRepository.findByEmail(Mockito.eq("specific@email.com")))
.thenReturn(Optional.of(new Member()));
DTO Test
class MemberSignUpRequestDTOTest {
// Validator 초기화
private final ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
private final Validator validator = factory.getValidator();
@Test
@DisplayName("DTO -> JSON 변환 테스트")
void testDtoToJson() throws JsonProcessingException {
ObjectMapper objectMapper = new ObjectMapper();
MemberSignUpRequestDTO dto = new MemberSignUpRequestDTO(
"valid@example.com", // 이메일
"ValidPassword1@", // 비밀번호
"010-1234-5678", // 전화번호
"John Doe", // 이름
Gender.MALE, // 성별 (예시 Gender enum: MALE, FEMALE 등)
"johnny", // 닉네임
"1990-01-01" // 생년월일
);
//
String json = objectMapper.writeValueAsString(dto);
Assertions.assertThat(json)
.isEqualTo(
"{" +
"\\"email\\":\\"valid@example.com\\"," +
"\\"password\\":\\"ValidPassword1@\\"," +
"\\"phone\\":\\"010-1234-5678\\"," +
"\\"name\\":\\"John Doe\\"," +
"\\"gender\\":\\"MALE\\"," +
"\\"nickname\\":\\"johnny\\"," +
"\\"birthday\\":\\"1990-01-01\\"" +
"}"
);
}
@Test
@DisplayName("JSON -> DTO 변환 테스트")
void testJsonToDto() throws JsonProcessingException {
ObjectMapper objectMapper = new ObjectMapper();
// JSON 문자열 정의
String json = "{"
+ "\\"email\\":\\"valid@example.com\\","
+ "\\"password\\":\\"ValidPassword1@\\","
+ "\\"phone\\":\\"010-1234-5678\\","
+ "\\"name\\":\\"John Doe\\","
+ "\\"gender\\":\\"MALE\\","
+ "\\"nickname\\":\\"johnny\\","
+ "\\"birthday\\":\\"1990-01-01\\""
+ "}";
// JSON -> DTO 변환 (역직렬화)
MemberSignUpRequestDTO dto = objectMapper.readValue(json, MemberSignUpRequestDTO.class);
// 변환된 DTO의 값 검증
Assertions.assertThat(dto.getEmail()).isEqualTo("valid@example.com");
Assertions.assertThat(dto.getPassword()).isEqualTo("ValidPassword1@");
Assertions.assertThat(dto.getPhone()).isEqualTo("010-1234-5678");
Assertions.assertThat(dto.getName()).isEqualTo("John Doe");
Assertions.assertThat(dto.getGender()).isEqualTo(Gender.MALE);
Assertions.assertThat(dto.getNickname()).isEqualTo("johnny");
Assertions.assertThat(dto.getBirthday()).isEqualTo("1990-01-01");
}
@Test
@DisplayName("회원가입, 유효성 검사를 실패하지 않아야 한다.")
void validDTOTest() {
// 유효한 데이터로 DTO 생성
MemberSignUpRequestDTO dto = new MemberSignUpRequestDTO(
"valid@example.com", // 이메일
"ValidPassword1@", // 비밀번호
"010-1234-5678", // 전화번호
"John Doe", // 이름
Gender.MALE, // 성별 (예시 Gender enum: MALE, FEMALE 등)
"johnny", // 닉네임
"1990-01-01" // 생년월일
);
// 유효성 검사
Set<ConstraintViolation<MemberSignUpRequestDTO>> violations = validator.validate(dto);
// 유효성 검사가 실패한 항목이 없으면 테스트 통과
assertTrue(violations.isEmpty(), "유효성 검사 실패: " + violations);
}
@Test
@DisplayName("회원가입, 잘못된 데이터 입력시 에러 메시지를 반환한다.")
void invalidDTOTest() {
// 잘못된 데이터로 DTO 생성
MemberSignUpRequestDTO dto = new MemberSignUpRequestDTO(
"invalid-email", // 잘못된 이메일
"short", // 너무 짧은 비밀번호
"invalid-phone", // 잘못된 전화번호 형식
null, // 비어 있는 이름
null, // 성별이 null (필수 입력값)
"a", // 너무 짧은 닉네임
"1990-13-01" // 잘못된 생년월일 형식 (13월)
);
// 유효성 검사
Set<ConstraintViolation<MemberSignUpRequestDTO>> violations = validator.validate(dto);
// 각 에러 메시지를 검증
for (ConstraintViolation<MemberSignUpRequestDTO> violation : violations) {
if ("email".equals(violation.getPropertyPath().toString())) {
assertEquals("이메일 형식이 올바르지 않습니다.", violation.getMessage());
}
if ("password".equals(violation.getPropertyPath().toString())) {
assertEquals("비밀번호는 8자 이상이어야 하며, 소문자, 대문자, 특수문자를 포함해야 합니다.", violation.getMessage());
}
if ("phone".equals(violation.getPropertyPath().toString())) {
assertEquals("전화번호 형식이 올바르지 않습니다.", violation.getMessage());
}
if ("name".equals(violation.getPropertyPath().toString())) {
assertEquals("이름은 필수 입력 값입니다.", violation.getMessage());
}
if ("gender".equals(violation.getPropertyPath().toString())) {
assertEquals("성별은 필수 입력 값입니다.", violation.getMessage());
}
if ("nickname".equals(violation.getPropertyPath().toString())) {
assertEquals("닉네임은 2자 이상이어야 합니다.", violation.getMessage());
}
if ("birthday".equals(violation.getPropertyPath().toString())) {
assertEquals("생년월일은 YYYY-MM-DD 형식이어야 합니다.", violation.getMessage());
}
}
}
}
DTO (Data Transfer Object) 테스트는 일반적으로 스프링 부트 컨텍스트나 Bean이 필요 하지 않다. DTO는 단순히 데이터를 전달하기 위한 객체로, 비즈니스 로직이나 데이터베이스와의 상호작용이 없기 때문에, 해당 객체만 테스트하면 된다.
MemberSignUpRequestDTO의 직렬화/역직렬화(JSON 변환), 유효성 검사(Validation) 테스트만 수행하였다. 이미 Domain, Repository, Service 테스트가 검증되었기 때문이다.
우리는 DTO의 역할만 검증하면 된다.
JSON 변환(직렬화) 테스트
- ObjectMapper.writeValueAsString(dto)를 사용해 DTO를 JSON 문자열로 변환(직렬화)하여 변환된 JSON이 예상 값과 동일한지 검증하였다.
JSON을 DTO로 변환(역직렬화) 테스트
- ObjectMapper.readValue(json, DTO.class)를 사용해 JSON을 DTO로 변환하여 변환된 DTO가 올바른 값을 가지는지 검증하였다.
올바른 데이터의 경우 유효성 검사 테스트
- validator.validate(dto)를 사용해 DTO의 유효성을 검사한다. 에러가 없어야 정상적인 DTO 객체임을 검증하였다.
잘못된 데이터 입력 시, 올바른 에러 메시지가 반환되는지 테스트
- validator.validate(dto)를 사용해 유효성 검사를 수행한다. 특정 필드(email, password, phone 등)가 올바르지 않은 경우, 적절한 에러 메시지가 나오는지 검증하였다.
Controller 테스트
@WebMvcTest(MemberSignupController.class) // 필요한 컨트롤러만 로드하여 테스트한다.
@Import(TestConfig.class)
class MemberSignupControllerTest {
//MockBean은 3.4.0부터 deprecated 되었다.
//MockitoBean을 사용하자
//@MockitoBean ApplicationContext의 빈을 모킹한다.
@MockitoBean
private ValidationService validationService; // Mock으로 생성하여 독립 테스트 가능
@MockitoBean
private MemberSignupService memberSignupService; // 실제 로직 실행 없이 가짜 객체로 대체
@Autowired
private MockMvc mockMvc; // 내장 서버를 실행하지 않고 컨트롤러를 테스트 할 수 있는 도구;[-
@Autowired
private ObjectMapper objectMapper;
@Test
@DisplayName("유저 회원가입 요청 성공 테스트")
void signup() throws Exception{
// given
MemberSignUpRequestDTO memberSignUpRequestDTO = new MemberSignUpRequestDTO("email@naver.com",
"Password12@",
"010-0000-0000",
"name",
Gender.MALE,
"nickname1",
"1991-01-01");
// validationService가 항상 빈 에러 리스트를 반환하도록 Mock 설정
given(validationService.validate(any(BindingResult.class)))
.willReturn(Collections.emptyMap());
// when then
mockMvc.perform(post("/api/signup")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(memberSignUpRequestDTO)))
.andExpect(status().isCreated());
}
// 유효성 검사 실패 테스트
@Test
@DisplayName("유효성 검사 실패 시 400 상태 코드 반환")
void signupValidationFailTest() throws Exception {
// 유효성 검사를 실패한 경우를 가정하여 에러 메시지가 설정되는 것을 가정한다.
Map<String, String> errors = new HashMap<>();
errors.put("username", "Username is required");
errors.put("password", "Password must be at least 6 characters");
given(validationService.validate(any(BindingResult.class))).willReturn(errors);
// 회원가입 요청 DTO (잘못된 데이터)
MemberSignUpRequestDTO requestDTO = new MemberSignUpRequestDTO();
mockMvc.perform(post("/api/signup")
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(requestDTO)))
.andExpect(status().isBadRequest()) // 응답 상태 코드 400 확인
.andExpect(jsonPath("$.username").value("Username is required")) // username 필드 에러 메시지 검증
.andExpect(jsonPath("$.password").value("Password must be at least 6 characters")); // password 필드 에러 메시지 검증
}
}
@WebMvcTest
- WebMvcTest는 스프링 부트에서 컨트롤러를 테스트할 때 사용하는 어노테이션으로, 웹 레이어만 로드하여 테스트를 수행한다.
- Spring MVC 관련 빈들, DispatcherServlet, HandlerMapping, MessageConverters, Validaator, Jackson 등의 빈들이 로드 된다.
- 또한 HTTP 요청과 응답을 처리하는 데 필요한 @ControllerAdvice 또는 @ExceptionHanlder로 설정된 예외 처리 기능이 활성화 된다.
- Service, Respository, Database와 관련된 빈들은 로드하지 않는다. 실제 DB나 비즈니스 로직을 처리하는 서비스 로직은 따로 @MockBean을 선언하여 모킹하여야 한다.
- Spring Boot 3.4.0 이상 버전부터는 MockBean은 3.4.0부터 deprecated 되어 MockitoBean을 사용하자.
@Import(TestConfig.class)
TestConfig
@TestConfiguration
public class TestConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/api/**").permitAll()
.anyRequest().authenticated());
http
.csrf(AbstractHttpConfigurer::disable);
return http.build();
}
}
@TestConfiguration은 테스트 환경에서만 사용할 수 있는 빈을 정의할 수 있다. 즉, 실제 애플리케이션의 설정에 영향을 주지 않고 테스트에 필요한 빈만 로드할 수 있다는 장점이 있다.
TestConfig를 import한 이유는, @WebMvcTest가 외부에서 테스트하는 환경처럼 동작하기 때문에 POST, PUT, DELETE와 같은 상태 변경 요청에 대해 CSRF 토큰을 요구한다. 따라서 테스트 환경에서 CSRF 보호를 비활성화 시키기 위해 TestConfig를 사용하여 CSRF를 차단하였다.
MockMvc
MockMvc는 스프링 테스트에서 HTTP 요청을 모킹하여 테스트하는데 사용된다. 실제 서버를 실행하지 않고 컨트롤러 동작을 검증 할 수 있다.
HTTP 메서드인 GET, POST, PUT, DELETE와 요청 파라미터, 헤더, 본문 등 설정가능하고 검증할 수 있다.
ObjectMapper
ObjectMapper는 JSON → DTO(직렬화), DTO → JSON(역직렬화) 하는데 사용된다.
이 컨트롤러 테스트에서 사용자는 JSON 데이터를 넘겨주기 때문에 역직렬화를 하는데 목적으로 ObjectMapper를 사용하였다.
- 유효성 검사 실패 시 400 상태 코드 반환 테스트:
- 회원가입 요청 시 유효성 검사가 실패하면, 서버가 **400 상태 코드 (Bad Request)**를 반환하는지 확인하였다.
- 유효성 검사 실패 시 에러 메시지 반환 테스트:
- 회원가입 요청 시 유효성 검사가 실패하면, 적절한 에러 메시지가 포함된 JSON 응답이 반환되는지 확인하였다.
다음 글은 통합테스트를 진행하여 포스팅하고자 한다.
'Spring' 카테고리의 다른 글
Spring Boot - Querydsl (0) | 2025.01.15 |
---|---|
Spring Boot - ResponseEntity 클래스 (0) | 2025.01.11 |
Spring Boot - Lombok (0) | 2024.04.03 |
Spirng Boot - REST API로 CRUD 만들기 (0) | 2024.04.03 |
Spring Security 살펴보기 (0) | 2024.02.22 |
기존에는 Postman을 사용해 API 테스트를 진행했지만, 단순한 요청 및 응답 검증만 가능해 내부 비즈니스 로직을 철저히 검증하기 어려웠다.
Postman을 이용한 기존 테스트 방식
- 애플리케이션 실행
- Postman을 이용해 API 요청 후 응답 확인
- 콘솔에 출력된 로그를 직접 확인
- 예상 결과와 다르면 코드 수정 후 다시 실행
이 과정을 정상적인 결과가 나올 때까지 반복해야 했고, 테스트에 많은 시간을 소모하게 되었다.
이러한 비효율적인 방식에서 벗어나기 위해, Spring Boot 테스트 프레임워크를 활용한 단위 테스트와 통합 테스트를 도입하기로 결정했다.
테스트를 위한 어노테이션
기본 테스트 어노테이션
- @Test
- JUnit에서 제공하는 기본 테스트 메서드 어노테이션
- @DisplayName("테스트 설명")
- 테스트의 가독성을 높이기 위해 설명을 추가하는 어노테이션
Spring Boot 테스트 어노테이션
- @ExtendWith(SpringExtension.class)
- JUnit5와 Spring을 통합하여 테스트 실행
- @SpringBootTest
- 통합 테스트를 위한 어노테이션으로, Spring Context를 로드하여 애플리케이션 전체를 테스트
- 단위 테스트에서는 가급적 사용하지 않음
- @WebMvcTest(Controller.class)
- 특정 컨트롤러의 단위 테스트 수행
- Controller 관련 Bean만 로드되며, Service나 Repository는 로드되지 않음
- @DataJpaTest
- JPA 관련 컴포넌트만 로드하여 Repository 테스트 수행
Mockito 기반 어노테이션
- @Mock
- 가짜 객체(Mock 객체)를 생성하여 테스트할 때 사용
- @InjectMocks
- @Mock으로 생성한 객체를 테스트 대상 클래스에 주입
- @MockitoSpyBean
- 실제 객체를 생성하면서도 일부 메서드를 Mock으로 처리
테스트 순서는 domain → repository → service → DTO → Controller 순으로 진행하였다.
domain(entity) Test
- 회원가입 시 ROLE | 1 : N | MEMBER로 매핑되기 때문에 먼저 ROLE 도메인 테스트를 먼저 진행하였다.
- RoleName
public enum RoleName {
ROLE_USER,
ROLE_ADMIN;
}
RoleName은 ROLE_USER와 ROLE_ADMIN 값을 가지는 enum 클래스이다.
- Role
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
private RoleName roleName;
}
Role은 고유 인덱스가 있고, RoleName 타입의 값을 가진다.
RoleTest
class RoleTest {
@Test
@DisplayName("Role 객체 생성 시 ROLE_USER가 설정되어야 한다.")
void testRoleCreation() {
//given
Role role = new Role();
role.setRoleName(RoleName.ROLE_USER);
//then
Assertions.assertThat(role.getRoleName())
.isEqualTo(RoleName.ROLE_USER);
}
@Test
@DisplayName("Role 객체 생성 시 ROLE_ADMIN이 설정되어야 한다.")
void testRoleEnum() {
// Given
Role role = new Role();
role.setRoleName(RoleName.ROLE_ADMIN);
// Then
Assertions.assertThat(role.getRoleName())
.isEqualTo(RoleName.ROLE_ADMIN);
}
@Test
@DisplayName("Role 생성자를 통해 ROLE_USER 역할이 설정된 객체가 생성되어야 한다.")
void testRoleWithConstructor() {
// Given
Role role = new Role(1L, RoleName.ROLE_USER);
// Then
Assertions.assertThat(role.getRoleName())
.isEqualTo(RoleName.ROLE_USER);
// And
Assertions.assertThat(role.getId())
.isNotNull();
}
}
- 도메인 테스트는 순수 도메인 로직을 검증 하는 것이 목적이다. 엔티티 생성, 필드 값 변경, 비즈니스 로직 동작 여부 정도만 확인 하였다.
- 실제 데이터베이스와 연관 짓지 않고, 자바 자체의 객체로 받아들이기로 하였다.
- 빈 객체에 set메소드를 통한 동작 진행
- 객체 생성시 동시 설정 동작 테스트 진행
Junit보단 Assertj를 테스트 라이브러리를 사용하였다. 아무래도 가독성이 좋고 사용하기가 편리한 느낌이 들었다.
Assertion의 assertThat은 검증할 값 실제 값을 넣고, isEqualTo는 기대한 값을 넣어서 이 값과 기대한 값이 일치 하는지 검사하도록 하였다. 그 외 isNotNull(), isFalse, isTrue등의 메서드 등이 있는데 용도에 맞게 사용하면 된다.
MemberTest
public class MemberTest {
Role role = new Role(1L, RoleName.ROLE_USER);
@Test
@DisplayName("회원 엔티티 생성 테스트")
void createMember() {
// given
LocalDate birthday = LocalDate.of(1995, 5, 20);
LocalDateTime signupDate = LocalDateTime.now();
Member member = Member.builder()
.email("test@example.com")
.password("securePassword123")
.phone("010-1234-5678")
.name("홍길동")
.gender(Gender.MALE)
.nickname("길동이")
.birthday(birthday)
.signupDate(signupDate)
.role(role)
.build();// 단순 객체 주입
// when & then
assertThat(member.getEmail()).isEqualTo("test@example.com");
assertThat(member.getPhone()).isEqualTo("010-1234-5678");
assertThat(member.getNickname()).isEqualTo("길동이");
assertThat(member.getGender()).isEqualTo(Gender.MALE);
assertThat(member.getBirthday()).isEqualTo(birthday);
assertThat(member.getSignupDate()).isEqualTo(signupDate);
}
@Test
@DisplayName("회원 정보 수정 테스트")
void updateMemberInfo() {
// given
Member member = Member.builder()
.email("test@example.com")
.password("securePassword123")
.phone("010-1234-5678")
.name("홍길동")
.gender(Gender.MALE)
.nickname("길동이")
.birthday(LocalDate.of(1995, 5, 20))
.signupDate(LocalDateTime.now())
.role(role)
.build();
// when
member.setNickname("새로운닉네임");
member.setPhone("010-9876-5432");
// then
assertThat(member.getNickname()).isEqualTo("새로운닉네임");
assertThat(member.getPhone()).isEqualTo("010-9876-5432");
}
}
회원 객체가 생성되기 전에는 ROLE(유저의 권한)이 필요하여 ROLE을 따로 생성해주었다.
Role과 마찬가지로 객체 생성 및 수정 테스트를 진행하였다.
repository Test
RoleRepostiroyTest
@DataJpaTest
// @ActiveProfiles("test") // apllication-test.properties 파일의 데이터베이스를 사용하도록 설정
class RoleRepositoryTest {
@Autowired
private RoleRepository roleRepository;
@Test
@DisplayName("ROLE 저장 및 조회 테스트")
void findByRoleName() {
// given
Role roleUser = new Role();
Role roleAdmin = new Role();
roleUser.setRoleName(RoleName.ROLE_USER);
roleAdmin.setRoleName(RoleName.ROLE_ADMIN);
// when
roleRepository.saveAll(List.of(roleUser, roleAdmin));
List<Role> all = roleRepository.findAll();
// then
assertThat(all).isNotNull();
assertThat(all).containsAll(List.of(roleUser, roleAdmin));
}
@Test
@DisplayName("ROLE 잘못된 저장 - 오류 발생")
void testSaveRoleWithInvalidData_throwsError() {
// given
Role role = new Role();
assertThatThrownBy(() ->
roleRepository.save(role) // 빈객체 저장 제약조건 위반 exception 발생
).isInstanceOf(ConstraintViolationException.class);
}
}
repository 계층 테스트를 위해 @DataJpaTest 어노테이션을 사용하였다.
@DataJpaTest
- JPA 관련 컴포넌트만 로드하여 리포지토리 및 JPA 엔티티 관련 테스트를 실행할 수 있도록 설정한다.
- 이 어노테이션을 사용하면 실제 데이터베이스와 상호작용하는 인메모리 데이터베이스를 자동으로 설정하기 때문에 데이터베이스에 의존적인 테스트를 진행할 수 있다.
- 기본적으로 H2, HSQL, Derby와 같은 인메모리 데이터베이스를 사용한다.
- @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.None) 어노테이션을 선언하면 기본데이터 베이스를 사용하지 않게 할 수 있다
- 즉 실제로 내가 개발하고 있는 데이터베이스를 사용하게 해준다.
- @Transactional이 기본적으로 사용되기 때문에 각 테스트가 완료되면 트랜잭션 롤백한다.
- Service, Controller 계층의 Bean은 따로 업로드 하지 않는다.
@ActiveProfiles(”test”)
- 이 어노테이션은 application-test.properties에 있는 프로파일을 활성화하여 테스트할 수 있게 해준다.
- “prod”라고 설정하면 application-prod.properties에 있는 프로파일을 활성화하여 테스트를 할 수 있다.
- 실제 데이터베이스를 설정이 가능하고, prod는 운영환경, test는 테스트 환경이며 test와 prod는 일반적으로 사용되는 관습적인 프로파일이다.
- apllication-eee.properties는 @ActiveProfiles(”eee”)로도 가능한데 prod와 test를 사용하는 것이 관습이다.
- @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.None)
- 인메모리 데이터베이스 사용x, 내 개발환경 사용
- @ActiveProfiles(”test”)
- 프로파일에 따른 데이터베이스 사용
@Autowired private RoleRepository roleRepository;
- @DataJpaTest를 통해 JPA관련 컴포넌트를 스캔해 DI가 가능하다.
- ROLE 저장 및 조회 테스트 진행
- AssertJ의 assertThatThrownBy()를 사용해 ROLE을 빈 객체로 저장할 시 Exception 테스트 진행
MemberRepositoryTest
@DataJpaTest
class MemberRepositoryTest {
@Autowired
private MemberRepository memberRepository;
Role role = new Role(1L, RoleName.ROLE_USER);
Member member;
Member savedMember;
@BeforeEach
void setup() {
// given
member = Member.builder()
.email("email@example.com")
.password("password123")
.phone("010-1234-5678")
.name("John Doe")
.gender(Gender.MALE)
.nickname("johnny")
.birthday(LocalDate.of(1990, 1, 1))
.role(role)
.signupDate(LocalDateTime.now())
.build();
savedMember = memberRepository.save(member); // 저장
}
@Test
@DisplayName("회원 저장 및 Id로 조회")
void saveAndFindById() {
// when
Member savedMember = memberRepository.save(member);// 저장
Member findMember = memberRepository.findById(savedMember.getMemberId())
.orElseThrow(() -> new NoSuchElementException("찾기 실패"));
// then
assertThat(findMember).isNotNull();
assertThat(findMember.getEmail()).isEqualTo(savedMember.getEmail());
assertThat(findMember.getName()).isEqualTo(savedMember.getName());
assertThat(findMember.getPassword()).isEqualTo(savedMember.getPassword());
}
@Test
@DisplayName("회원 저장 및 email로 조회")
void saveAndFindByEmail() {
// when
Member findMember = memberRepository.findByEmail(savedMember.getEmail())
.orElseThrow(() -> new NoSuchElementException("찾기 실패"));
// then
assertThat(findMember).isNotNull();
assertThat(findMember.getEmail()).isEqualTo(savedMember.getEmail());
assertThat(findMember.getName()).isEqualTo(savedMember.getName());
assertThat(findMember.getPassword()).isEqualTo(savedMember.getPassword());
}
@Test
@DisplayName("회원 저장 실패 - Role이 빈 객체")
void shouldFailWhenRoleIsEmpty() {
Role emptyRole = new Role();
Member emptyRoleMember = Member.builder()
.email("email@example.com")
.password("password123")
.phone("010-1234-5678")
.name("John Doe")
.gender(Gender.MALE)
.nickname("johnny")
.birthday(LocalDate.of(1990, 1, 1))
.role(emptyRole)
.signupDate(LocalDateTime.now())
.build();
assertThatThrownBy(() ->
memberRepository.save(emptyRoleMember)
).isInstanceOf(InvalidDataAccessApiUsageException.class);
}
@Test
@DisplayName("회원 저장 실패 - 중복 저장")
void shouldFailWhenEmailIsDuplicate() {
// given
Member duplicateMember = Member.builder()
.email("email@example.com")
.password("password123")
.phone("010-1234-5678")
.name("John Doe")
.gender(Gender.MALE)
.nickname("johnny")
.birthday(LocalDate.of(1990, 1, 1))
.role(role)
.signupDate(LocalDateTime.now())
.build();
assertThatThrownBy(() ->
memberRepository.save(duplicateMember)
).isInstanceOf(DataIntegrityViolationException.class);
}
}
- 조회된 회원의 이메일, 이름, 비밀번호가 저장된 값과 일치하는지 테스트 진행
- 조회된 회원의 이메일, 이름, 비밀번호가 저장된 값과 일치 테스트 진행
- 빈 Role을 가진 회원을 저장하려 할 때 InvalidDataAccessApiUsageException 예외가 발생 테스트 진행
- 중복된 회원 정보를 저장할 때 DataIntegrityViolationException 예외가 발생 테스트 진행
Service Test
@ExtendWith(MockitoExtension.class) //Mockito의 기능을 활성화
class MemberSignupServiceImplTest {
@Mock
private MemberRepository memberRepository;
@Mock
private RoleRepository roleRepository;
@Mock // 가짜 객체를 삽입, 사용할 메서드의 반환값은 개발자가 정한다.
private PasswordEncoder passwordEncoder;
@InjectMocks // 필요한 의존성을(위 Mock 들을)자동으로 주입, 실제 우리가 테스트할 부분
private MemberSignupServiceImpl memberSignupService; // 실제객체를 주입해야한다.
MemberSignUpRequestDTO signUpRequestDTO = new MemberSignUpRequestDTO();
@BeforeEach
void setup(){
signUpRequestDTO.setEmail("email@naver.com");
signUpRequestDTO.setPassword("Password@@1");
signUpRequestDTO.setPhone("010-2222-2222");
signUpRequestDTO.setName("name");
signUpRequestDTO.setGender(Gender.MALE);
signUpRequestDTO.setNickname("nickname");
signUpRequestDTO.setBirthday("1991-01-01");
}
@Test
@DisplayName("회원가입 서비스 성공 테스트")
public void testSignup_Success() {
// given
// 비밀번호 암호화 Mock
Mockito.when(passwordEncoder.encode(signUpRequestDTO.getPassword())).thenReturn("encodedPassword");
// 역할 검증 Mock
Role mockRole = new Role(1L, RoleName.ROLE_USER);
Mockito.when(roleRepository.findByRoleName(RoleName.ROLE_USER)).thenReturn(Optional.of(mockRole));
// 이메일이 존재하지 않으면 정상 회원가입 처리
Mockito.when(memberRepository.existsByEmail(signUpRequestDTO.getEmail())).thenReturn(false);
// 닉네임이 존재하지 않으면 정상 회원가입 처리
Mockito.when(memberRepository.existsByNickname(signUpRequestDTO.getNickname())).thenReturn(false);
// when
memberSignupService.signup(signUpRequestDTO);
// then
// memberRepository.save가 한 번 호출되었는지 검증
Mockito.verify(memberRepository, Mockito.times(1)).save(any(Member.class));
}
@Test
@DisplayName("이메일이 이미 존재하는 경우")
public void testSignup_EmailAlreadyExists() {
// given
// 이미 이메일이 존재하는 경우
Mockito.when(memberRepository.existsByEmail(signUpRequestDTO.getEmail())).thenReturn(true);
// when / then
assertThatThrownBy(() ->
memberSignupService.signup(signUpRequestDTO)
).isInstanceOf(EmailAlreadyExistsException.class)
.hasMessage("이메일이 이미 존재합니다.");
}
@Test
@DisplayName("닉네임이 이미 존재하는 경우")
public void testSignup_NicknameAlreadyExists() {
// given
// 이미 이메일이 존재하는 경우
Mockito.when(memberRepository.existsByNickname(signUpRequestDTO.getNickname())).thenReturn(true);
assertThatThrownBy(() ->
memberSignupService.signup(signUpRequestDTO)
).isInstanceOf(DuplicateNicknameException.class)
.hasMessage("이미 사용 중인 닉네임입니다.");
}
@Test
@DisplayName("권한 검증 성공 테스트")
public void testValidateRole_Success() {
// given
RoleName roleName = RoleName.ROLE_USER;
Role mockRole = new Role(1L, roleName);
// when
Mockito.when(roleRepository.findByRoleName(roleName)).thenReturn(Optional.of(mockRole));
Role role = memberSignupService.validateRole(roleName);
// then
Assertions.assertNotNull(role);
Assertions.assertEquals(roleName, role.getRoleName());
}
@Test
@DisplayName("권한 검증 오류 테스트")
public void testValidateRole_RoleNotFound() {
// given
RoleName roleName = RoleName.ROLE_USER;
// when / then
Mockito.when(roleRepository.findByRoleName(roleName)).thenReturn(Optional.empty());
assertThatThrownBy(() ->
memberSignupService.signup(signUpRequestDTO)
).isInstanceOf(RoleNotFoundException.class)
.hasMessage("해당 권한은 존재하지 않습니다.");
}
}
- 회원가입 시 비밀번호 암호화, 역할 검증, 이메일·닉네임 중복 확인 후 save() 호출 여부를 검증
- 이메일이 이미 존재하는 경우 EmailAlreadyExistsException이 발생하는지 확인
- 닉네임이 이미 존재하는 경우 DuplicateNicknameException이 발생하는지 확인
- ROLE_USER가 존재할 때 validateRole()이 올바르게 동작하는지 확인
- 존재하지 않는 역할을 요청할 경우 RuntimeException이 발생하는지 확인
@ExtendWith(MokitoExtension.class)
- JUnit5 에서 Mockito를 사용하기 위한 어노테이션이다.
- Mokito 기능을 활성화하고, 테스트 클래스에서 mocking 기능을 사용할 수 있게 해준다.
@Mock
- Mockito에서 사용되는 어노테이션으로 해당 필드를 mock 객체로 생성해준다.
- mock 객체는 실제 객체 대신에 가짜 객체를 넣기 때문에 실제 동작을 개발자가 정의 해야한다.
@InjectMocks
- @Mock으로 선언된 필드를 @InjectMocks로 선언될 필드에 주입한다.
Mockito를 사용하는 이유
실제 객체 대신 Mock 객체를 생성하여 특정 메서드의 동작을 직접 지정할 수 있다.
- 즉, Repository 등의 외부 의존성을 실제로 실행하지 않고, 우리가 원하는 값이 반환되도록 설정할 수 있다.
- 이를 통해 테스트의 독립성을 유지할 수 있다.
Repository의 동작을 검증할 필요가 없다.
- Service 계층의 테스트를 수행할 때, Repository는 단순히 데이터를 가져오거나 저장하는 역할이므로, 이를 검증하는 것은 의미가 없다.
- 대신 Service 로직이 정상적으로 동작하는지 테스트하는 것이 목적이다.
JPA 컴포넌트가 로드되면 테스트가 무거워진다.
- Spring Boot에서 @DataJpaTest나 실제 @SpringBootTest를 사용하면, JPA 엔티티 매핑, DB 커넥션 등이 포함되면서 테스트 속도가 느려진다.
- 따라서, Service 계층 테스트에서는 JPA를 직접 실행하는 것이 아니라, Repository를 Mocking하여 간단하게 테스트하는 것이 좋다.
우리는 Service를 테스트하는 것이 목적이다.
- Service가 Repository를 어떻게 사용하는지는 중요하지 않으며, 서비스 로직이 예상대로 동작하는지 확인하는 것이 핵심이다.
- 따라서, Repository의 실제 구현을 몰라도 테스트할 수 있도록 Mocking을 활용하는 것이다.
Mockito 사용방법
Stub 설정, 특정 상황에 대한 동작 정의
// existsByEmail이 동작할 때 true를 반환하도록 설정
Mockito.when(memberRepository.existsByEmail("test@email.com")).thenReturn(true);
// 메서드가 void일 경우, donNothing()을 사용하여
Mockito.doNothing().when(memberRepository).deleteById(1L);
//특정 예외를 반환하도록 설정
Mockito.when(memberRepository.existsByEmail("test@email.com"))
.thenThrow(new EmailAlreadyExistsException("이메일이 이미 존재합니다."));
mock 객체의 메서드 호출 검증
// existsByEmail이 한번만 호출되었는지 확인
Mockito.verify(memberRepository, Mockito.times(1)).existsByEmail("test@email.com");
// deleteAll이 한 번도 호출되지 않았는지
Mockito.verify(memberRepository, Mockito.never()).deleteAll();
Argument Matcher 사용
// findByEmail은 인자가 String이기 때문에 어떠한 값을 입력해도 new Member()를 반환
Mockito.when(memberRepository.findByEmail(Mockito.anyString()))
.thenReturn(Optional.of(new Member()));
// eq인자와 같을 때만 new Member()를 반환
Mockito.when(memberRepository.findByEmail(Mockito.eq("specific@email.com")))
.thenReturn(Optional.of(new Member()));
DTO Test
class MemberSignUpRequestDTOTest {
// Validator 초기화
private final ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
private final Validator validator = factory.getValidator();
@Test
@DisplayName("DTO -> JSON 변환 테스트")
void testDtoToJson() throws JsonProcessingException {
ObjectMapper objectMapper = new ObjectMapper();
MemberSignUpRequestDTO dto = new MemberSignUpRequestDTO(
"valid@example.com", // 이메일
"ValidPassword1@", // 비밀번호
"010-1234-5678", // 전화번호
"John Doe", // 이름
Gender.MALE, // 성별 (예시 Gender enum: MALE, FEMALE 등)
"johnny", // 닉네임
"1990-01-01" // 생년월일
);
//
String json = objectMapper.writeValueAsString(dto);
Assertions.assertThat(json)
.isEqualTo(
"{" +
"\\"email\\":\\"valid@example.com\\"," +
"\\"password\\":\\"ValidPassword1@\\"," +
"\\"phone\\":\\"010-1234-5678\\"," +
"\\"name\\":\\"John Doe\\"," +
"\\"gender\\":\\"MALE\\"," +
"\\"nickname\\":\\"johnny\\"," +
"\\"birthday\\":\\"1990-01-01\\"" +
"}"
);
}
@Test
@DisplayName("JSON -> DTO 변환 테스트")
void testJsonToDto() throws JsonProcessingException {
ObjectMapper objectMapper = new ObjectMapper();
// JSON 문자열 정의
String json = "{"
+ "\\"email\\":\\"valid@example.com\\","
+ "\\"password\\":\\"ValidPassword1@\\","
+ "\\"phone\\":\\"010-1234-5678\\","
+ "\\"name\\":\\"John Doe\\","
+ "\\"gender\\":\\"MALE\\","
+ "\\"nickname\\":\\"johnny\\","
+ "\\"birthday\\":\\"1990-01-01\\""
+ "}";
// JSON -> DTO 변환 (역직렬화)
MemberSignUpRequestDTO dto = objectMapper.readValue(json, MemberSignUpRequestDTO.class);
// 변환된 DTO의 값 검증
Assertions.assertThat(dto.getEmail()).isEqualTo("valid@example.com");
Assertions.assertThat(dto.getPassword()).isEqualTo("ValidPassword1@");
Assertions.assertThat(dto.getPhone()).isEqualTo("010-1234-5678");
Assertions.assertThat(dto.getName()).isEqualTo("John Doe");
Assertions.assertThat(dto.getGender()).isEqualTo(Gender.MALE);
Assertions.assertThat(dto.getNickname()).isEqualTo("johnny");
Assertions.assertThat(dto.getBirthday()).isEqualTo("1990-01-01");
}
@Test
@DisplayName("회원가입, 유효성 검사를 실패하지 않아야 한다.")
void validDTOTest() {
// 유효한 데이터로 DTO 생성
MemberSignUpRequestDTO dto = new MemberSignUpRequestDTO(
"valid@example.com", // 이메일
"ValidPassword1@", // 비밀번호
"010-1234-5678", // 전화번호
"John Doe", // 이름
Gender.MALE, // 성별 (예시 Gender enum: MALE, FEMALE 등)
"johnny", // 닉네임
"1990-01-01" // 생년월일
);
// 유효성 검사
Set<ConstraintViolation<MemberSignUpRequestDTO>> violations = validator.validate(dto);
// 유효성 검사가 실패한 항목이 없으면 테스트 통과
assertTrue(violations.isEmpty(), "유효성 검사 실패: " + violations);
}
@Test
@DisplayName("회원가입, 잘못된 데이터 입력시 에러 메시지를 반환한다.")
void invalidDTOTest() {
// 잘못된 데이터로 DTO 생성
MemberSignUpRequestDTO dto = new MemberSignUpRequestDTO(
"invalid-email", // 잘못된 이메일
"short", // 너무 짧은 비밀번호
"invalid-phone", // 잘못된 전화번호 형식
null, // 비어 있는 이름
null, // 성별이 null (필수 입력값)
"a", // 너무 짧은 닉네임
"1990-13-01" // 잘못된 생년월일 형식 (13월)
);
// 유효성 검사
Set<ConstraintViolation<MemberSignUpRequestDTO>> violations = validator.validate(dto);
// 각 에러 메시지를 검증
for (ConstraintViolation<MemberSignUpRequestDTO> violation : violations) {
if ("email".equals(violation.getPropertyPath().toString())) {
assertEquals("이메일 형식이 올바르지 않습니다.", violation.getMessage());
}
if ("password".equals(violation.getPropertyPath().toString())) {
assertEquals("비밀번호는 8자 이상이어야 하며, 소문자, 대문자, 특수문자를 포함해야 합니다.", violation.getMessage());
}
if ("phone".equals(violation.getPropertyPath().toString())) {
assertEquals("전화번호 형식이 올바르지 않습니다.", violation.getMessage());
}
if ("name".equals(violation.getPropertyPath().toString())) {
assertEquals("이름은 필수 입력 값입니다.", violation.getMessage());
}
if ("gender".equals(violation.getPropertyPath().toString())) {
assertEquals("성별은 필수 입력 값입니다.", violation.getMessage());
}
if ("nickname".equals(violation.getPropertyPath().toString())) {
assertEquals("닉네임은 2자 이상이어야 합니다.", violation.getMessage());
}
if ("birthday".equals(violation.getPropertyPath().toString())) {
assertEquals("생년월일은 YYYY-MM-DD 형식이어야 합니다.", violation.getMessage());
}
}
}
}
DTO (Data Transfer Object) 테스트는 일반적으로 스프링 부트 컨텍스트나 Bean이 필요 하지 않다. DTO는 단순히 데이터를 전달하기 위한 객체로, 비즈니스 로직이나 데이터베이스와의 상호작용이 없기 때문에, 해당 객체만 테스트하면 된다.
MemberSignUpRequestDTO의 직렬화/역직렬화(JSON 변환), 유효성 검사(Validation) 테스트만 수행하였다. 이미 Domain, Repository, Service 테스트가 검증되었기 때문이다.
우리는 DTO의 역할만 검증하면 된다.
JSON 변환(직렬화) 테스트
- ObjectMapper.writeValueAsString(dto)를 사용해 DTO를 JSON 문자열로 변환(직렬화)하여 변환된 JSON이 예상 값과 동일한지 검증하였다.
JSON을 DTO로 변환(역직렬화) 테스트
- ObjectMapper.readValue(json, DTO.class)를 사용해 JSON을 DTO로 변환하여 변환된 DTO가 올바른 값을 가지는지 검증하였다.
올바른 데이터의 경우 유효성 검사 테스트
- validator.validate(dto)를 사용해 DTO의 유효성을 검사한다. 에러가 없어야 정상적인 DTO 객체임을 검증하였다.
잘못된 데이터 입력 시, 올바른 에러 메시지가 반환되는지 테스트
- validator.validate(dto)를 사용해 유효성 검사를 수행한다. 특정 필드(email, password, phone 등)가 올바르지 않은 경우, 적절한 에러 메시지가 나오는지 검증하였다.
Controller 테스트
@WebMvcTest(MemberSignupController.class) // 필요한 컨트롤러만 로드하여 테스트한다.
@Import(TestConfig.class)
class MemberSignupControllerTest {
//MockBean은 3.4.0부터 deprecated 되었다.
//MockitoBean을 사용하자
//@MockitoBean ApplicationContext의 빈을 모킹한다.
@MockitoBean
private ValidationService validationService; // Mock으로 생성하여 독립 테스트 가능
@MockitoBean
private MemberSignupService memberSignupService; // 실제 로직 실행 없이 가짜 객체로 대체
@Autowired
private MockMvc mockMvc; // 내장 서버를 실행하지 않고 컨트롤러를 테스트 할 수 있는 도구;[-
@Autowired
private ObjectMapper objectMapper;
@Test
@DisplayName("유저 회원가입 요청 성공 테스트")
void signup() throws Exception{
// given
MemberSignUpRequestDTO memberSignUpRequestDTO = new MemberSignUpRequestDTO("email@naver.com",
"Password12@",
"010-0000-0000",
"name",
Gender.MALE,
"nickname1",
"1991-01-01");
// validationService가 항상 빈 에러 리스트를 반환하도록 Mock 설정
given(validationService.validate(any(BindingResult.class)))
.willReturn(Collections.emptyMap());
// when then
mockMvc.perform(post("/api/signup")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(memberSignUpRequestDTO)))
.andExpect(status().isCreated());
}
// 유효성 검사 실패 테스트
@Test
@DisplayName("유효성 검사 실패 시 400 상태 코드 반환")
void signupValidationFailTest() throws Exception {
// 유효성 검사를 실패한 경우를 가정하여 에러 메시지가 설정되는 것을 가정한다.
Map<String, String> errors = new HashMap<>();
errors.put("username", "Username is required");
errors.put("password", "Password must be at least 6 characters");
given(validationService.validate(any(BindingResult.class))).willReturn(errors);
// 회원가입 요청 DTO (잘못된 데이터)
MemberSignUpRequestDTO requestDTO = new MemberSignUpRequestDTO();
mockMvc.perform(post("/api/signup")
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(requestDTO)))
.andExpect(status().isBadRequest()) // 응답 상태 코드 400 확인
.andExpect(jsonPath("$.username").value("Username is required")) // username 필드 에러 메시지 검증
.andExpect(jsonPath("$.password").value("Password must be at least 6 characters")); // password 필드 에러 메시지 검증
}
}
@WebMvcTest
- WebMvcTest는 스프링 부트에서 컨트롤러를 테스트할 때 사용하는 어노테이션으로, 웹 레이어만 로드하여 테스트를 수행한다.
- Spring MVC 관련 빈들, DispatcherServlet, HandlerMapping, MessageConverters, Validaator, Jackson 등의 빈들이 로드 된다.
- 또한 HTTP 요청과 응답을 처리하는 데 필요한 @ControllerAdvice 또는 @ExceptionHanlder로 설정된 예외 처리 기능이 활성화 된다.
- Service, Respository, Database와 관련된 빈들은 로드하지 않는다. 실제 DB나 비즈니스 로직을 처리하는 서비스 로직은 따로 @MockBean을 선언하여 모킹하여야 한다.
- Spring Boot 3.4.0 이상 버전부터는 MockBean은 3.4.0부터 deprecated 되어 MockitoBean을 사용하자.
@Import(TestConfig.class)
TestConfig
@TestConfiguration
public class TestConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/api/**").permitAll()
.anyRequest().authenticated());
http
.csrf(AbstractHttpConfigurer::disable);
return http.build();
}
}
@TestConfiguration은 테스트 환경에서만 사용할 수 있는 빈을 정의할 수 있다. 즉, 실제 애플리케이션의 설정에 영향을 주지 않고 테스트에 필요한 빈만 로드할 수 있다는 장점이 있다.
TestConfig를 import한 이유는, @WebMvcTest가 외부에서 테스트하는 환경처럼 동작하기 때문에 POST, PUT, DELETE와 같은 상태 변경 요청에 대해 CSRF 토큰을 요구한다. 따라서 테스트 환경에서 CSRF 보호를 비활성화 시키기 위해 TestConfig를 사용하여 CSRF를 차단하였다.
MockMvc
MockMvc는 스프링 테스트에서 HTTP 요청을 모킹하여 테스트하는데 사용된다. 실제 서버를 실행하지 않고 컨트롤러 동작을 검증 할 수 있다.
HTTP 메서드인 GET, POST, PUT, DELETE와 요청 파라미터, 헤더, 본문 등 설정가능하고 검증할 수 있다.
ObjectMapper
ObjectMapper는 JSON → DTO(직렬화), DTO → JSON(역직렬화) 하는데 사용된다.
이 컨트롤러 테스트에서 사용자는 JSON 데이터를 넘겨주기 때문에 역직렬화를 하는데 목적으로 ObjectMapper를 사용하였다.
- 유효성 검사 실패 시 400 상태 코드 반환 테스트:
- 회원가입 요청 시 유효성 검사가 실패하면, 서버가 **400 상태 코드 (Bad Request)**를 반환하는지 확인하였다.
- 유효성 검사 실패 시 에러 메시지 반환 테스트:
- 회원가입 요청 시 유효성 검사가 실패하면, 적절한 에러 메시지가 포함된 JSON 응답이 반환되는지 확인하였다.
다음 글은 통합테스트를 진행하여 포스팅하고자 한다.
'Spring' 카테고리의 다른 글
Spring Boot - Querydsl (0) | 2025.01.15 |
---|---|
Spring Boot - ResponseEntity 클래스 (0) | 2025.01.11 |
Spring Boot - Lombok (0) | 2024.04.03 |
Spirng Boot - REST API로 CRUD 만들기 (0) | 2024.04.03 |
Spring Security 살펴보기 (0) | 2024.02.22 |