참고 :
요즘 Java 단위테스트 작성에는 크게 2가지 라이브러리가 사용된다.
JUnit 만으로도 단위 테스트를 충분히 작성할 수 있다. 하지만 JUnit에서 제공하는 assertEquals()와 같은 메소드는 AssertJ가 주는 메소드에 비해 가독성이 떨어진다. 그렇기 때문에 순수 Java 애플리케이션에서 단위 테스트를 위해 JUnit5와 AssertJ 조합이 많이 사용된다.
이번 게시글(겸 챌린지반 과제)에서는 junit을 사용해서 테스트 코드를 작성하고자 한다.
// build.gradle
// JUnit 5
testImplementation("org.springframework.boot:spring-boot-starter-test")
// AssertJ
testImplementation 'org.assertj:assertj-core:3.25.3'
test {
useJUnitPlatform()
}
💡 참고
현재는
Spring Boot 3.xx기반의 버전을 사용하지만,
실무에서는2.xx버전을 사용하는Legacy Code가 남아있을 수 있다.
2.xx버전에서는 위의 설정과는 다른 부분이 있으니 💡참고💡

1️⃣ Fast
2️⃣ Independent
3️⃣ Repeatable
4️⃣ Self-Validating
5️⃣ Timely
좋은 단위 테스트는 실행이 빨라야한다.

단위 테스트는 내부 코드만 테스트할 때와 외부 자원을 다룰 경우의 실행 시간 차이가 크다.
모든 외부 자원을 다루어 한 테스트가 200ms를 소모할 때 2500개의 테스트를 수행한다면 8분이 걸리게 된다.
단위 테스트는 대상 시스템에 대해 지속적이고 빠르게 피드백을 주는데 가치가 있기 때문에 빨라야 한다.
단위 테스트의 실행이 오래 걸린다면 테스트 작성의 의미가 퇴색되는 것이므로 우리는 느린 테스트 코드를 지양해야 한다.
좋은 단위 테스트는 테스트하고자 하는 단위 기능에 집중해야 한다.
즉, 독립적이어야함 (진짜 DB를 갔다오는것도아니고, 진짜 API를 쓰는것도아님!!)

단위 테스트를 작성할 때 테스트하고자 하는 단위 기능을 명확하게 하지 않는다면 그 테스트는 하나 이상의 기능을 테스트 할 것이다.
단위 테스트가 통합 테스트보다 장점을 갖는 것은 하나의 테스트 당 하나의 기능만을 테스트하기 때문이다.
좋은 단위 테스트는 반복적으로 수행하더라도 항상 같은 결과를 반환해야 한다.

그렇기 위해서는 결과가 어떻게 나올지 명확해야 하며 통제할 수 있어야 한다.
테스트 대상 코드의 나머지를 격리하고 Mock객체를 활용하는 방안을 사용해 이를 해결할 수 있다.
좋은 단위 테스트는 기대하는 결과가 무엇인지 단언(assert)해야 한다.

테스트 결과를 검증할 때 System.out.println이나 log.info등을 이용해 직접 비교하며 검증할 수도 있다.
하지만 이렇게 되면 많은 비용을 지불하게 된다.
그렇기 때문에 JUnit에서 제공하는 assert와 같은 검증 코드를 이용해 검증하도록 한다.
좋은 단위 테스트는 미루지 않고 즉시 작성한다.

단위 테스트는 소프트웨어 개발의 완성도, 품질을 높이는 좋은 습관이다.
만약 테스트를 제때 작성하지 않고 미루어 작성하지 않는다면 코드에 결함이 발생할 확률이 높아진다.
이런 점에서 TDD와 같은 프로세스가 등장하게 되었다.
| 어노테이션 | 쓸 수 있는 상황 | 설명 |
|---|---|---|
| @Test | 모든 테스트 메서드에 사용 | 테스트임을 나타냄 |
| @BeforeEach | 각 테스트 실행 전에 초기화 코드 작성할 때 | 예: 공통 Mock 객체 초기화, 데이터 준비 |
| @AfterEach | 각 테스트 후 정리 코드 작성할 때 | 예: 자원 정리, 로그 확인 등 |
| @DisplayName | 테스트 메서드 설명 작성 시 | 테스트 이름을 보기 좋게 커스터마이징 |
| @Disabled | 일시적으로 테스트 제외할 때 | 테스트 실행하지 않음 (개발 중 임시 스킵) |
| @ExtendWith(MockitoExtension.class) | ✅ Mockito 기반 테스트 시 필수 | @Mock, @InjectMocks 등 사용 가능하게 함 |
💡 참고 : JUnit 4 vs JUnit 5
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
@Test
void testAdd() {
//given
Calculator cal = new Calculator();
//when
int result = cal.add(2, 3);
//then
assertEquals(5, result); // 예상값 5와 실제값 비교
}
}
assertEquals(expected, actual) : 예상값과 실제값이 같아야 통과
Assertions : 값 비교, null 여부 확인 등
|메서드 |설명|
|---|---|
|assertEquals(a, b) |a와 b가 같은지 검사|
|assertTrue(expr)|expr이 true인지 검사|
|assertNotNull(obj)| obj가 null이 아님을 검사|
|assertThrows(예외.class, () -> {...}) |예외 발생 테스트|
기본적으로 assert 함수류들은 한눈에 보기 편해서 찾기도 쉬운거같은 느낌이다...
결과가 기대하는 값과 일치하는지 확인한다.
assertThat(result.getUsername()).isEqualTo(username);
result.getUsername() 랑 username랑 같은가~~
익셉션이 발생하는 케이스를 테스트할 때 사용한다.
assertThatThrownBy(() -> someMethod()).isInstanceOf(SomeException.class).hasMessage("오류 메시지");
🔍 isInstanceOf(Class<.?>)
.isInstanceOf(StringIndexOutOfBoundsException.class)앞에서 나온 예외가 괄호안의 예외랑 같은지 우선 비교함.
나는 지금 커스텀으로 예외 다 만들어줘서 그거 집어넣었다.
사실 사진보면 로그인했을때 복사해온거 그대로와서 지금 확인하고 급하게 고침 ㄷㄷ;;
생각해보니 암호화를 위한 클래스를 따로 떼놓지 않아서
가진 클래스들이 모두 다른 클래스들과 연동되어있다.
그러면 그 클래스 안의 함수들의 리턴값들도 다 알아야하고, 테스트를 위해서 값도 넣어줘야하는데 이부분이 골치다.
@Mock: 테스트할 클래스의 의존성(예: Repository)을 가짜로 만듦
UserController에 대한 테스트를 한다고 치면
UserService는 테스트 대상이 아니기때문에 mocking이 필요하다....
@InjectMocks: 실제 테스트할 클래스에 @Mock으로 만든 의존성을 주입
<의존성 추가하기>
// Mockito (가짜 객체 만들기)
testImplementation 'org.mockito:mockito-core:5.11.0'
testImplementation 'org.mockito:mockito-junit-jupiter:5.11.0'
JUnit은 설정된 테스트 단위로 테스트 객체를 만듭니다. 이를 테스트 인스턴스라고 말한다.
테스트 실행 범위라고 생각하면 된다.
JUnit 5는 테스트 인스턴스 생성 기본 단위가 메소드이다.
각 메소드 별로 따로 인스턴스가 생성되어 테스트를 한다는 뜻입니다.
메소드 단위로 인스턴스가 만들어지면 테스트 간 영향이 없어 단위 테스트하기에 좋다.
메소드 끼리 영향을 주는 테스트 케이스를 테스트하려면 어떻게 해야 할까요? 그때 사용하는 게 @TestIntance입니다.
테스트 인스턴스의 생성 단위를 변경하기 위해 사용하는 어노테이션
설명
테스트 인스턴스 생성 단위 설정
클래스에서 우클릭 + Generate


근데 회원가입을 성공 / 실패 하는 것으로 나눠서 짜야하니깐 사실 저 함수는 쓸모가없어졌다..
나같은경우는
가짜 객체인 UserRepository userRepository;가 필요하고,
UserServiceImpl 객체를 생성하면서, 위에서 선언한 userRepository mock을 주입해야한다.
그럼
@Mock
UserRepository userRepository;
@InjectMocks
UserServiceImpl userService;
이것들이 제일 먼저 필요함!!!
저런 @Mock, @InjectMocks 같은 어노테이션을 사용하려면 ㅌ클래스에서 위에서 말한
@ExtendWith(MockitoExtension.class) ㄴ어노테이션이 필요하다
그럼 클래스부분 선언은
@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {
@Mock
UserRepository userRepository;
@InjectMocks
UserServiceImpl userService;
이렇게된다..
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService{
private final ScheduleRepository scheduleRepository;
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
private final UserRepository userRepository;
/**
* 회원 가입 메서드입니다.
* 사용자의 정보를 받아 새로운 계정을 생성하고 저장한 후, 응답 DTO로 변환하여 반환합니다.
* @param username 가입할 사용자의 아이디
* @param password 가입할 사용자의 비밀번호
* @param email 가입할 사용자의 이메일
* @return 생성된 사용자의 정보를 담은 UserResponseDto 객체
*/
@Override
public UserResponseDto signUp(String username, String password, String email) {
Optional<User> loginUser = userRepository.findByEmail(email);
if(loginUser.isPresent()){
throw new UnauthorizedActionException("회원가입이 불가능한 이메일입니다.");
}
//비밀번호 암호화
String encodedPassword = encoder.encode(password);
User user = new User(username,encodedPassword,email);
User savedUser = userRepository.save(user);
return new UserResponseDto(savedUser.getId(), savedUser.getUsername(), savedUser.getEmail(),
savedUser.getCreateDate(), savedUser.getUpdateDate());
}
이거 테스트 작성떄문에 Setter 적용해줫다ㅣ.
/**
* JPA 사용을 위해 엔티티 등록을 해줍니다.
* 사용하는 테이블 이름은 USER입니다.
*/
@Getter
@Entity
@Setter
@Table(name = "user")
public class User extends DateEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String email;
public User(String username, String password, String email){
this.username = username;
this.password = password;
this.email = email;
}
public User() {
}
}
아 참고로 공통적으로 들어가는 이름, 이메일 내용으로
String username;
String password;
@BeforeEach
void setUp() {
username = "테스트이름";
password = "password123";
}
이걸 클래스안에서 적용했다.
<전체 코드>
void signUpSuccess() {
// given
String email = "test1@email.com";
given(userRepository.findByEmail(email)).willReturn(Optional.empty());
User user = new User(username, password, email);
user.setId(1L); // 테스트용 ID 지정
given(userRepository.save(any(User.class))).willReturn(user);
// when
UserResponseDto result = userService.signUp(username, password, email);
// then
assertThat(result.getUsername()).isEqualTo(username);
assertThat(result.getEmail()).isEqualTo(email);
}
설명 (내가 몰랐던거 위주 ㅇㅇ)
머리깨지는줄
//givengiven(userRepository.findByEmail(email)).willReturn(Optional.empty());
userRepository.findByEmail(email)이 함수를 사용하게되면
-> whillReturn 안의 내용이 반환될거야! (Optional.empty()) << 반환됨
성공적인 회원가입이기 때문에 텅텅비는것이 정상적임.
User user = new User(username, password, email); user.setId(1L); // 테스트용 ID 지정저장될 유저 객체 생성하는데, DB가 자동으로 해주는 게 아니라서 테스트에서 사용하려면 ID도 직접 지정해야 함
given(userRepository.save(any(User.class))).willReturn(user);any(User.class): 어떤 User 객체든 상관없이 매치하는 Mockito의 헬퍼
save()가 호출되면 위에서 만든 user를 반환하도록(실제로 성공하면 유저 반환하니깐) 설정
<전체 코드>
@Test
@DisplayName("회원가입 실패 - 이메일 중복")
void signUpDuplicateEmailFail() {
// given
String email = "test1@email.com";
User existingUser = new User(username, password, email);
given(userRepository.findByEmail(email)).willReturn(Optional.of(existingUser));
// when & then
assertThatThrownBy(() -> userService.signUp(username, password, email))
.isInstanceOf(UnauthorizedActionException.class)
.hasMessage("404 NOT_FOUND \"회원가입이 불가능한 이메일입니다.\"");
}
설명 (내가 몰랐던거 위주)
//given
given(userRepository.findByEmail(email)).willReturn(Optional.of(existingUser));
userRepository.findByEmail(email)이 함수를 사용하게되면
-> whillReturn 안의 내용이 반환될거야! (existingUser) << 반환됨 유저가 존재한다는 뜻
//when & thenassertThatThrownBy(() -> userService.signUp(username, password, email)) .isInstanceOf(ResourceNotFoundException.class) .hasMessage("404 NOT_FOUND \"회원가입이 불가능한 이메일입니다.\"");
signUp()을 호출하면UnauthorizedActionException이 발생해야 함 <<<<<<<<<<<<
예외 타입과 메시지를 둘 다 검증함 (AssertJ의 assertThatThrownBy)

잘보면 나와야하는 메시지에 " 가 한번씩 더 들어가잇어서 일치하지안흔ㄴㄷ내다 ㅡ,.ㅡ,.ㅡ,.ㅡ

어케 메시지 다시 붙이지 고민하다가 그냥 정규식으로 " 넣어줬다.