프로그램을 개발하고 서비스를 제공함에 있어 테스트 코드는 단순히 버그를 잡는 것을 넘어서 소프트웨어의 전반적인 품질을 보장하는 데 필수적인 도구이다.
지금부터 테스트 코드 어떻게 작성해야 하는지 알아보자.
Given : 주어진 전제 조건을 정의하고 테스트 실행을 위한 준비
"어떤 배경이나 상태가 주어졌을 때"
When : 테스트하려는 메서드나 기능을 실행하는 과정
"어떤 행동(메서드)를 실행하면"
Then : 메서드나 기능이 실행된 후 예상되는 결과가 나오는지 확인
"예상한 결과와 실제 결과가 일치하는지 확인"
[예시]
@Test
void userLogin_successful() {
// given
User user = new User("john@example.com", "password123");
userService.saveUser(user);
// when
boolean result = userService.login("john@example.com", "password123");
// then
assertTrue(result, "로그인 성공해야 함");
}
JUnit5란 자바에서 사용되는 최신 단위 테스트 프레임워크로 JUnit Platform, JUnit Jupiter,JUnit Vintage가 포함된다.
org.junit.jupiter 패키지를 포함하며, @Test
, @BeforeEach
, @AfterEach
, @BeforeAll
, @AfterAll
등의 어노테이션을 제공한다.
@Test
: 테스트 메서드를 표시한다.
@BeforeEach
: 각 테스트 메서드 실행 전에 수행될 작업을 정의한다.
@AfterEach
: 각 테스트 메서드 실행 후에 수행될 작업을 정의한다.
@BeforeAll
: 모든 테스트 메서드가 실행되기 전에 한 번만 수행될 작업을 정의한다.
@AfterAll
: 모든 테스트 메서드가 실행된 후에 한 번만 수행될 작업을 정의한다.
@Disabled
: 특정 테스트 메서드나 클래스의 실행을 비활성화한다.
@ParameterizedTest
: 같은 테스트 메서드를 여러 가지 입력 데이터로 반복 실행할 수 있다.
@ExtendWith
: JUnit Jupiter의 확장 기능을 사용하여 테스트를 확장하거나 테스트 환경을 설정할 수 있다.
@Nested
: 테스트 클래스 내에서 중첩된 클래스를 정의하여 테스트 계층 구조를 구성할 수 있습니다.
Mockito
는 자바용 모킹 프레임워크로, 테스트 코드에서 객체의 동작을 모방(mock)하여 테스트의 정확성을 높이고, 외부 의존성으로부터 독립적인 단위 테스트를 작성할 수 있도록 도와준다. Mockito를 사용하면 테스트하려는 객체의 동작을 설정하고, 그 객체가 다른 객체와 상호작용하는 방식을 검증할 수 있다.
※ Mock = 가짜
Mockito 특징
Mockito는 Mock 객체를 쉽게 만들고, 관리하고, 검증할 수 있는 방법을 제공하는 프레임워크이다.
통합테스트인 @SpringBootTest 와 다르게 별도 환경, 구성에 대한 제약 사항이 없기 때문에 매우 빠르게 동작한다.
**단위 테스트(Unit Test) : 단위 테스트는 개별 구성 요소(단위, 유닛)를 독립적으로 검증하는 테스트이다.
여기서 '단위'는 보통 메서드(함수), 클래스 등의 가장 작은 테스트 가능한 코드를 의미한다. 유닛 테스트의 주요 목적은 각 코드가 의도한 대로 작동하는지 확인하는 것이다.
단위 테스트에서는 실제 객체를 사용하지 않고 Mocking을 사용함으로써 의존성이 적고 빠르게 테스트를 할 수 있다.
FIRST 원칙
Fast
: 유닛 테스트는 빨라야 함
Isolated
: 테스트는 각 테스트간에 독립적으로 실행해야함
Repeatable
: 테스트는 환경에 상관없이 실행할 때마다 같은 결과를 만들어야 함.
Self-validating
: 테스트는 명확히 성공/실패로 구분하여 테스트 자체가 결과를 검증할 수 있어야함.
Timely
: 테스트는 개발간에 즉시 작성해야 함. 대표적으로 TDD 방법론이 있음. (쉽지 않음)
이를 통해 테스트하려는 코드가 의존하는 객체나 외부 리소스를 실제로 사용하지 않고도 해당 객체의 동작을 흉내 내거나 제어할 수 있다.
[Mock을 사용한 단위 테스트 예시]
@ExtendWith(MockitoExtension.class)
public class MovieServiceTest {
@Mock // 모의 객체
MovieRepository movieRepository;
@InjectMocks // 모의 객체를 주입받을 클래스
MovieService movieService;
@Test
@DisplayName("영화 단건조회 테스트")
public void getMovieTest() {
// given
int movieId = 1;
Movie movie = new Movie(
new Director("ahn"),
List.of(new Actor("park"), new Actor("kim"))
);
given(movieRepository.findById(anyLong())).willReturn(Optional.of(movie));
// when
MovieResponse result = movieService.getMovie(movieId);
// then
assertNotNull(result);
}
}
@ExtendWith(MockitoExtension.class)
: Mocking할 클래스 위에 붙이면 Mocking 기능을 제공해준다.
@Mock
: 테스트에서 사용될 실제 객체를 흉내내는 가짜 객체로 해당 객체가 가지고 있는 메서드를 모방 하여 사용하기 위한 용도이다.
위 예시에서 @Mock을 사용하여 movieRepository를 모의 객체로 만들어서, 실제 데이터베이스 접근 없이 movieRepository의 findById 메서드의 동작을 가정하고 테스트를 진행할 수 있다.
@InjectMocks
: @Mock으로 만들어진 인스턴스들을 해당 클래스에 자동으로 주입해준다.
Mock 객체를 주입하여 실제 객체의 의존성을 자동으로 설정해준다. 즉, Mock이 아닌 실제 객체를 의미한다.
따라서 @InjectMocks 대상 객체에는 mock에 넣어주는 any() 가 아닌 실제 자원을 넣어줘야 한다.
(any는 mock에만 사용 가능하다.)
@Test
public void 영화_단건조회() {
// given
long movieId = 1L;
given(movieRepository.findById(anyLong())).willReturn(Optional.of(movie));
...
}
any~
: given에서 자원을 주어줄 때, mock에 어떤 값이든(any) 할당하는 값
any(), anyLong(), anyString(), anyList(), anySet(), anyMap() 등이 존재한다.
given(..Mock의 메서드 호출..)
: Mockito에서 특정 메서드가 호출되었을 때를 의미하며, 주로 그 메서드의 반환값을 설정하는 데 사용된다.
BDDMockito의 스타일로 주로 사용되며, "주어진 조건에서"를 의미한다.
willReturn(Optional.of(객체 이름))
: given에서의 메서드가 호출되었을 때 반환할 값을 설정한다.
여기서는 null이 나오지 않도록 Optional.of(movie)를 반환하도록 설정하였다.
null값이 나오게 하고 싶으면 OPtional의 empty() 메서드를 사용하면 된다.
@Test
@DisplayName("영화 단건조회 테스트")
public void getMovieTest() {
...
// when
MovieResponse result = movieService.getMovie(movieId);
...
}
@InjectMocks 대상 객체는 mock 객체가 아니라 실제 객체이기 때문에 @InjectMocks 대상은 테스트하려는 실제 객체를 생성하고, 그 객체의 의존성들만 mocking된 객체로 대체된다.
따라서 @InjectMocks 대상 객체에는 mock에 넣어주는 any() 가 아닌 실제 자원을 넣어줘야 한다는 점을 꼭 기억해야 한다.
(any는 mock에만 사용 가능)
@Test
@DisplayName("영화 삭제 실패")
public void getMovieTest() {
...
// then
assertEquals("entity null error", exception.getMessage());
verify(movieRepository, times(0)).delete(any(Movie.class));
}
assert
: 테스트의 결과를 검증하는 데 사용하며, 테스트 예상한 결과와 실제 결과와 비교하여 테스트성공/실패를 결정한다.
verify
: 특정 메소드가 호출되었는지, 호출 횟수는 몇 번인지, 호출 순서는 어떤지 등의 부가적인 요소를 검증하는 데 사용된다.
@Test
public void 영화단건_조회() {
// given
long movieId = 1L;
given(movieRepository.findById(anyLong())).willReturn(Optional.empty());
// when
NullPointerException exception = assertThrows(NullPointerException.class, () -> movieService.getMovie(movieId));
// then
assertEquals("null error", exception.getMessage());
}
assertThrows
: 예외 처리로 인한 에러가 제대로 의도한대로 발생하는지 확인한다.
assertThrows(발생 에러 클래스 이름.class, () -> 호출한 실제 메서드)
형식이다.
assertEquals("정의한 에러 메시지", 정의한 에러 객체 이름.getMessage())
로 각 상황에 정의한 에러 메시지가 제대로 발생하는지 확인할 수 있다.
@Test
public void 영화_삭제() {
// given
long movieId = 1L;
Movie movie = new Movie("재밌는영화", 2002);
given(movieRepository.findById(anyLong())).willReturn(Optional.of(movie));
doNothing().when(movieRepository).delete(any(Movie.class));
// when
movieService.removeMovie(movieId);
// then
verify(movieRepository, times(1)).delete(any(Movie.class));
}
doNothing()
: Mockito에서 특정 메서드가 호출되었을 때, 아무 동작도 하지 않도록 설정하는 메서드이다.
예를 들어, 보통 메서드를 호출하면 무언가 실행되지만, doNothing()은 그 메서드를 호출했을 때 아무런 행동이 일어나지 않도록 설정한다.
이 기능은 주로 반환형이 없는 void 메서드를 테스트할 때 유용하다. 특히 삭제, 업데이트 또는 외부 시스템 호출처럼 상태 변화나 부작용이 있을 수 있는 메서드들을 테스트할 때, 실제로 수행되지 않도록 방지할 수 있다.
void는 반환형이 없기 때문에 반환값을 확인하기 힘들어 verify와 함께 핵심 자원이 호출 되었는지 정도 확인해주면 충분하다.
when(Mock 객체).메서드(객체)
: 테스트에서 모의(Mock)된 객체의 메서드를 호출할 때, 해당 메서드의 동작을 지정한다.
위의 예시에서는 movieRepository.delete() 메서드가 호출될 때의 동작을 지정한다.
@Spy
: Mockito에서 사용하는 어노테이션으로, 객체의 일부 메서드는 실제로 실행하면서 나머지는 모의(mock)할 수 있는 기능을 제공한다.
즉, 부분적으로 객체를 모의할 수 있는 방법이다.
(@Spy를 지정하면 실제 해당 객체가 들어온다. 하지만 필요에 따라 given()처럼 특정 메서드 호출 시 모의 동작을 지정할 수 있다.)
[Spy 사용 예시 1]
@ExtendWith(MockitoExtension.class)
public class MovieServiceTest {
@Spy
private MovieRepository movieRepository = new MovieRepository(); // 실제 객체로 생성됨
@InjectMocks
private MovieService movieService;
@Test
public void testSpyExample() {
// given: 실제 객체의 findById() 메서드를 모의(mock) 설정
Movie mockMovie = new Movie("Spy Movie");
doReturn(Optional.of(mockMovie)).when(movieRepository).findById(1L);
// when: 실제 delete() 메서드가 호출됨
movieService.deleteMovie(1L);
// then: findById()는 모의 동작에 따라 실행되고, delete()는 실제 동작함
verify(movieRepository).delete(mockMovie);
}
}
[Spy 사용 예시 2]
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
@Mock
private UserRepository userRepository;
@Spy
private PasswordEncoder passwordEncoder;
@InjectMocks
private UserService userService;
@Test
public void changePassword_비밀번호_변경_검증() {
// given
long userId = 1L;
User user = new User("test@a.com" , passwordEncoder.encode("Asd12345") , UserRole.ADMIN);
UserChangePasswordRequest userChangePasswordRequest = new UserChangePasswordRequest("Asd12345" , "Asd12345");
given(userRepository.findById(anyLong())).willReturn(Optional.of(user));
// when
InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> userService.changePassword(userId , userChangePasswordRequest));
// then
assertEquals("새 비밀번호는 기존 비밀번호와 같을 수 없습니다." , exception.getMessage());
}
}
위의 예시는 유저가 비밀번호를 변경할 때의 코드를 검증하는 spy를 사용한 테스트 코드이다.
유저의 비밀번호를 검증하기 위해서는 입력한 비밀번호를 인코딩해주는 PasswordEncoder 클래스의 encode() 메서드를 호출하여 사용하여야 하는데 만약 단순히 @Mock를 이용하여 Mock 객체로 PasswordEncoder를 주입한다면 encode() 메서드가 작동하지 않아 테스트 검증이 불가능하다.
따라서 PasswordEncoder는 @Spy를 통해 spy로 지정하면 PasswordEncoder의 메서드 중 하나인 encode() 메서드는 실제 메서드의 기능을 수행하도록 할 수 있다.
@Mock: 객체의 모든 메서드가 모의되며, 기본적으로 아무런 동작을 하지 않다. 즉, 객체의 내부 로직이 실행되지 않고, 원하는 동작을 명시적으로 설정해야 할 때 사용한다.
@Spy: 실제 객체의 일부 메서드는 그대로 실행하면서, 특정 메서드는 모의할 수 있다. 즉, 객체의 내부 로직이 실행되는 부분적인 모의가 가능하다.
@Spy는 실제 동작이 필요한 메서드가 있는 경우에 유용하다.
예를 들어, 일부 메서드만 Mock으로 사용하고, 나머지는 실제로 실행해야 하는 경우에 사용하면 좋다. 이 방식은 복잡한 로직을 가진 객체를 테스트하면서, 특정 부분만 모의하고 나머지 로직은 그대로 검증하고자 할 때 적합하다.
@Nested는 JUnit 5에서 제공하는 애너테이션으로, 테스트 클래스 내에 또 다른 테스트 클래스를 중첩하여 테스트 계층 구조를 만들 때 사용한다. 이를 통해 더 나은 가독성, 구조화된 테스트 코드, 그리고 테스트 그룹화를 얻을 수 있다.
import org.junit.jupiter.api.Nested;
@ExtendWith(MockitoExtension.class)
public class MovieServiceTest {
...
@Nested
class GetMovieTest {
@Test
public void 영화단건조회_조회결과없음() {
...
}
@Test
public void 영화단건조회_정상조회() {
...
}
}
@Nested
class DeleteMovieTest {
@Test
public void 영화삭제_조회결과없음() {
...
}
@Test
public void 영화삭제_정상동작() {
...
}
}
}
Nested 특징
@Nested 중첩 클래스를 통해 테스트 묶음 단위 설정이 가능하다.
서로 관련 있는 Unit 테스트간 묶음을 통해 테스트 코드 가독성을 증가시킬 수 있다.
묶음 단위 내에서의 순서를 통해 테스트 시나리오 작성이 가능하다.