김영한님의 Spring 강의와 Java/Spring 테스트를 추가하고 싶은 개발자들의 오답노트 강의를 들으면서 실제 프로젝트에 적용할 때 정리해두면 좋을 내용들을 정리해봤다.
단위 테스트는 코드의 품질을 보장하고 리팩토링의 안전망 역할을 하는 중요한 요소라고 할 수 있다.
단위 테스트(Unit Test)란 하나의 '단위'에 해당하는 클래스나 메서드를 외부 의존성 없이 독립적으로 테스트하는 것을 말한다. 일반적으로는 도메인 로직, 유틸성 메서드 등을 테스트하며, 외부 시스템(DB, 네트워크 등)은 포함하지 않는다.
단위 테스트 특징:
| 항목 | 단위 테스트 | 통합 테스트 |
|---|---|---|
| 테스트 범위 | 하나의 클래스/메서드 | 여러 컴포넌트 간의 상호작용 |
| 실행 속도 | 매우 빠름 | 상대적으로 느림 |
| 외부 의존성 | 없음 (또는 Mock 사용) | DB, 네트워크 등 실제 시스템 포함 |
| 테스트 목적 | 개별 로직 검증 | 시스템 간 연결 및 전체 흐름 검증 |
단위 테스트는 작은 단위를 신뢰할 수 있게 만드는 작업이고, 통합 테스트는 그 단위들이 잘 연결되는지를 검증하는 작업이다.
단위 테스트를 시행할 때, 가장 널리 사용되는 패턴이 Given-When-Then 구조다.
@Test
@DisplayName("정상적인 이메일을 넣으면 Email 생성")
void validEmailTest() {
// given - 데이터 설정
String emailText = "hyeonseob22@gmail.com";
// when - 실행
Email email = Email.from(emailText);
// then - 결과 검증
assertThat(email.getValue()).isEqualTo("hyeonseob22@gmail.com");
}
단위 테스트는 위처럼 단일 스레드 기반에서 실행되며, 테스트 대상의 순수 로직만 집중해서 검증한다. 외부 의존성이 필요한 경우에는 Mock 등을 사용해 격리한다.
AssertJ는 JUnit의 기본 assertion보다 가독성이 뛰어나고 더 많은 기능을 제공하는 라이브러리다.
Spring Boot 프로젝트라면 spring-boot-starter-test에 AssertJ가 이미 포함되어 있다:
testImplementation 'org.springframework.boot:spring-boot-starter-test'
Spring Boot 없이 사용하는 경우:
testImplementation 'org.assertj:assertj-core:3.24.2'
import static org.assertj.core.api.Assertions.*;
JUnit 방식:
@Test
void userValidationTest() {
User user = new User("", 15);
assertFalse(user.getName().isEmpty()); // 실패 시: Expected: <false> but was: <true>
assertTrue(user.getAge() >= 18); // 실패 시: Expected: <true> but was: <false>
}
AssertJ 방식 (권장):
@Test
void userValidationTest() {
User user = new User("", 15);
assertThat(user.getName())
.isNotEmpty()
.hasSize(5);
assertThat(user.getAge())
.isGreaterThanOrEqualTo(18);
}
Expected: <true> but was: <false> → 무엇을 검증하려 했는지 불명확Expecting actual:<15> to be greater than or equal to:<18> → 구체적이고 명확함컬렉션 테스트에서 AssertJ의 진가가 발휘된다.
@Test
@DisplayName("사용자 목록 필터링 및 검증")
void userListFilteringTest() {
// given
List<User> users = Arrays.asList(
new User("alice@gmail.com", "Alice", 25, true),
new User("bob@gmail.com", "Bob", 30, false),
new User("charlie@gmail.com", "Charlie", 35, true)
);
// when
List<User> activeUsers = users.stream()
.filter(User::isActive)
.collect(toList());
// then
assertThat(activeUsers)
.hasSize(2)
.extracting(User::getName)
.containsOnly("Alice", "Charlie")
.doesNotContain("Bob");
assertThat(users)
.filteredOn(User::isActive)
.extracting("name", "age")
.containsExactly(
tuple("Alice", 25),
tuple("Charlie", 35)
);
}
hasSize(int) - 컬렉션 크기 검증isEmpty() / isNotEmpty() - 빈 컬렉션 여부contains(Object...) - 지정된 요소들 포함 여부 (순서 무관)containsOnly(Object...) - 정확히 지정된 요소들만 포함 (순서 무관)containsExactly(Object...) - 정확히 지정된 요소들만 순서대로 포함doesNotContain(Object...) - 지정된 요소들 미포함extracting(String...) - 객체의 특정 필드들 추출extracting(Function) - 함수를 통한 값 추출filteredOn(Condition) - 조건에 맞는 요소들로 필터링allMatch(Predicate) / anyMatch(Predicate) - 모든/일부 요소 조건 만족@Test
@DisplayName("User 생성")
void create_UserDomain() {
// given
String email = "test@gmail.com";
String nickname = "nickname";
String rawPassword = "Password123!";
// when
UserDomain userDomain = UserDomain.create(email, nickname, rawPassword);
// then
assertThat(userDomain).isNotNull();
assertThat(userDomain.getEmail().getValue()).isEqualTo("test@gmail.com");
assertThat(userDomain.getNickname()).isEqualTo("nickname");
assertThat(userDomain.isActive()).isFalse(); // 기본값 검증
}
예외 테스트는 단순히 예외가 발생하는지 확인하는 것을 넘어서 구체적인 예외 메시지까지 검증하는 것이 좋다.
간단한 타입 검증의 경우에는 isInstanceOf(Class) 까지만 사용하지만 구체적인 에러 메시지가 필요한 경우 hasMessage()등을 사용해서 체크한다.
@Test
@DisplayName("이메일이 100자가 넘어갈 경우 예외가 발생")
void emailOver100Test() {
// given
String longEmailTest = "h".repeat(101) + "@gmail.com";
// when & then
assertThatThrownBy(() -> Email.from(longEmailTest))
.isInstanceOf(InvalidEmailFormatException.class) // 간단한 예외 처리일 경우 여기까지만 해도 됨
.hasMessageContaining("100자")
.hasMessageContaining("초과");
}
@Test
@DisplayName("잘못된 이메일 형식으로 생성 시 정확한 예외 메시지")
void invalidEmailFormatTest() {
// given
String invalidEmail = "invalid-email";
// when & then
assertThatThrownBy(() -> Email.from(invalidEmail))
.isInstanceOf(InvalidEmailFormatException.class)
.hasMessage("올바른 이메일 형식이 아닙니다: " + invalidEmail);
}
@Test
@DisplayName("비밀번호가 8자 미만일 때 예외")
void passwordTooShortTest() {
// given
String shortPassword = "123";
// when & then
assertThatThrownBy(() -> Password.from(shortPassword))
.isInstanceOf(InvalidPasswordException.class)
.hasMessageStartingWith("비밀번호는")
.hasMessageEndingWith("이상이어야 합니다");
}
hasMessage(String) - 정확한 메시지 일치hasMessageContaining(String) - 메시지에 특정 문자열 포함hasMessageStartingWith(String) - 메시지 시작 부분hasMessageEndingWith(String) - 메시지 끝 부분hasMessageMatching(String regex) - 정규표현식 패턴hasNoCause() - 원인 예외가 없음을 검증hasCauseInstanceOf(Class) - 특정 타입의 원인 예외 검증@Test
@DisplayName("같은 이메일 값이라면 equals/hashCode 결과 동일")
void equalsAndHashCodeTest() {
// given
String emailValue = "test@gmail.com";
// when
Email email1 = Email.from(emailValue);
Email email2 = Email.from(emailValue);
// then
assertThat(email1).isEqualTo(email2);
assertThat(email1.hashCode()).isEqualTo(email2.hashCode());
}
동등성과 해시코드는 VO에서 매우 중요하다. 특히 Set, Map 같은 자료구조를 사용할 때 반드시 필요한 검증이다.
단위 테스트는 코드의 품질을 보장하고 안전한 리팩토링을 가능하게 하는 필수 요소다.
핵심 내용:
extracting(), filteredOn(), containsOnly() 등으로 복잡한 컬렉션도 간단히 검증이러한 원칙들을 지키면서 테스트를 작성하면, 유지보수하기 쉽고 신뢰할 수 있는 코드를 만들 수 있다.